diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..712dcac06 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +/app/libs/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..b8e815aed --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "app/src/main/jni/badvpn"] + path = app/src/main/jni/badvpn + url = https://github.com/shadowsocks/badvpn +[submodule "app/src/main/jni/libancillary"] + path = app/src/main/jni/libancillary + url = https://github.com/shadowsocks/libancillary +[submodule "v2ray"] + path = v2ray + url = https://github.com/nekohasekai/AndroidLibV2rayLite diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 000000000..26d33521a --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 000000000..a3b7a9050 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,140 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 000000000..79ee123c2 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 000000000..61a9130cd --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/dictionaries/sekai.xml b/.idea/dictionaries/sekai.xml new file mode 100644 index 000000000..da24e543a --- /dev/null +++ b/.idea/dictionaries/sekai.xml @@ -0,0 +1,11 @@ + + + + downlink + gson + snackbar + uplink + vmess + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 000000000..a3654909b --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,24 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 000000000..7ef070a38 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml new file mode 100644 index 000000000..237047402 --- /dev/null +++ b/.idea/jarRepositories.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 000000000..fe0fc8717 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 000000000..572ec2b85 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/README b/README new file mode 100644 index 000000000..f1c89c088 --- /dev/null +++ b/README @@ -0,0 +1,10 @@ +SagerNet +============ + +The universal proxy toolchain for Android, written in Kotlin. + +This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along with this program. If not, see http://www.gnu.org/licenses/. \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 000000000..bb230f390 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,113 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "kotlin-kapt" + id "kotlin-parcelize" + +} + +android { + compileSdkVersion 30 + buildToolsVersion "30.0.3" + + defaultConfig { + applicationId "io.nekohasekai.sagernet" + minSdkVersion 21 + targetSdkVersion 30 + versionCode 1 + versionName "0.1-SNAPSHOT" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + + externalNativeBuild.ndkBuild { + abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64" + arguments "-j${Runtime.getRuntime().availableProcessors()}" + } + + javaCompileOptions { + annotationProcessorOptions { + arguments += [ + "room.schemaLocation":"$projectDir/schemas".toString(), + "room.incremental":"true", + "room.expandProjection":"true"] + } + } + } + + externalNativeBuild.ndkBuild.path "src/main/jni/Android.mk" + + + buildTypes { + release { + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + coreLibraryDesugaringEnabled true + } + + packagingOptions { + exclude "/META-INF/*.version" + exclude "/META-INF/*.kotlin_module" + exclude "/META-INF/native-image/**" + exclude "/META-INF/INDEX.LIST" + exclude "DebugProbesKt.bin" + exclude "/kotlin/**" + } + + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + + implementation fileTree(dir: "libs") + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.3" + + implementation "androidx.core:core-ktx:1.6.0-alpha02" + implementation "androidx.activity:activity-ktx:1.3.0-alpha06" + implementation "androidx.fragment:fragment-ktx:1.3.2" + + implementation "androidx.constraintlayout:constraintlayout:2.0.4" + implementation "androidx.navigation:navigation-fragment-ktx:2.3.5" + implementation "androidx.navigation:navigation-ui-ktx:2.3.5" + implementation "androidx.preference:preference-ktx:1.1.1" + implementation "androidx.appcompat:appcompat:1.2.0" + + implementation "com.google.android.material:material:1.3.0" + implementation "com.github.daniel-stoneuk:material-about-library:3.2.0-rc01" + implementation "com.squareup.okhttp3:okhttp:5.0.0-alpha.2" + implementation "cn.hutool:hutool-core:5.6.3" + implementation "cn.hutool:hutool-json:5.6.3" + implementation "com.google.code.gson:gson:2.8.6" + implementation "me.weishu:free_reflection:3.0.1" + implementation "com.github.zawadz88.materialpopupmenu:material-popup-menu:4.1.0" + implementation "com.squareup.okhttp3:okhttp:5.0.0-alpha.2" + implementation 'androidx.preference:preference:1.1.1' + + def room_version = "2.2.6" + implementation "androidx.room:room-runtime:$room_version" + kapt "androidx.room:room-compiler:$room_version" + implementation "androidx.room:room-ktx:$room_version" + + def roomigrant_version = "0.3.4" + implementation "com.github.MatrixDev.Roomigrant:RoomigrantLib:$roomigrant_version" + kapt "com.github.MatrixDev.Roomigrant:RoomigrantCompiler:$roomigrant_version" + + implementation "com.esotericsoftware:kryo:5.1.0" + + testImplementation "junit:junit:4.13.2" + testImplementation "androidx.room:room-testing:$room_version" + androidTestImplementation "androidx.test.ext:junit:1.1.2" + androidTestImplementation "androidx.test.espresso:espresso-core:3.3.0" + + coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:1.1.5" + +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 000000000..497d5452a --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,23 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile +-dontobfuscate +-keepattributes SourceFile,LineNumberTable \ No newline at end of file diff --git a/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/1.json b/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/1.json new file mode 100644 index 000000000..902e5cee6 --- /dev/null +++ b/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/1.json @@ -0,0 +1,197 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "e798091fbf9ed3facd2985f7560c4975", + "entities": [ + { + "tableName": "proxy_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userOrder` INTEGER NOT NULL, `isDefault` INTEGER NOT NULL, `name` TEXT, `isSubscription` INTEGER NOT NULL, `subscriptionLinks` TEXT NOT NULL, `lastUpdate` INTEGER NOT NULL, `layout` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userOrder", + "columnName": "userOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDefault", + "columnName": "isDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSubscription", + "columnName": "isSubscription", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscriptionLinks", + "columnName": "subscriptionLinks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdate", + "columnName": "lastUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "layout", + "columnName": "layout", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "proxy_entities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL, `type` TEXT NOT NULL, `userOrder` INTEGER NOT NULL, `tx` INTEGER NOT NULL, `rx` INTEGER NOT NULL, `proxyApps` INTEGER NOT NULL, `individual` TEXT, `meteredNetwork` INTEGER NOT NULL, `vmessBean` BLOB, `socksBean` BLOB)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userOrder", + "columnName": "userOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tx", + "columnName": "tx", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rx", + "columnName": "rx", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proxyApps", + "columnName": "proxyApps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "individual", + "columnName": "individual", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "meteredNetwork", + "columnName": "meteredNetwork", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "vmessBean", + "columnName": "vmessBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "socksBean", + "columnName": "socksBean", + "affinity": "BLOB", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "groupId", + "unique": false, + "columnNames": [ + "groupId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `groupId` ON `${TABLE_NAME}` (`groupId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "KeyValuePair", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `valueType` INTEGER NOT NULL, `value` BLOB NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "valueType", + "columnName": "valueType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e798091fbf9ed3facd2985f7560c4975')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.nekohasekai.sagernet.database.preference.PublicDatabase/1.json b/app/schemas/io.nekohasekai.sagernet.database.preference.PublicDatabase/1.json new file mode 100644 index 000000000..4986920a2 --- /dev/null +++ b/app/schemas/io.nekohasekai.sagernet.database.preference.PublicDatabase/1.json @@ -0,0 +1,46 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "f1aab1fb633378621635c344dbc8ac7b", + "entities": [ + { + "tableName": "KeyValuePair", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `valueType` INTEGER NOT NULL, `value` BLOB NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "valueType", + "columnName": "valueType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f1aab1fb633378621635c344dbc8ac7b')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/io/nekohasekai/sagernet/ExampleInstrumentedTest.kt b/app/src/androidTest/java/io/nekohasekai/sagernet/ExampleInstrumentedTest.kt new file mode 100644 index 000000000..38d598995 --- /dev/null +++ b/app/src/androidTest/java/io/nekohasekai/sagernet/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package io.nekohasekai.sagernet + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("io.nekohasekai.sagernet", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..26355025c --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/aidl/com/github/shadowsocks/aidl/IShadowsocksService.aidl b/app/src/main/aidl/com/github/shadowsocks/aidl/IShadowsocksService.aidl new file mode 100644 index 000000000..026455be5 --- /dev/null +++ b/app/src/main/aidl/com/github/shadowsocks/aidl/IShadowsocksService.aidl @@ -0,0 +1,13 @@ +package com.github.shadowsocks.aidl; + +import com.github.shadowsocks.aidl.IShadowsocksServiceCallback; + +interface IShadowsocksService { + int getState(); + String getProfileName(); + + void registerCallback(in IShadowsocksServiceCallback cb); + void startListeningForBandwidth(in IShadowsocksServiceCallback cb, long timeout); + oneway void stopListeningForBandwidth(in IShadowsocksServiceCallback cb); + oneway void unregisterCallback(in IShadowsocksServiceCallback cb); +} diff --git a/app/src/main/aidl/com/github/shadowsocks/aidl/IShadowsocksServiceCallback.aidl b/app/src/main/aidl/com/github/shadowsocks/aidl/IShadowsocksServiceCallback.aidl new file mode 100644 index 000000000..b116db6d6 --- /dev/null +++ b/app/src/main/aidl/com/github/shadowsocks/aidl/IShadowsocksServiceCallback.aidl @@ -0,0 +1,10 @@ +package com.github.shadowsocks.aidl; + +import com.github.shadowsocks.aidl.TrafficStats; + +oneway interface IShadowsocksServiceCallback { + void stateChanged(int state, String profileName, String msg); + void trafficUpdated(long profileId, in TrafficStats stats); + // Traffic data has persisted to database, listener should refetch their data from database + void trafficPersisted(long profileId); +} diff --git a/app/src/main/aidl/com/github/shadowsocks/aidl/TrafficStats.aidl b/app/src/main/aidl/com/github/shadowsocks/aidl/TrafficStats.aidl new file mode 100644 index 000000000..8668fa849 --- /dev/null +++ b/app/src/main/aidl/com/github/shadowsocks/aidl/TrafficStats.aidl @@ -0,0 +1,3 @@ +package com.github.shadowsocks.aidl; + +parcelable TrafficStats; diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 000000000..cb34b42f9 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/github/shadowsocks/aidl/TrafficStats.kt b/app/src/main/java/com/github/shadowsocks/aidl/TrafficStats.kt new file mode 100644 index 000000000..31229ee4f --- /dev/null +++ b/app/src/main/java/com/github/shadowsocks/aidl/TrafficStats.kt @@ -0,0 +1,21 @@ + + +package com.github.shadowsocks.aidl + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class TrafficStats( + // Bytes per second + var txRate: Long = 0L, + var rxRate: Long = 0L, + + // Bytes for the current session + var txTotal: Long = 0L, + var rxTotal: Long = 0L, +) : Parcelable { + operator fun plus(other: TrafficStats) = TrafficStats( + txRate + other.txRate, rxRate + other.rxRate, + txTotal + other.txTotal, rxTotal + other.rxTotal) +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/Constants.kt b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt new file mode 100644 index 000000000..ae46cb202 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt @@ -0,0 +1,23 @@ +package io.nekohasekai.sagernet + +object Key { + + const val DB_PUBLIC = "configuration.db" + const val DB_PROFILE = "sager_net.db" + const val DISABLE_AEAD = "V2RAY_VMESS_AEAD_DISABLED" + + const val SERVICE_MODE = "service_mode" + const val MODE_VPN = 0 + const val MODE_PROXY = 1 + const val MODE_TRANS = 2 + +} + +object Action { + const val SERVICE = "com.github.shadowsocks.SERVICE" + const val CLOSE = "com.github.shadowsocks.CLOSE" + const val RELOAD = "com.github.shadowsocks.RELOAD" + const val ABORT = "com.github.shadowsocks.ABORT" + + const val EXTRA_PROFILE_ID = "com.github.shadowsocks.EXTRA_PROFILE_ID" +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/SagerApp.kt b/app/src/main/java/io/nekohasekai/sagernet/SagerApp.kt new file mode 100644 index 000000000..e3764d176 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/SagerApp.kt @@ -0,0 +1,103 @@ +package io.nekohasekai.sagernet + +import android.app.* +import android.app.admin.DevicePolicyManager +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.net.ConnectivityManager +import android.os.Build +import android.os.UserManager +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import io.nekohasekai.sagernet.bg.SagerConnection +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.ui.MainActivity +import io.nekohasekai.sagernet.utils.DeviceStorageApp +import me.weishu.reflection.Reflection + +class SagerApp : Application() { + + companion object { + lateinit var application: SagerApp + val deviceStorage by lazy { + if (Build.VERSION.SDK_INT < 24) application else DeviceStorageApp(application) + } + + val configureIntent: (Context) -> PendingIntent by lazy { + { + PendingIntent.getActivity(it, 0, Intent(application, MainActivity::class.java) + .setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT), 0) + } + } + val activity by lazy { application.getSystemService()!! } + val clipboard by lazy { application.getSystemService()!! } + val connectivity by lazy { application.getSystemService()!! } + val notification by lazy { application.getSystemService()!! } + val user by lazy { application.getSystemService()!! } + val packageInfo: PackageInfo by lazy { application.getPackageInfo(application.packageName) } + val directBootSupported by lazy { + Build.VERSION.SDK_INT >= 24 && application.getSystemService()?.storageEncryptionStatus == + DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE_PER_USER + } + + fun updateNotificationChannels() { + if (Build.VERSION.SDK_INT >= 26) @RequiresApi(26) { + notification.createNotificationChannels(listOf( + NotificationChannel("service-vpn", application.getText(R.string.service_vpn), + if (Build.VERSION.SDK_INT >= 28) NotificationManager.IMPORTANCE_MIN + else NotificationManager.IMPORTANCE_LOW), // #1355 + NotificationChannel("service-proxy", + application.getText(R.string.service_proxy), + NotificationManager.IMPORTANCE_LOW), + NotificationChannel("service-transproxy", + application.getText(R.string.service_transproxy), + NotificationManager.IMPORTANCE_LOW))) + } + } + + fun startService() = ContextCompat.startForegroundService(application, + Intent(application, SagerConnection.serviceClass)) + + fun reloadService() = + application.sendBroadcast(Intent(Action.RELOAD).setPackage(application.packageName)) + + fun stopService() = + application.sendBroadcast(Intent(Action.CLOSE).setPackage(application.packageName)) + + } + + override fun attachBaseContext(base: Context) { + super.attachBaseContext(base) + application = this + Reflection.unseal(base) + } + + override fun onCreate() { + super.onCreate() + DataStore.init() + updateNotificationChannels() + } + + fun getPackageInfo(packageName: String) = packageManager.getPackageInfo(packageName, + if (Build.VERSION.SDK_INT >= 28) PackageManager.GET_SIGNING_CERTIFICATES + else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES)!! + + fun trySetPrimaryClip(clip: String) = try { + clipboard.setPrimaryClip(ClipData.newPlainText(null, clip)) + true + } catch (e: RuntimeException) { + false + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + updateNotificationChannels() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt new file mode 100644 index 000000000..9eb8d4601 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt @@ -0,0 +1,308 @@ +package io.nekohasekai.sagernet.bg + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import android.os.IBinder +import android.os.RemoteCallbackList +import android.os.RemoteException +import com.github.shadowsocks.aidl.IShadowsocksService +import com.github.shadowsocks.aidl.IShadowsocksServiceCallback +import com.github.shadowsocks.aidl.TrafficStats +import io.nekohasekai.sagernet.Action +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.SagerDatabase +import io.nekohasekai.sagernet.ktx.broadcastReceiver +import io.nekohasekai.sagernet.ktx.readableMessage +import kotlinx.coroutines.* +import java.net.URL +import java.net.UnknownHostException + +class BaseService { + + enum class State(val canStop: Boolean = false) { + /** + * Idle state is only used by UI and will never be returned by BaseService. + */ + Idle, + Connecting(true), + Connected(true), + Stopping, + Stopped, + } + + interface ExpectedException + class ExpectedExceptionWrapper(e: Exception) : Exception(e.localizedMessage, e), + ExpectedException + + class Data internal constructor(private val service: Interface) { + var state = State.Stopped + var processes: GuardedProcessPool? = null + var proxy: ProxyInstance? = null + var notification: ServiceNotification? = null + + val closeReceiver = broadcastReceiver { _, intent -> + when (intent.action) { + Intent.ACTION_SHUTDOWN -> service.persistStats() + Action.RELOAD -> service.forceLoad() + else -> service.stopRunner() + } + } + var closeReceiverRegistered = false + + val binder = Binder(this) + var connectingJob: Job? = null + + fun changeState(s: State, msg: String? = null) { + if (state == s && msg == null) return + binder.stateChanged(s, msg) + state = s + } + } + + class Binder(private var data: Data? = null) : IShadowsocksService.Stub(), CoroutineScope, + AutoCloseable { + private val callbacks = object : RemoteCallbackList() { + override fun onCallbackDied(callback: IShadowsocksServiceCallback?, cookie: Any?) { + super.onCallbackDied(callback, cookie) + stopListeningForBandwidth(callback ?: return) + } + } + private val bandwidthListeners = + mutableMapOf() // the binder is the real identifier + override val coroutineContext = Dispatchers.Main.immediate + Job() + private var looper: Job? = null + + override fun getState(): Int = (data?.state ?: State.Idle).ordinal + override fun getProfileName(): String = data?.proxy?.profile?.requireBean()?.name ?: "Idle" + + override fun registerCallback(cb: IShadowsocksServiceCallback) { + callbacks.register(cb) + } + + private fun broadcast(work: (IShadowsocksServiceCallback) -> Unit) { + val count = callbacks.beginBroadcast() + try { + repeat(count) { + try { + work(callbacks.getBroadcastItem(it)) + } catch (_: RemoteException) { + } catch (e: Exception) { + } + } + } finally { + callbacks.finishBroadcast() + } + } + + private suspend fun loop() { + var lastQueryTime = 0L + while (true) { + delay(bandwidthListeners.values.minOrNull() ?: return) + val queryTime = System.currentTimeMillis() + val sinceLastQueryInSeconds = (queryTime - lastQueryTime) / 1000.0 + val proxy = data?.proxy ?: continue + lastQueryTime = queryTime + val up = proxy.uplink + val down = proxy.downlink + if (up + down == 0L) continue + val stats = TrafficStats(up / sinceLastQueryInSeconds.toLong(), + down / sinceLastQueryInSeconds.toLong(), + up, down) + if (data?.state == State.Connected && bandwidthListeners.isNotEmpty()) { + broadcast { item -> + if (bandwidthListeners.contains(item.asBinder())) { + item.trafficUpdated(proxy.profile.id, stats) + } + } + } + + } + + } + + override fun startListeningForBandwidth( + cb: IShadowsocksServiceCallback, + timeout: Long, + ) { + launch { + if (bandwidthListeners.isEmpty() and (bandwidthListeners.put(cb.asBinder(), + timeout) == null) + ) { + check(looper == null) + looper = launch { loop() } + } + if (data?.state != State.Connected) return@launch + val data = data + data?.proxy ?: return@launch + val sum = TrafficStats() + cb.trafficUpdated(0, sum) + } + } + + override fun stopListeningForBandwidth(cb: IShadowsocksServiceCallback) { + launch { + if (bandwidthListeners.remove(cb.asBinder()) != null && bandwidthListeners.isEmpty()) { + looper!!.cancel() + looper = null + } + } + } + + override fun unregisterCallback(cb: IShadowsocksServiceCallback) { + stopListeningForBandwidth(cb) // saves an RPC, and safer + callbacks.unregister(cb) + } + + fun stateChanged(s: State, msg: String?) = launch { + val profileName = profileName + broadcast { it.stateChanged(s.ordinal, profileName, msg) } + } + + fun trafficPersisted(ids: List) = launch { + if (bandwidthListeners.isNotEmpty() && ids.isNotEmpty()) broadcast { item -> + if (bandwidthListeners.contains(item.asBinder())) ids.forEach(item::trafficPersisted) + } + } + + override fun close() { + callbacks.kill() + cancel() + data = null + } + } + + interface Interface { + val data: Data + val tag: String + fun createNotification(profileName: String): ServiceNotification + + fun onBind(intent: Intent): IBinder? = + if (intent.action == Action.SERVICE) data.binder else null + + fun forceLoad() { + stopRunner(false, (this as Context).getString(R.string.profile_empty)) + val s = data.state + when { + s == State.Stopped -> startRunner() + s.canStop -> stopRunner(true) + //else -> Timber.w("Illegal state $s when invoking use") + } + } + + val isVpnService get() = false + + suspend fun startProcesses() { + GlobalScope.launch(Dispatchers.IO) { data.proxy!!.start() } + } + + fun startRunner() { + this as Context + if (Build.VERSION.SDK_INT >= 26) startForegroundService(Intent(this, javaClass)) + else startService(Intent(this, javaClass)) + } + + fun killProcesses(scope: CoroutineScope) { + data.proxy?.stop() + data.processes?.run { + close(scope) + data.processes = null + } + } + + fun stopRunner(restart: Boolean = false, msg: String? = null) { + if (data.state == State.Stopping) return + // channge the state + data.changeState(State.Stopping) + GlobalScope.launch(Dispatchers.Main.immediate) { + data.connectingJob?.cancelAndJoin() // ensure stop connecting first + this@Interface as Service + // we use a coroutineScope here to allow clean-up in parallel + coroutineScope { + killProcesses(this) + // clean up receivers + val data = data + if (data.closeReceiverRegistered) { + unregisterReceiver(data.closeReceiver) + data.closeReceiverRegistered = false + } + + data.notification?.destroy() + data.notification = null + data.binder.trafficPersisted(listOfNotNull(data.proxy).map { it.profile.id }) + data.proxy = null + } + + // change the state + data.changeState(State.Stopped, msg) + + // stop the service if nothing has bound to it + if (restart) startRunner() else { + // BootReceiver.enabled = false + stopSelf() + } + } + } + + fun persistStats() = + listOfNotNull(data.proxy).forEach { it.persistStats() } + + suspend fun preInit() {} + suspend fun openConnection(url: URL) = url.openConnection() + + fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val data = data + if (data.state != State.Stopped) return Service.START_NOT_STICKY + val profile = SagerDatabase.proxyDao.getById(DataStore.selectedProxy) + this as Context + if (profile == null) { + // gracefully shutdown: https://stackoverflow.com/q/47337857/2245107 + data.notification = createNotification("") + stopRunner(false, getString(R.string.profile_empty)) + return Service.START_NOT_STICKY + } + val proxy = ProxyInstance(profile) + data.proxy = proxy + if (!data.closeReceiverRegistered) { + registerReceiver(data.closeReceiver, IntentFilter().apply { + addAction(Action.RELOAD) + addAction(Intent.ACTION_SHUTDOWN) + addAction(Action.CLOSE) + }, "$packageName.SERVICE", null) + data.closeReceiverRegistered = true + } + + data.notification = createNotification(profile.requireBean().name) + + data.changeState(State.Connecting) + data.connectingJob = GlobalScope.launch(Dispatchers.Main) { + try { + Executable.killAll() // clean up old processes + preInit() + proxy.init(this@Interface) + data.processes = GuardedProcessPool { +// Timber.w(it) + stopRunner(false, it.readableMessage) + } + startProcesses() + data.changeState(State.Connected) + } catch (_: CancellationException) { + // if the job was cancelled, it is canceller's responsibility to call stopRunner + } catch (_: UnknownHostException) { + stopRunner(false, getString(R.string.invalid_server)) + } catch (exc: Throwable) { +// if (exc is ExpectedException) Timber.d(exc) else Timber.w(exc) + stopRunner(false, + "${getString(R.string.service_failed)}: ${exc.readableMessage}") + } finally { + data.connectingJob = null + } + } + return Service.START_NOT_STICKY + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/Executable.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/Executable.kt new file mode 100644 index 000000000..bd3b69cab --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/Executable.kt @@ -0,0 +1,35 @@ +package io.nekohasekai.sagernet.bg + +import android.system.ErrnoException +import android.system.Os +import android.system.OsConstants +import android.text.TextUtils +import io.nekohasekai.sagernet.ktx.Logs +import java.io.File +import java.io.IOException + +object Executable { + const val SS_LOCAL = "libsslocal.so" + const val TUN2SOCKS = "libtun2socks.so" + + private val EXECUTABLES = setOf(SS_LOCAL, TUN2SOCKS) + + fun killAll() { + for (process in File("/proc").listFiles { _, name -> TextUtils.isDigitsOnly(name) } + ?: return) { + val exe = File(try { + File(process, "cmdline").inputStream().bufferedReader().readText() + } catch (_: IOException) { + continue + }.split(Character.MIN_VALUE, limit = 2).first()) + if (EXECUTABLES.contains(exe.name)) try { + Os.kill(process.name.toInt(), OsConstants.SIGKILL) + } catch (e: ErrnoException) { + if (e.errno != OsConstants.ESRCH) { + Logs.w("SIGKILL ${exe.absolutePath} (${process.name}) failed") + Logs.w(e) + } + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt new file mode 100644 index 000000000..bd028f8e3 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt @@ -0,0 +1,110 @@ +package io.nekohasekai.sagernet.bg + +import android.os.Build +import android.os.SystemClock +import android.system.ErrnoException +import android.system.Os +import android.system.OsConstants +import android.util.Log +import androidx.annotation.MainThread +import io.nekohasekai.sagernet.SagerApp +import io.nekohasekai.sagernet.ktx.Logs +import io.nekohasekai.sagernet.utils.Commandline +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import java.io.File +import java.io.IOException +import java.io.InputStream +import kotlin.concurrent.thread + +class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : CoroutineScope { + companion object { + private val pid by lazy { + Class.forName("java.lang.ProcessManager\$ProcessImpl").getDeclaredField("pid") + .apply { isAccessible = true } + } + } + + private inner class Guard(private val cmd: List) { + private lateinit var process: Process + + private fun streamLogger(input: InputStream, logger: (String) -> Unit) = try { + input.bufferedReader().forEachLine(logger) + } catch (_: IOException) { + } // ignore + + fun start() { + process = ProcessBuilder(cmd).directory(SagerApp.deviceStorage.noBackupFilesDir).start() + } + + suspend fun looper(onRestartCallback: (suspend () -> Unit)?) { + var running = true + val cmdName = File(cmd.first()).nameWithoutExtension + val exitChannel = Channel() + try { + while (true) { + thread(name = "stderr-$cmdName") { + streamLogger(process.errorStream) { Log.e(cmdName, it) } + } + thread(name = "stdout-$cmdName") { + streamLogger(process.inputStream) { Log.v(cmdName, it) } + // this thread also acts as a daemon thread for waitFor + runBlocking { exitChannel.send(process.waitFor()) } + } + val startTime = SystemClock.elapsedRealtime() + val exitCode = exitChannel.receive() + running = false + when { + SystemClock.elapsedRealtime() - startTime < 1000 -> throw IOException( + "$cmdName exits too fast (exit code: $exitCode)") + exitCode == 128 + OsConstants.SIGKILL -> Logs.w("$cmdName was killed") + else -> Logs.w(IOException("$cmdName unexpectedly exits with code $exitCode")) + } + Logs.i("restart process: ${Commandline.toString(cmd)} (last exit code: $exitCode)") + start() + running = true + onRestartCallback?.invoke() + } + } catch (e: IOException) { + Logs.w("error occurred. stop guard: ${Commandline.toString(cmd)}") + GlobalScope.launch(Dispatchers.Main) { onFatal(e) } + } finally { + if (running) withContext(NonCancellable) { // clean-up cannot be cancelled + if (Build.VERSION.SDK_INT < 24) { + try { + Os.kill(pid.get(process) as Int, OsConstants.SIGTERM) + } catch (e: ErrnoException) { + if (e.errno != OsConstants.ESRCH) Logs.w(e) + } catch (e: ReflectiveOperationException) { + Logs.w(e) + } + if (withTimeoutOrNull(500) { exitChannel.receive() } != null) return@withContext + } + process.destroy() // kill the process + if (Build.VERSION.SDK_INT >= 26) { + if (withTimeoutOrNull(1000) { exitChannel.receive() } != null) return@withContext + process.destroyForcibly() // Force to kill the process if it's still alive + } + exitChannel.receive() + } // otherwise process already exited, nothing to be done + } + } + } + + override val coroutineContext = Dispatchers.Main.immediate + Job() + + @MainThread + fun start(cmd: List, onRestartCallback: (suspend () -> Unit)? = null) { + Logs.i("start process: ${Commandline.toString(cmd)}") + Guard(cmd).apply { + start() // if start fails, IOException will be thrown directly + launch { looper(onRestartCallback) } + } + } + + @MainThread + fun close(scope: CoroutineScope) { + cancel() + coroutineContext[Job]!!.also { job -> scope.launch { job.join() } } + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/ProxyInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/ProxyInstance.kt new file mode 100644 index 000000000..3470c3aaf --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/ProxyInstance.kt @@ -0,0 +1,83 @@ +package io.nekohasekai.sagernet.bg + +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.ProxyEntity +import io.nekohasekai.sagernet.database.SagerDatabase +import io.nekohasekai.sagernet.fmt.socks.SOCKSBean +import io.nekohasekai.sagernet.fmt.v2ray.buildV2rayConfig +import io.nekohasekai.sagernet.ktx.Logs +import libv2ray.Libv2ray +import libv2ray.V2RayPoint +import libv2ray.V2RayVPNServiceSupportsSet +import java.io.IOException + +class ProxyInstance(val profile: ProxyEntity) { + + lateinit var v2rayPoint: V2RayPoint + lateinit var service: VpnService + + fun init(service: BaseService.Interface) { + v2rayPoint = Libv2ray.newV2RayPoint(SagerSupportClass(if (service is VpnService) + service else null), false) + if (profile.requireBean() is SOCKSBean) { + val socks = profile.requireSOCKS() + v2rayPoint.domainName = socks.serverAddress + ":" + socks.serverPort + v2rayPoint.configureFileContent = buildV2rayConfig(socks, + if (DataStore.allowAccess) "0.0.0.0" else "127.0.0.1", + DataStore.socks5Port + ) + } + } + + fun start() { + v2rayPoint.runLoop(DataStore.preferIpv6) + println("Satrted") + } + + fun stop() { + v2rayPoint.stopLoop() + } + + val uplink + get() = if (!::v2rayPoint.isInitialized) -1L else v2rayPoint.queryStats("out", + "uplink") + val downlink + get() = if (!::v2rayPoint.isInitialized) -1L else v2rayPoint.queryStats("out", + "downlink") + + fun persistStats() { + try { + profile.tx += uplink + profile.rx += downlink + SagerDatabase.proxyDao.updateProxy(profile) + } catch (e: IOException) { + /* if (!DataStore.directBootAware) throw e*/ // we should only reach here because we're in direct boot + } + } + + private class SagerSupportClass(val service: VpnService?) : V2RayVPNServiceSupportsSet { + + override fun onEmitStatus(p0: Long, status: String): Long { + Logs.i("onEmitStatus $status") + return 0L + } + + override fun prepare(): Long { + return 0L + } + + override fun protect(l: Long): Boolean { + return (service ?: return true).protect(l.toInt()) + } + + override fun setup(p0: String?): Long { + return 0 + } + + override fun shutdown(): Long { + return 0 + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/ProxyService.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/ProxyService.kt new file mode 100644 index 000000000..4906bef90 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/ProxyService.kt @@ -0,0 +1,20 @@ +package io.nekohasekai.sagernet.bg + +import android.app.Service +import android.content.Intent + +class ProxyService : Service(), BaseService.Interface { + override val data = BaseService.Data(this) + override val tag: String get() = "SagerNetProxyService" + override fun createNotification(profileName: String): ServiceNotification = + ServiceNotification(this, profileName, "service-proxy", true) + + override fun onBind(intent: Intent) = super.onBind(intent) + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = + super.onStartCommand(intent, flags, startId) + + override fun onDestroy() { + super.onDestroy() + data.binder.close() + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/SagerConnection.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/SagerConnection.kt new file mode 100644 index 000000000..a3e7c8cfe --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/SagerConnection.kt @@ -0,0 +1,149 @@ +package io.nekohasekai.sagernet.bg + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import android.os.RemoteException +import com.github.shadowsocks.aidl.IShadowsocksService +import com.github.shadowsocks.aidl.IShadowsocksServiceCallback +import com.github.shadowsocks.aidl.TrafficStats +import io.nekohasekai.sagernet.Action +import io.nekohasekai.sagernet.Key +import io.nekohasekai.sagernet.database.DataStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +class SagerConnection(private var listenForDeath: Boolean = false) : ServiceConnection, + IBinder.DeathRecipient { + companion object { + val serviceClass + get() = when (DataStore.serviceMode) { + Key.MODE_PROXY -> ProxyService::class + Key.MODE_VPN -> VpnService::class + // Key.MODE_TRANS -> TransproxyService::class + else -> throw UnknownError() + }.java + } + + interface Callback { + fun stateChanged(state: BaseService.State, profileName: String?, msg: String?) + fun trafficUpdated(profileId: Long, stats: TrafficStats) {} + fun trafficPersisted(profileId: Long) {} + + fun onServiceConnected(service: IShadowsocksService) + + /** + * Different from Android framework, this method will be called even when you call `detachService`. + */ + fun onServiceDisconnected() {} + fun onBinderDied() {} + } + + private var connectionActive = false + private var callbackRegistered = false + private var callback: Callback? = null + private val serviceCallback = object : IShadowsocksServiceCallback.Stub() { + override fun stateChanged(state: Int, profileName: String?, msg: String?) { + val callback = callback ?: return + GlobalScope.launch(Dispatchers.Main.immediate) { + callback.stateChanged(BaseService.State.values()[state], profileName, msg) + } + } + + override fun trafficUpdated(profileId: Long, stats: TrafficStats) { + val callback = callback ?: return + GlobalScope.launch(Dispatchers.Main.immediate) { + callback.trafficUpdated(profileId, + stats) + } + } + + override fun trafficPersisted(profileId: Long) { + val callback = callback ?: return + GlobalScope.launch(Dispatchers.Main.immediate) { callback.trafficPersisted(profileId) } + } + } + private var binder: IBinder? = null + + var bandwidthTimeout = 0L + set(value) { + try { + if (value > 0) service?.startListeningForBandwidth(serviceCallback, value) + else service?.stopListeningForBandwidth(serviceCallback) + } catch (_: RemoteException) { + } + field = value + } + var service: IShadowsocksService? = null + + override fun onServiceConnected(name: ComponentName?, binder: IBinder) { + this.binder = binder + val service = IShadowsocksService.Stub.asInterface(binder)!! + this.service = service + try { + if (listenForDeath) binder.linkToDeath(this, 0) + check(!callbackRegistered) + service.registerCallback(serviceCallback) + callbackRegistered = true + if (bandwidthTimeout > 0) service.startListeningForBandwidth(serviceCallback, + bandwidthTimeout) + } catch (e: RemoteException) { + e.printStackTrace() + } + callback!!.onServiceConnected(service) + } + + override fun onServiceDisconnected(name: ComponentName?) { + unregisterCallback() + callback?.onServiceDisconnected() + service = null + binder = null + } + + override fun binderDied() { + service = null + callbackRegistered = false + callback?.also { GlobalScope.launch(Dispatchers.Main.immediate) { it.onBinderDied() } } + } + + private fun unregisterCallback() { + val service = service + if (service != null && callbackRegistered) try { + service.unregisterCallback(serviceCallback) + } catch (_: RemoteException) { + } + callbackRegistered = false + } + + fun connect(context: Context, callback: Callback) { + if (connectionActive) return + connectionActive = true + check(this.callback == null) + this.callback = callback + val intent = Intent(context, serviceClass).setAction(Action.SERVICE) + context.bindService(intent, this, Context.BIND_AUTO_CREATE) + } + + fun disconnect(context: Context) { + unregisterCallback() + if (connectionActive) try { + context.unbindService(this) + } catch (_: IllegalArgumentException) { + } // ignore + connectionActive = false + if (listenForDeath) try { + binder?.unlinkToDeath(this, 0) + } catch (_: NoSuchElementException) { + } + binder = null + try { + service?.stopListeningForBandwidth(serviceCallback) + } catch (_: RemoteException) { + } + service = null + callback = null + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt new file mode 100644 index 000000000..13a748555 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt @@ -0,0 +1,111 @@ +package io.nekohasekai.sagernet.bg + +import android.app.PendingIntent +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import android.os.PowerManager +import android.text.format.Formatter +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import com.github.shadowsocks.aidl.IShadowsocksServiceCallback +import com.github.shadowsocks.aidl.TrafficStats +import io.nekohasekai.sagernet.Action +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.SagerApp + +/** + * User can customize visibility of notification since Android 8. + * The default visibility: + * + * Android 8.x: always visible due to system limitations + * VPN: always invisible because of VPN notification/icon + * Other: always visible + * + * See also: https://github.com/aosp-mirror/platform_frameworks_base/commit/070d142993403cc2c42eca808ff3fafcee220ac4 + */ +class ServiceNotification( + private val service: BaseService.Interface, profileName: String, + channel: String, visible: Boolean = false, +) : BroadcastReceiver() { + private val callback: IShadowsocksServiceCallback by lazy { + object : IShadowsocksServiceCallback.Stub() { + override fun stateChanged(state: Int, profileName: String?, msg: String?) {} // ignore + override fun trafficUpdated(profileId: Long, stats: TrafficStats) { + if (profileId != 0L) return + builder.apply { + setContentText((service as Context).getString(R.string.traffic, + service.getString(R.string.speed, + Formatter.formatFileSize(service, stats.txRate)), + service.getString(R.string.speed, + Formatter.formatFileSize(service, stats.rxRate)))) + setSubText(service.getString(R.string.traffic, + Formatter.formatFileSize(service, stats.txTotal), + Formatter.formatFileSize(service, stats.rxTotal))) + } + show() + } + + override fun trafficPersisted(profileId: Long) {} + } + } + private var callbackRegistered = false + + private val builder = NotificationCompat.Builder(service as Context, channel) + .setWhen(0) + .setColor(ContextCompat.getColor(service, R.color.material_primary_500)) + .setTicker(service.getString(R.string.forward_success)) + .setContentTitle(profileName) + .setContentIntent(SagerApp.configureIntent(service)) + .setSmallIcon(R.drawable.ic_service_active) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setPriority(if (visible) NotificationCompat.PRIORITY_LOW else NotificationCompat.PRIORITY_MIN) + + init { + service as Context + val closeAction = NotificationCompat.Action.Builder( + R.drawable.ic_navigation_close, + service.getText(R.string.stop), + PendingIntent.getBroadcast(service, + 0, + Intent(Action.CLOSE).setPackage(service.packageName), + 0)).apply { + setShowsUserInterface(false) + }.build() + if (Build.VERSION.SDK_INT < 24) builder.addAction(closeAction) else builder.addInvisibleAction( + closeAction) + updateCallback(service.getSystemService()?.isInteractive != false) + service.registerReceiver(this, IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_ON) + addAction(Intent.ACTION_SCREEN_OFF) + }) + show() + } + + override fun onReceive(context: Context, intent: Intent) { + if (service.data.state == BaseService.State.Connected) updateCallback(intent.action == Intent.ACTION_SCREEN_ON) + } + + private fun updateCallback(screenOn: Boolean) { + if (screenOn) { + service.data.binder.registerCallback(callback) + service.data.binder.startListeningForBandwidth(callback, 1000) + callbackRegistered = true + } else if (callbackRegistered) { // unregister callback to save battery + service.data.binder.unregisterCallback(callback) + callbackRegistered = false + } + } + + private fun show() = (service as Service).startForeground(1, builder.build()) + + fun destroy() { + (service as Service).unregisterReceiver(this) + updateCallback(false) + service.stopForeground(true) + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/VpnService.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/VpnService.kt new file mode 100644 index 000000000..ebde001f0 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/VpnService.kt @@ -0,0 +1,349 @@ +package io.nekohasekai.sagernet.bg + +import android.app.Service +import android.content.Intent +import android.net.LocalSocket +import android.net.LocalSocketAddress +import android.os.Build +import android.os.ParcelFileDescriptor +import android.system.ErrnoException +import android.system.Os +import io.nekohasekai.sagernet.Key +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.SagerApp +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.ui.VpnRequestActivity +import io.nekohasekai.sagernet.utils.Subnet +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import java.io.File +import java.io.FileDescriptor +import java.io.IOException +import android.net.VpnService as BaseVpnService + +class VpnService : BaseVpnService(), BaseService.Interface { + + companion object { + private const val VPN_MTU = 1500 + private const val PRIVATE_VLAN4_CLIENT = "172.19.0.1" + private const val PRIVATE_VLAN4_ROUTER = "172.19.0.2" + private const val PRIVATE_VLAN6_CLIENT = "fdfe:dcba:9876::1" + private const val PRIVATE_VLAN6_ROUTER = "fdfe:dcba:9876::2" + + private val PRIVATE_ROUTES = arrayOf( + "1.0.0.0/8", + "2.0.0.0/7", + "4.0.0.0/6", + "8.0.0.0/7", + "11.0.0.0/8", + "12.0.0.0/6", + "16.0.0.0/4", + "32.0.0.0/3", + "64.0.0.0/3", + "96.0.0.0/6", + "100.0.0.0/10", + "100.128.0.0/9", + "101.0.0.0/8", + "102.0.0.0/7", + "104.0.0.0/5", + "112.0.0.0/10", + "112.64.0.0/11", + "112.96.0.0/12", + "112.112.0.0/13", + "112.120.0.0/14", + "112.124.0.0/19", + "112.124.32.0/21", + "112.124.40.0/22", + "112.124.44.0/23", + "112.124.46.0/24", + "112.124.48.0/20", + "112.124.64.0/18", + "112.124.128.0/17", + "112.125.0.0/16", + "112.126.0.0/15", + "112.128.0.0/9", + "113.0.0.0/8", + "114.0.0.0/10", + "114.64.0.0/11", + "114.96.0.0/12", + "114.112.0.0/15", + "114.114.0.0/18", + "114.114.64.0/19", + "114.114.96.0/20", + "114.114.112.0/23", + "114.114.115.0/24", + "114.114.116.0/22", + "114.114.120.0/21", + "114.114.128.0/17", + "114.115.0.0/16", + "114.116.0.0/14", + "114.120.0.0/13", + "114.128.0.0/9", + "115.0.0.0/8", + "116.0.0.0/6", + "120.0.0.0/6", + "124.0.0.0/7", + "126.0.0.0/8", + "128.0.0.0/3", + "160.0.0.0/5", + "168.0.0.0/8", + "169.0.0.0/9", + "169.128.0.0/10", + "169.192.0.0/11", + "169.224.0.0/12", + "169.240.0.0/13", + "169.248.0.0/14", + "169.252.0.0/15", + "169.255.0.0/16", + "170.0.0.0/7", + "172.0.0.0/12", + "172.32.0.0/11", + "172.64.0.0/10", + "172.128.0.0/9", + "173.0.0.0/8", + "174.0.0.0/7", + "176.0.0.0/4", + "192.0.0.8/29", + "192.0.0.16/28", + "192.0.0.32/27", + "192.0.0.64/26", + "192.0.0.128/25", + "192.0.1.0/24", + "192.0.3.0/24", + "192.0.4.0/22", + "192.0.8.0/21", + "192.0.16.0/20", + "192.0.32.0/19", + "192.0.64.0/18", + "192.0.128.0/17", + "192.1.0.0/16", + "192.2.0.0/15", + "192.4.0.0/14", + "192.8.0.0/13", + "192.16.0.0/12", + "192.32.0.0/11", + "192.64.0.0/12", + "192.80.0.0/13", + "192.88.0.0/18", + "192.88.64.0/19", + "192.88.96.0/23", + "192.88.98.0/24", + "192.88.100.0/22", + "192.88.104.0/21", + "192.88.112.0/20", + "192.88.128.0/17", + "192.89.0.0/16", + "192.90.0.0/15", + "192.92.0.0/14", + "192.96.0.0/11", + "192.128.0.0/11", + "192.160.0.0/13", + "192.169.0.0/16", + "192.170.0.0/15", + "192.172.0.0/14", + "192.176.0.0/12", + "192.192.0.0/10", + "193.0.0.0/8", + "194.0.0.0/7", + "196.0.0.0/7", + "198.0.0.0/12", + "198.16.0.0/15", + "198.20.0.0/14", + "198.24.0.0/13", + "198.32.0.0/12", + "198.48.0.0/15", + "198.50.0.0/16", + "198.51.0.0/18", + "198.51.64.0/19", + "198.51.96.0/22", + "198.51.101.0/24", + "198.51.102.0/23", + "198.51.104.0/21", + "198.51.112.0/20", + "198.51.128.0/17", + "198.52.0.0/14", + "198.56.0.0/13", + "198.64.0.0/10", + "198.128.0.0/9", + "199.0.0.0/8", + "200.0.0.0/7", + "202.0.0.0/8", + "203.0.0.0/18", + "203.0.64.0/19", + "203.0.96.0/20", + "203.0.112.0/24", + "203.0.114.0/23", + "203.0.116.0/22", + "203.0.120.0/21", + "203.0.128.0/17", + "203.1.0.0/16", + "203.2.0.0/15", + "203.4.0.0/14", + "203.8.0.0/13", + "203.16.0.0/12", + "203.32.0.0/11", + "203.64.0.0/10", + "203.128.0.0/9", + "204.0.0.0/6", + "208.0.0.0/4", + ) + + private fun FileDescriptor.use(block: (FileDescriptor) -> T) = try { + block(this) + } finally { + try { + Os.close(this) + } catch (_: ErrnoException) { + } + } + } + + private var conn: ParcelFileDescriptor? = null + private var active = false + private var metered = false + + override suspend fun startProcesses() { + super.startProcesses() + sendFd(startVpn()) + } + + override fun killProcesses(scope: CoroutineScope) { + super.killProcesses(scope) + active = false + conn?.close() + } + + + override fun onBind(intent: Intent) = when (intent.action) { + SERVICE_INTERFACE -> super.onBind(intent) + else -> super.onBind(intent) + } + + override val data = BaseService.Data(this) + override val tag = "SagerNetVpnService" + override fun createNotification(profileName: String) = + ServiceNotification(this, profileName, "service-vpn") + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (DataStore.serviceMode == Key.MODE_VPN) { + if (prepare(this) != null) { + startActivity(Intent(this, + VpnRequestActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } else return super.onStartCommand(intent, flags, startId) + } + stopRunner() + return Service.START_NOT_STICKY + } + + inner class NullConnectionException : NullPointerException(), BaseService.ExpectedException { + override fun getLocalizedMessage() = getString(R.string.reboot_required) + } + + private suspend fun startVpn(): FileDescriptor { + val profile = data.proxy!!.profile + val builder = Builder() + .setConfigureIntent(SagerApp.configureIntent(this)) + .setSession(profile.displayName()) + .setMtu(VPN_MTU) + .addAddress(PRIVATE_VLAN4_CLIENT, 30) + + PRIVATE_ROUTES.forEach { + val subnet = Subnet.fromString(it)!! + builder.addRoute(subnet.address.hostAddress, subnet.prefixSize) + } + + builder.addRoute(PRIVATE_VLAN4_ROUTER, 32) + // https://issuetracker.google.com/issues/149636790 + if (DataStore.ipv6Route) { + builder.addRoute("2000::", 3) + } + + /* val proxyApps = when (profile.proxyApps) { + 0 -> DataStore.proxyApps > 0 + 1 -> false + else -> true + } + val bypass = when (profile.proxyApps) { + 0 -> DataStore.proxyApps == 2 + 3 -> true + else -> false + } + + if (proxyApps) { + + val me = packageName + (profile.individual ?: DataStore.individual ?: "").split('\n') + .filter { it.isNotBlank() && it != me } + .forEach { + try { + if (bypass) builder.addDisallowedApplication(it) + else builder.addAllowedApplication(it) + } catch (ex: PackageManager.NameNotFoundException) { + // Timber.w(ex) + } + } + + } + */ + builder.addDisallowedApplication("com.github.shadowsocks") +// builder.addDisallowedApplication(packageName) + + metered = when (profile.meteredNetwork) { + 0 -> DataStore.meteredNetwork + 1 -> false + else -> true + } + active = true // possible race condition here? +// builder.setUnderlyingNetworks(underlyingNetworks) + if (Build.VERSION.SDK_INT >= 29) builder.setMetered(metered) + + val conn = builder.establish() ?: throw NullConnectionException() + this.conn = conn + + val cmd = + arrayListOf(File(applicationInfo.nativeLibraryDir, Executable.TUN2SOCKS).canonicalPath, + "--netif-ipaddr", + PRIVATE_VLAN4_ROUTER, + "--socks-server-addr", + "127.0.0.1:${DataStore.socks5Port}", + "--tunmtu", + VPN_MTU.toString(), + "--sock-path", + File(SagerApp.deviceStorage.noBackupFilesDir, "sock_path").canonicalPath, + "--loglevel", "debug") + if (DataStore.ipv6Route) { + cmd += "--netif-ip6addr" + cmd += PRIVATE_VLAN6_ROUTER + } + // cmd += "--enable-udprelay" + data.processes!!.start(cmd, onRestartCallback = { + try { + sendFd(conn.fileDescriptor) + } catch (e: ErrnoException) { + stopRunner(false, e.message) + } + }) + return conn.fileDescriptor + } + + private suspend fun sendFd(fd: FileDescriptor) { + var tries = 0 + val path = File(SagerApp.deviceStorage.noBackupFilesDir, "sock_path").canonicalPath + while (true) try { + delay(50L shl tries) + LocalSocket().use { localSocket -> + localSocket.connect(LocalSocketAddress(path, + LocalSocketAddress.Namespace.FILESYSTEM)) + localSocket.setFileDescriptorsForSend(arrayOf(fd)) + localSocket.outputStream.write(42) + } + System.out.println("FD Sended") + return + } catch (e: IOException) { + if (tries > 5) throw e + tries += 1 + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt b/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt new file mode 100644 index 000000000..4641ec7e6 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt @@ -0,0 +1,41 @@ +package io.nekohasekai.sagernet.database + +import android.os.Build +import io.nekohasekai.sagernet.Key +import io.nekohasekai.sagernet.SagerApp +import io.nekohasekai.sagernet.database.preference.PublicDatabase +import io.nekohasekai.sagernet.database.preference.RoomPreferenceDataStore +import io.nekohasekai.sagernet.ktx.boolean +import io.nekohasekai.sagernet.ktx.int +import io.nekohasekai.sagernet.ktx.long +import io.nekohasekai.sagernet.ktx.string +import kotlinx.coroutines.DEBUG_PROPERTY_NAME +import kotlinx.coroutines.DEBUG_PROPERTY_VALUE_ON + +object DataStore { + + val publicStore = RoomPreferenceDataStore(PublicDatabase.kvPairDao) + val sagerStore = RoomPreferenceDataStore(SagerDatabase.kvPairDao) + + fun init() { + if (Build.VERSION.SDK_INT >= 24) { + SagerApp.deviceStorage.moveDatabaseFrom(SagerApp.application, Key.DB_PUBLIC) + } + + System.setProperty(DEBUG_PROPERTY_NAME, DEBUG_PROPERTY_VALUE_ON) + + } + + var serviceMode by sagerStore.int(Key.SERVICE_MODE) + var selectedProxy by sagerStore.long("selected_proxy") + var allowAccess by sagerStore.boolean("allow_access") + var socks5Port by sagerStore.int("socks5_port") { 3389 } + var useHttp by sagerStore.boolean("use_http") + var httpPort by sagerStore.long("http_port") + var ipv6Route by sagerStore.boolean("ipv6_route") + var preferIpv6 by sagerStore.boolean("prefer_ipv6") + var meteredNetwork by sagerStore.boolean("metered_network") + var proxyApps by sagerStore.int("proxyApps") + var individual by sagerStore.string("individual") + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt b/app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt new file mode 100644 index 000000000..9e8c65020 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt @@ -0,0 +1,65 @@ +package io.nekohasekai.sagernet.database + +import androidx.room.* +import io.nekohasekai.sagernet.fmt.AbstractBean +import io.nekohasekai.sagernet.fmt.socks.SOCKSBean +import io.nekohasekai.sagernet.fmt.v2ray.VMessBean + +@Entity(tableName = "proxy_entities", indices = [ + Index("groupId", name = "groupId") +]) +class ProxyEntity( + @PrimaryKey(autoGenerate = true) + var id: Long = 0L, + var groupId: Long, + var type: String, + var userOrder: Long = 0L, + var tx: Long = 0L, + var rx: Long = 0L, + var proxyApps: Int = 0, + var individual: String? = null, + var meteredNetwork: Int = 0, + var vmessBean: VMessBean? = null, + var socksBean: SOCKSBean? = null, +) { + + fun displayType(): String { + return when (type) { + "vmess" -> "VMess" + "socks" -> "SOCKS5" + else -> "Undefined type $type" + } + } + + fun displayName(): String { + return requireBean().name + } + + fun requireBean(): AbstractBean { + return when (type) { + "vmess" -> vmessBean ?: error("Null vmess node") + "socks" -> socksBean ?: error("Null socks node") + else -> error("Undefined type $type") + } + } + + fun requireVMess() = requireBean() as VMessBean + fun requireSOCKS() = requireBean() as SOCKSBean + + @androidx.room.Dao + interface Dao { + + @Query("SELECT * FROM proxy_entities WHERE groupId = :groupId ORDER BY userOrder") + fun getByGroup(groupId: Long): List + + @Query("SELECT * FROM proxy_entities WHERE id = :proxyId") + fun getById(proxyId: Long): ProxyEntity? + + @Insert + fun addProxy(proxy: ProxyEntity) + + @Update + fun updateProxy(proxy: ProxyEntity) + + } +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/ProxyGroup.kt b/app/src/main/java/io/nekohasekai/sagernet/database/ProxyGroup.kt new file mode 100644 index 000000000..7da18fa2e --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/database/ProxyGroup.kt @@ -0,0 +1,36 @@ +package io.nekohasekai.sagernet.database + +import androidx.room.* +import java.util.* + +@Entity(tableName = "proxy_groups") +class ProxyGroup( + @PrimaryKey(autoGenerate = true) + var id: Long = 0L, + var userOrder: Long = 0L, + var isDefault: Boolean = false, + var name: String? = null, + var isSubscription: Boolean = false, + var subscriptionLinks: MutableList = LinkedList(), + var lastUpdate: Long = 0L, + var layout: Int = 0, +) { + + @androidx.room.Dao + interface Dao { + + @Query("SELECT * FROM proxy_groups ORDER BY userOrder") + fun allGroups(): List + + @Query("SELECT * FROM proxy_groups WHERE id = :groupId") + fun getById(groupId: Long): ProxyGroup? + + @Delete + fun delete(group: ProxyGroup) + + @Insert + fun createGroup(group: ProxyGroup) + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt b/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt new file mode 100644 index 000000000..a47dde1fb --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt @@ -0,0 +1,42 @@ +package io.nekohasekai.sagernet.database + +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import dev.matrix.roomigrant.GenerateRoomMigrations +import io.nekohasekai.sagernet.Key +import io.nekohasekai.sagernet.SagerApp +import io.nekohasekai.sagernet.database.preference.KeyValuePair +import io.nekohasekai.sagernet.fmt.KryoConverters +import io.nekohasekai.sagernet.fmt.gson.GsonConverters +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +@Database(entities = [ProxyGroup::class, ProxyEntity::class, KeyValuePair::class], version = 1) +@TypeConverters(value = [KryoConverters::class, GsonConverters::class]) +@GenerateRoomMigrations +abstract class SagerDatabase : RoomDatabase() { + + companion object { + private val instance by lazy { + Room.databaseBuilder(SagerApp.application, SagerDatabase::class.java, Key.DB_PROFILE) + .addMigrations(*SagerDatabase_Migrations.build()) + .allowMainThreadQueries() + .enableMultiInstanceInvalidation() + .fallbackToDestructiveMigration() + .setQueryExecutor { GlobalScope.launch { it.run() } } + .build() + } + + val kvPairDao get() = instance.keyValuePairDao() + val groupDao get() = instance.groupDao() + val proxyDao get() = instance.proxyDao() + + } + + abstract fun keyValuePairDao(): KeyValuePair.Dao + abstract fun groupDao(): ProxyGroup.Dao + abstract fun proxyDao(): ProxyEntity.Dao + +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/preference/EditTextPreferenceModifiers.kt b/app/src/main/java/io/nekohasekai/sagernet/database/preference/EditTextPreferenceModifiers.kt new file mode 100644 index 000000000..185e66c6d --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/database/preference/EditTextPreferenceModifiers.kt @@ -0,0 +1,28 @@ + + +package io.nekohasekai.sagernet.database.preference + +import android.graphics.Typeface +import android.text.InputFilter +import android.view.inputmethod.EditorInfo +import android.widget.EditText +import androidx.preference.EditTextPreference + +object EditTextPreferenceModifiers { + object Monospace : EditTextPreference.OnBindEditTextListener { + override fun onBindEditText(editText: EditText) { + editText.typeface = Typeface.MONOSPACE + } + } + + object Port : EditTextPreference.OnBindEditTextListener { + private val portLengthFilter = arrayOf(InputFilter.LengthFilter(5)) + + override fun onBindEditText(editText: EditText) { + editText.inputType = EditorInfo.TYPE_CLASS_NUMBER + editText.filters = portLengthFilter + editText.setSingleLine() + editText.setSelection(editText.text.length) + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/preference/KeyValuePair.kt b/app/src/main/java/io/nekohasekai/sagernet/database/preference/KeyValuePair.kt new file mode 100644 index 000000000..606d495d7 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/database/preference/KeyValuePair.kt @@ -0,0 +1,119 @@ +package io.nekohasekai.sagernet.database.preference + +import androidx.room.* +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer + +@Entity +class KeyValuePair() { + companion object { + const val TYPE_UNINITIALIZED = 0 + const val TYPE_BOOLEAN = 1 + const val TYPE_FLOAT = 2 + + @Deprecated("Use TYPE_LONG.") + const val TYPE_INT = 3 + const val TYPE_LONG = 4 + const val TYPE_STRING = 5 + const val TYPE_STRING_SET = 6 + } + + @androidx.room.Dao + interface Dao { + @Query("SELECT * FROM `KeyValuePair` WHERE `key` = :key") + operator fun get(key: String): KeyValuePair? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun put(value: KeyValuePair): Long + + @Query("DELETE FROM `KeyValuePair` WHERE `key` = :key") + fun delete(key: String): Int + } + + @PrimaryKey + var key: String = "" + var valueType: Int = TYPE_UNINITIALIZED + var value: ByteArray = ByteArray(0) + + val boolean: Boolean? + get() = if (valueType == TYPE_BOOLEAN) ByteBuffer.wrap(value).get() != 0.toByte() else null + val float: Float? + get() = if (valueType == TYPE_FLOAT) ByteBuffer.wrap(value).float else null + + @Suppress("DEPRECATION") + @Deprecated("Use long.", ReplaceWith("long")) + val int: Int? + get() = if (valueType == TYPE_INT) ByteBuffer.wrap(value).int else null + val long: Long? + get() = when (valueType) { + @Suppress("DEPRECATION") + TYPE_INT, + -> ByteBuffer.wrap(value).int.toLong() + TYPE_LONG -> ByteBuffer.wrap(value).long + else -> null + } + val string: String? + get() = if (valueType == TYPE_STRING) String(value) else null + val stringSet: Set? + get() = if (valueType == TYPE_STRING_SET) { + val buffer = ByteBuffer.wrap(value) + val result = HashSet() + while (buffer.hasRemaining()) { + val chArr = ByteArray(buffer.int) + buffer.get(chArr) + result.add(String(chArr)) + } + result + } else null + + @Ignore + constructor(key: String) : this() { + this.key = key + } + + // putting null requires using DataStore + fun put(value: Boolean): KeyValuePair { + valueType = TYPE_BOOLEAN + this.value = ByteBuffer.allocate(1).put((if (value) 1 else 0).toByte()).array() + return this + } + + fun put(value: Float): KeyValuePair { + valueType = TYPE_FLOAT + this.value = ByteBuffer.allocate(4).putFloat(value).array() + return this + } + + @Suppress("DEPRECATION") + @Deprecated("Use long.") + fun put(value: Int): KeyValuePair { + valueType = TYPE_INT + this.value = ByteBuffer.allocate(4).putInt(value).array() + return this + } + + fun put(value: Long): KeyValuePair { + valueType = TYPE_LONG + this.value = ByteBuffer.allocate(8).putLong(value).array() + return this + } + + fun put(value: String): KeyValuePair { + valueType = TYPE_STRING + this.value = value.toByteArray() + return this + } + + fun put(value: Set): KeyValuePair { + valueType = TYPE_STRING_SET + val stream = ByteArrayOutputStream() + val intBuffer = ByteBuffer.allocate(4) + for (v in value) { + intBuffer.rewind() + stream.write(intBuffer.putInt(v.length).array()) + stream.write(v.toByteArray()) + } + this.value = stream.toByteArray() + return this + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/preference/OnPreferenceDataStoreChangeListener.kt b/app/src/main/java/io/nekohasekai/sagernet/database/preference/OnPreferenceDataStoreChangeListener.kt new file mode 100644 index 000000000..9cca6d301 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/database/preference/OnPreferenceDataStoreChangeListener.kt @@ -0,0 +1,7 @@ +package io.nekohasekai.sagernet.database.preference + +import androidx.preference.PreferenceDataStore + +interface OnPreferenceDataStoreChangeListener { + fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/preference/PublicDatabase.kt b/app/src/main/java/io/nekohasekai/sagernet/database/preference/PublicDatabase.kt new file mode 100644 index 000000000..6b772b016 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/database/preference/PublicDatabase.kt @@ -0,0 +1,31 @@ +package io.nekohasekai.sagernet.database.preference + +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import dev.matrix.roomigrant.GenerateRoomMigrations +import io.nekohasekai.sagernet.Key +import io.nekohasekai.sagernet.SagerApp +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +@Database(entities = [KeyValuePair::class], version = 1) +@GenerateRoomMigrations +abstract class PublicDatabase : RoomDatabase() { + companion object { + private val instance by lazy { + Room.databaseBuilder(SagerApp.deviceStorage, PublicDatabase::class.java, Key.DB_PUBLIC) + .addMigrations(*PublicDatabase_Migrations.build()) + .allowMainThreadQueries() + .enableMultiInstanceInvalidation() + .fallbackToDestructiveMigration() + .setQueryExecutor { GlobalScope.launch { it.run() } } + .build() + } + + val kvPairDao get() = instance.keyValuePairDao() + } + + abstract fun keyValuePairDao(): KeyValuePair.Dao + +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/preference/RoomPreferenceDataStore.kt b/app/src/main/java/io/nekohasekai/sagernet/database/preference/RoomPreferenceDataStore.kt new file mode 100644 index 000000000..885a4e5eb --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/database/preference/RoomPreferenceDataStore.kt @@ -0,0 +1,80 @@ +package io.nekohasekai.sagernet.database.preference + +import androidx.preference.PreferenceDataStore +import java.util.* + +@Suppress("MemberVisibilityCanBePrivate", "unused") +open class RoomPreferenceDataStore(private val kvPairDao: KeyValuePair.Dao) : + PreferenceDataStore() { + + fun getBoolean(key: String) = kvPairDao[key]?.boolean + fun getFloat(key: String) = kvPairDao[key]?.float + fun getInt(key: String) = kvPairDao[key]?.long?.toInt() + fun getLong(key: String) = kvPairDao[key]?.long + fun getString(key: String) = kvPairDao[key]?.string + fun getStringSet(key: String) = kvPairDao[key]?.stringSet + + override fun getBoolean(key: String, defValue: Boolean) = getBoolean(key) ?: defValue + override fun getFloat(key: String, defValue: Float) = getFloat(key) ?: defValue + override fun getInt(key: String, defValue: Int) = getInt(key) ?: defValue + override fun getLong(key: String, defValue: Long) = getLong(key) ?: defValue + override fun getString(key: String, defValue: String?) = getString(key) ?: defValue + override fun getStringSet(key: String, defValue: MutableSet?) = + getStringSet(key) ?: defValue + + fun putBoolean(key: String, value: Boolean?) = + if (value == null) remove(key) else putBoolean(key, value) + + fun putFloat(key: String, value: Float?) = + if (value == null) remove(key) else putFloat(key, value) + + fun putInt(key: String, value: Int?) = + if (value == null) remove(key) else putLong(key, value.toLong()) + + fun putLong(key: String, value: Long?) = if (value == null) remove(key) else putLong(key, value) + override fun putBoolean(key: String, value: Boolean) { + kvPairDao.put(KeyValuePair(key).put(value)) + fireChangeListener(key) + } + + override fun putFloat(key: String, value: Float) { + kvPairDao.put(KeyValuePair(key).put(value)) + fireChangeListener(key) + } + + override fun putInt(key: String, value: Int) { + kvPairDao.put(KeyValuePair(key).put(value.toLong())) + fireChangeListener(key) + } + + override fun putLong(key: String, value: Long) { + kvPairDao.put(KeyValuePair(key).put(value)) + fireChangeListener(key) + } + + override fun putString(key: String, value: String?) = if (value == null) remove(key) else { + kvPairDao.put(KeyValuePair(key).put(value)) + fireChangeListener(key) + } + + override fun putStringSet(key: String, values: MutableSet?) = + if (values == null) remove(key) else { + kvPairDao.put(KeyValuePair(key).put(values)) + fireChangeListener(key) + } + + fun remove(key: String) { + kvPairDao.delete(key) + fireChangeListener(key) + } + + private val listeners = HashSet() + private fun fireChangeListener(key: String) = + listeners.forEach { it.onPreferenceDataStoreChanged(this, key) } + + fun registerChangeListener(listener: OnPreferenceDataStoreChangeListener) = + listeners.add(listener) + + fun unregisterChangeListener(listener: OnPreferenceDataStoreChangeListener) = + listeners.remove(listener) +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/AbstractBean.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/AbstractBean.java new file mode 100644 index 000000000..a32584db4 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/AbstractBean.java @@ -0,0 +1,25 @@ +package io.nekohasekai.sagernet.fmt; + +import com.esotericsoftware.kryo.io.ByteBufferInput; +import com.esotericsoftware.kryo.io.ByteBufferOutput; + +public class AbstractBean { + + public String serverAddress; + public int serverPort; + + public String name; + + public void serialize(ByteBufferOutput output) { + output.writeString(name); + output.writeString(serverAddress); + output.writeInt(serverPort); + } + + public void deserialize(ByteBufferInput input) { + name = input.readString(); + serverAddress = input.readString(); + serverPort = input.readInt(); + } + +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/KryoConverters.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/KryoConverters.java new file mode 100644 index 000000000..1692ae2fb --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/KryoConverters.java @@ -0,0 +1,55 @@ +package io.nekohasekai.sagernet.fmt; + +import androidx.room.TypeConverter; + +import com.esotericsoftware.kryo.io.ByteBufferInput; +import com.esotericsoftware.kryo.io.ByteBufferOutput; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; + +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.ArrayUtil; +import io.nekohasekai.sagernet.fmt.socks.SOCKSBean; +import io.nekohasekai.sagernet.fmt.v2ray.VMessBean; +import io.nekohasekai.sagernet.ktx.KryosKt; + +public class KryoConverters { + + private static final byte[] NULL = new byte[0]; + + @TypeConverter + public static byte[] serialize(AbstractBean bean) { + if (bean == null) return NULL; + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ByteBufferOutput buffer = KryosKt.byteBuffer(out); + bean.serialize(buffer); + IoUtil.flush(buffer); + IoUtil.flush(out); + IoUtil.close(buffer); + IoUtil.close(out); + return out.toByteArray(); + } + + private static T deserialize(T bean, byte[] bytes) { + ByteArrayInputStream input = new ByteArrayInputStream(bytes); + ByteBufferInput buffer = KryosKt.byteBuffer(input); + bean.deserialize(buffer); + IoUtil.close(buffer); + IoUtil.close(input); + return bean; + } + + @TypeConverter + public static VMessBean vmessDeserialize(byte[] bytes) { + if (ArrayUtil.isEmpty(bytes)) return null; + return deserialize(new VMessBean(), bytes); + } + + @TypeConverter + public static SOCKSBean socksDeserialize(byte[] bytes) { + if (ArrayUtil.isEmpty(bytes)) return null; + return deserialize(new SOCKSBean(), bytes); + } + +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/GsonConverters.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/GsonConverters.java new file mode 100644 index 000000000..57af9e666 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/GsonConverters.java @@ -0,0 +1,19 @@ +package io.nekohasekai.sagernet.fmt.gson; + +import androidx.room.TypeConverter; + +import java.util.List; + +public class GsonConverters { + + @TypeConverter + public static String toJson(Object value) { + return GsonsKt.getGson().toJson(value); + } + + @TypeConverter + public static List toList(String value) { + return GsonsKt.getGson().fromJson(value, List.class); + } + +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/Gsons.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/Gsons.kt new file mode 100644 index 000000000..97780dbfc --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/Gsons.kt @@ -0,0 +1,8 @@ +package io.nekohasekai.sagernet.fmt.gson + +import com.google.gson.GsonBuilder + +val gson = GsonBuilder() + .registerTypeAdapterFactory(JsonOrAdapterFactory()) + .registerTypeAdapterFactory(JsonLazyFactory()) + .create() \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonLazyAdapter.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonLazyAdapter.java new file mode 100644 index 000000000..ac15db50c --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonLazyAdapter.java @@ -0,0 +1,42 @@ +package io.nekohasekai.sagernet.fmt.gson; + + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; + +public class JsonLazyAdapter extends TypeAdapter> { + + private final Gson gson; + private final Class> clazz; + + public JsonLazyAdapter(Gson gson, Class> clazz) { + this.gson = gson; + this.clazz = clazz; + } + + @Override + public void write(JsonWriter out, JsonLazyInterface value) throws IOException { + gson.getAdapter(value.type.getValue()).write(out, value.getValue()); + } + + @Override + public JsonLazyInterface read(JsonReader in) throws IOException { + try { + JsonLazyInterface instance = clazz.newInstance(); + instance.gson = gson; + instance.content = gson.getAdapter(JsonElement.class).read(in); + return instance; + } catch (Exception e) { + if (e instanceof IOException) { + throw ((IOException) e); + } else { + throw new IOException(e); + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonLazyFactory.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonLazyFactory.java new file mode 100644 index 000000000..881dab770 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonLazyFactory.java @@ -0,0 +1,17 @@ +package io.nekohasekai.sagernet.fmt.gson; + +import com.google.gson.Gson; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.reflect.TypeToken; + +public class JsonLazyFactory implements TypeAdapterFactory { + + @SuppressWarnings({"unchecked", "rawtypes"}) + @Override + public TypeAdapter create(Gson gson, TypeToken type) { + if (!JsonLazyInterface.class.isAssignableFrom(type.getRawType())) return null; + return new JsonLazyAdapter(gson, type.getRawType()); + } + +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonLazyInterface.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonLazyInterface.java new file mode 100644 index 000000000..417d5faff --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonLazyInterface.java @@ -0,0 +1,52 @@ +package io.nekohasekai.sagernet.fmt.gson; + +import androidx.annotation.Nullable; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; + +import kotlin.Lazy; +import kotlin.LazyKt; + +@SuppressWarnings("unchecked") +public abstract class JsonLazyInterface implements Lazy { + + protected JsonElement content; + protected Gson gson; + private T value; + private boolean fromValue; + + public JsonLazyInterface() { + } + + public JsonLazyInterface(T value) { + this.value = value; + this.fromValue = true; + } + + protected final Lazy> type = LazyKt.lazy(() -> (Class) getType()); + private final Lazy _value = LazyKt.lazy(this::init); + + private T init() { + if (type.getValue() == null) { + return null; + } + return gson.fromJson(content, type.getValue()); + } + + @Nullable + protected abstract Class getType(); + + @Override + public T getValue() { + if (fromValue) return value; + return _value.getValue(); + } + + @Override + public boolean isInitialized() { + if (fromValue) return true; + return _value.isInitialized(); + } + +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonOr.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonOr.java new file mode 100644 index 000000000..50f74fe4c --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonOr.java @@ -0,0 +1,30 @@ +package io.nekohasekai.sagernet.fmt.gson; + +import androidx.annotation.NonNull; + +import com.google.gson.stream.JsonToken; + +public class JsonOr { + + public JsonToken tokenX; + public JsonToken tokenY; + + public X valueX; + public Y valueY; + + public JsonOr(JsonToken tokenX, JsonToken tokenY) { + this.tokenX = tokenX; + this.tokenY = tokenY; + } + + protected JsonOr(X valueX, Y valueY) { + this.valueX = valueX; + this.valueY = valueY; + } + + @NonNull + @Override + public String toString() { + return valueX != null ? valueX.toString() : valueY != null ? valueY.toString() : "null"; + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonOrAdapter.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonOrAdapter.java new file mode 100644 index 000000000..813f29c29 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonOrAdapter.java @@ -0,0 +1,46 @@ +package io.nekohasekai.sagernet.fmt.gson; + +import com.google.gson.Gson; +import com.google.gson.TypeAdapter; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; + +public class JsonOrAdapter extends TypeAdapter> { + + private final Gson gson; + private final TypeToken typeX; + private final TypeToken typeY; + private final JsonToken tokenX; + private final JsonToken tokenY; + + public JsonOrAdapter(Gson gson, TypeToken typeX, TypeToken typeY, JsonToken tokenX, JsonToken tokenY) { + this.gson = gson; + this.typeX = typeX; + this.typeY = typeY; + this.tokenX = tokenX; + this.tokenY = tokenY; + } + + @Override + public void write(JsonWriter out, JsonOr value) throws IOException { + if (value.valueX != null) { + gson.getAdapter(typeX).write(out, value.valueX); + } else { + gson.getAdapter(typeY).write(out, value.valueY); + } + } + + @Override + public JsonOr read(JsonReader in) throws IOException { + if (in.peek() == tokenX) { + return new JsonOr<>(gson.getAdapter(typeX).read(in), null); + } else { + return new JsonOr<>(null, gson.getAdapter(typeY).read(in)); + } + + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonOrAdapterFactory.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonOrAdapterFactory.java new file mode 100644 index 000000000..baf6ea923 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonOrAdapterFactory.java @@ -0,0 +1,32 @@ +package io.nekohasekai.sagernet.fmt.gson; + +import com.google.gson.Gson; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.reflect.TypeToken; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; + +public class JsonOrAdapterFactory implements TypeAdapterFactory { + + @SuppressWarnings({"unchecked", "rawtypes"}) + @Override + public TypeAdapter create(Gson gson, TypeToken type) { + if (!JsonOr.class.isAssignableFrom(type.getRawType())) return null; + Type superclass = type.getRawType().getGenericSuperclass(); + if (superclass instanceof Class) { + throw new RuntimeException("Missing type parameter."); + } + ParameterizedType parameterized = (ParameterizedType) superclass; + Type[] args = parameterized.getActualTypeArguments(); + try { + JsonOr instance = (JsonOr) type.getRawType().newInstance(); + return new JsonOrAdapter(gson, TypeToken.get(args[0]), TypeToken.get(args[1]), instance.tokenX, instance.tokenY); + } catch (Exception e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } + +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/socks/SOCKSBean.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/socks/SOCKSBean.java new file mode 100644 index 000000000..36ece1851 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/socks/SOCKSBean.java @@ -0,0 +1,31 @@ +package io.nekohasekai.sagernet.fmt.socks; + +import com.esotericsoftware.kryo.io.ByteBufferInput; +import com.esotericsoftware.kryo.io.ByteBufferOutput; + +import io.nekohasekai.sagernet.fmt.AbstractBean; + +public class SOCKSBean extends AbstractBean { + + public String username; + public String password; + public boolean udp; + + @Override + public void serialize(ByteBufferOutput output) { + output.writeInt(0); + super.serialize(output); + output.writeString(username); + output.writeString(password); + output.writeBoolean(udp); + } + + @Override + public void deserialize(ByteBufferInput input) { + int version = input.readInt(); + super.deserialize(input); + username = input.readString(); + password = input.readString(); + udp = input.readBoolean(); + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/socks/SOCKSFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/socks/SOCKSFmt.kt new file mode 100644 index 000000000..eef563bdf --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/socks/SOCKSFmt.kt @@ -0,0 +1,35 @@ +package io.nekohasekai.sagernet.fmt.socks + +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +fun parseSOCKS5(link: String): SOCKSBean { + val url = ("https://" + link + .substringAfter("://")) + .toHttpUrlOrNull() ?: error("Not supported: $link") + + return SOCKSBean().apply { + serverAddress = url.host + serverPort = url.port + username = url.username + password = url.password + name = url.fragment + udp = url.queryParameter("udp") == "true" + } +} + +fun SOCKSBean.toUri(): String { + + val builder = HttpUrl.Builder() + .scheme("http") + .host(serverAddress) + .port(serverPort) + + if (!username.isNullOrBlank()) builder.username(username) + if (!password.isNullOrBlank()) builder.password(password) + if (!name.isNullOrBlank()) builder.fragment(name) + if (udp) builder.addQueryParameter("udp", "true") + + return builder.build().toString().replaceRange(0..4, "socks5") + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2rayConfig.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2rayConfig.java new file mode 100644 index 000000000..3ee1a4e5a --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2rayConfig.java @@ -0,0 +1,693 @@ +package io.nekohasekai.sagernet.fmt.v2ray; + +import androidx.annotation.Nullable; + +import com.google.gson.InstanceCreator; +import com.google.gson.annotations.SerializedName; +import com.google.gson.stream.JsonToken; + +import java.lang.reflect.Type; +import java.util.List; +import java.util.Map; + +import io.nekohasekai.sagernet.fmt.gson.JsonLazyInterface; +import io.nekohasekai.sagernet.fmt.gson.JsonOr; + +@SuppressWarnings({"SpellCheckingInspection", "unused", "RedundantSuppression"}) +public class V2rayConfig { + + public LogObject log; + + public static class LogObject { + + public String access; + public String error; + public String loglevel; + + } + + public ApiObject api; + + public static class ApiObject { + + public String tag; + public List services; + + } + + public DnsObject dns; + + public static class DnsObject { + + public Map hosts; + + public List servers; + + public static class ServerObject { + + public String address; + public Integer port; + public String clientIp; + + } + + public static class StringOrServerObject extends JsonOr { + public StringOrServerObject() { + super(JsonToken.STRING, JsonToken.BEGIN_OBJECT); + } + } + + public String clientIp; + public Boolean disableCache; + public String tag; + public List domains; + public List expectIPs; + + } + + public RoutingObject routing; + + public static class RoutingObject { + + public String domainStrategy; + public List rules; + + public static class RuleObject { + + public String type; + public List domain; + public List ip; + public String port; + public String sourcePort; + public String network; + public List source; + public List user; + public List inboundTag; + public List protocol; + public String attrs; + public String outboundTag; + public String balancerTag; + + } + + public List balancers; + + public static class BalancerObject { + + public String tag; + public List selector; + + } + + } + + public PolicyObject policy; + + public static class PolicyObject { + + public Map levels; + + public static class LevelPolicyObject { + + public Integer handshake; + public Integer connIdle; + public Integer uplinkOnly; + public Integer downlinkOnly; + public Boolean statsUserUplink; + public Boolean statsUserDownlink; + public Integer bufferSize; + + } + + public SystemPolicyObject system; + + public static class SystemPolicyObject { + + public Boolean statsInboundUplink; + public Boolean statsInboundDownlink; + public Boolean statsOutboundUplink; + public Boolean statsOutboundDownlink; + + } + + } + + public List inbounds; + + public static class InboundObject implements InstanceCreator { + + public String listen; + public Integer port; + public String protocol; + public LazyInboundConfigurationObject settings; + public StreamSettingsObject streamSettings; + public String tag; + public SniffingObject sniffing; + public AllocateObject allocate; + + public static class SniffingObject { + + public Boolean enabled; + public List destOverride; + public Boolean metadataOnly; + + } + + public static class AllocateObject { + + public String strategy; + public Integer refresh; + public Integer concurrency; + + } + + @Override + public LazyInboundConfigurationObject createInstance(Type type) { + return new LazyInboundConfigurationObject(); + } + + public class LazyInboundConfigurationObject extends JsonLazyInterface { + + public LazyInboundConfigurationObject() { + } + + public LazyInboundConfigurationObject(InboundConfigurationObject value) { + super(value); + } + + @Nullable + @Override + protected Class getType() { + switch (protocol.toLowerCase()) { + case "dokodemo-door": + return DokodemoDoorInboundConfigurationObject.class; + case "http": + return HTTPInboundConfigurationObject.class; + case "socks": + return SocksInboundConfigurationObject.class; + case "vmess": + return VMessInboundConfigurationObject.class; + case "vless": + return VLESSInboundConfigurationObject.class; + case "shadowsocks": + return ShadowsocksInboundConfigurationObject.class; + case "trojan": + return TrojanInboundConfigurationObject.class; + + } + return null; + } + + } + + } + + public interface InboundConfigurationObject { + } + + public static class DokodemoDoorInboundConfigurationObject implements InboundConfigurationObject { + + public String address; + public Integer port; + public String network; + public Integer timeout; + public Boolean followRedirect; + public Integer userLevel; + + } + + public static class HTTPInboundConfigurationObject implements InboundConfigurationObject { + + public Integer timeout; + public List accounts; + public Boolean allowTransparent; + public Integer userLevel; + + public static class AccountObject { + + public String user; + public String pass; + + } + + } + + public static class SocksInboundConfigurationObject implements InboundConfigurationObject { + + + public String auth; + public List accounts; + public Boolean udp; + public String ip; + public Integer userLevel; + + public static class AccountObject { + + public String user; + public String pass; + + } + + } + + public static class VMessInboundConfigurationObject implements InboundConfigurationObject { + + public List clients; + @SerializedName("default") + public DefaultObject defaultObject; + public DetourObject detour; + public Boolean disableInsecureEncryption; + + + public static class ClientObject { + + public String id; + public Integer level; + public Integer alterId; + public String email; + + } + + public static class DefaultObject { + + public Integer level; + public Integer alterId; + + } + + public static class DetourObject { + + public String to; + + } + + } + + public static class VLESSInboundConfigurationObject implements InboundConfigurationObject { + + public List clients; + public String decryption; + public List fallbacks; + + public static class ClientObject { + + public String id; + public Integer level; + public String email; + + } + + public static class FallbackObject { + + public String alpn; + public String path; + public Integer dest; + public Integer xver; + + } + + } + + public static class ShadowsocksInboundConfigurationObject implements InboundConfigurationObject { + + public String email; + public String method; + public String password; + public Integer level; + public String network; + + } + + public static class TrojanInboundConfigurationObject implements InboundConfigurationObject { + + public List clients; + public List fallbacks; + + public static class ClientObject { + + public String password; + public String email; + public Integer level; + + } + + public static class FallbackObject { + + public String alpn; + public String path; + public Integer dest; + public Integer xver; + + } + + } + + public List outbounds; + + public static class OutboundObject { + + public String sendThrough; + public String protocol; + public LazyOutboundConfigurationObject settings; + public String tag; + public StreamSettingsObject streamSettings; + public ProxySettingsObject proxySettings; + public MuxObject mux; + + public class LazyOutboundConfigurationObject extends JsonLazyInterface { + + public LazyOutboundConfigurationObject() { + } + + public LazyOutboundConfigurationObject(OutboundConfigurationObject value) { + super(value); + } + + @Nullable + @Override + protected Class getType() { + switch (protocol.toLowerCase()) { + case "blackhole": + return BlackholeOutboundConfigurationObject.class; + case "dns": + return DNSOutboundConfigurationObject.class; + case "freedom": + return FreedomOutboundConfigurationObject.class; + case "http": + return HTTPOutboundConfigurationObject.class; + case "socks": + return SocksOutboundConfigurationObject.class; + case "vmess": + return VMessOutboundConfigurationObject.class; + case "shadowsocks": + return ShadowsocksOutboundConfigurationObject.class; + case "vless": + return VLESSOutboundConfigurationObject.class; + case "loopback": + return LoopbackOutboundConfigurationObject.class; + } + return null; + } + } + + public static class ProxySettingsObject { + + public String tag; + public Boolean transportLayer; + + } + + public static class MuxObject { + + public Boolean enabled; + public Integer concurrency; + + } + + } + + public interface OutboundConfigurationObject { + } + + public static class BlackholeOutboundConfigurationObject implements OutboundConfigurationObject { + + public ResponseObject response; + + public static class ResponseObject { + public String type; + } + + } + + public static class DNSOutboundConfigurationObject implements OutboundConfigurationObject { + + public String network; + public String address; + public Integer port; + + } + + public static class FreedomOutboundConfigurationObject implements OutboundConfigurationObject { + + public String domainStrategy; + public String redirect; + public Integer userLevel; + + + } + + public static class HTTPOutboundConfigurationObject implements OutboundConfigurationObject { + + public List servers; + + public static class ServerObject { + + public String address; + public Integer port; + public List users; + + } + + } + + public static class SocksOutboundConfigurationObject implements OutboundConfigurationObject { + + public List servers; + + public static class ServerObject { + + public String address; + public Integer port; + public List users; + + public static class UserObject { + + public String user; + public String pass; + public Integer level; + + } + + } + + } + + public static class VMessOutboundConfigurationObject implements OutboundConfigurationObject { + + public List vnext; + + public static class ServerObject { + + public String address; + public Integer port; + public UserObject users; + + public static class UserObject { + + public String id; + public String alterId; + public String security; + public Integer level; + + } + + } + + } + + public static class ShadowsocksOutboundConfigurationObject implements OutboundConfigurationObject { + + public List servers; + + public static class ServerObject { + + public String address; + public Integer port; + public String method; + public String password; + public Integer level; + public String email; + + } + + } + + public static class VLESSOutboundConfigurationObject implements OutboundConfigurationObject { + + public List vnext; + + public static class ServerObject { + + public String address; + public Integer port; + public UserObject users; + + public static class UserObject { + + public String id; + public String encryption; + public Integer level; + + } + + } + + } + + public static class LoopbackOutboundConfigurationObject implements OutboundConfigurationObject { + + public String inboundTag; + + } + + public TransportObject transport; + + public static class TransportObject { + + public TLSObject tlsSettings; + public TcpObject tcpSettings; + public KcpObject kcpSettings; + public WebSocketObject wsSettings; + public HttpObject httpSettings; + public QuicObject quicSettings; + public DomainSocketObject dsSettings; + + } + + public static class StreamSettingsObject { + + public String network; + public String security; + public TLSObject tlsSettings; + public TcpObject tcpSettings; + public KcpObject kcpSettings; + public WebSocketObject wsSettings; + public HttpObject httpSettings; + public QuicObject quicSettings; + public DomainSocketObject dsSettings; + public SockoptObject sockopt; + + public static class SockoptObject { + + public Integer mark; + public Boolean tcpFastOpen; + public String tproxy; + + } + + } + + public static class TLSObject { + + public String serverName; + public Boolean allowInsecure; + public List alpn; + public List certificates; + public Boolean disableSystemRoot; + + public static class CertificateObject { + + public String usage; + public String certificateFile; + public String keyFile; + public List certificate; + public List key; + + } + + } + + public static class TcpObject { + + public Boolean acceptProxyProtocol; + public HeaderObject header; + + public static class HeaderObject { + + public String type; + + public HTTPRequestObject request; + public HTTPResponseObject response; + + public static class HTTPRequestObject { + + public String version; + public String method; + public List path; + public Map> headers; + + } + + public static class HTTPResponseObject { + + public String version; + public String status; + public String reason; + public Map> headers; + + } + + } + + } + + + public static class KcpObject { + + public Integer mtu; + public Integer tti; + public Integer uplinkCapacity; + public Integer downlinkCapacity; + public Boolean congestion; + public Integer readBufferSize; + public Integer writeBufferSize; + public HeaderObject header; + public String seed; + + public static class HeaderObject { + + public String type; + + } + + } + + public static class WebSocketObject { + + public Boolean acceptProxyProtocol; + public String path; + public Map headers; + + } + + public static class HttpObject { + + public List host; + public String path; + + } + + public static class QuicObject { + + public String security; + public String key; + public HeaderObject header; + + public static class HeaderObject { + + public String type; + + } + + } + + public static class DomainSocketObject { + + public String path; + @SerializedName("abstract") + public Boolean isAbstract; + public Boolean padding; + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/VMessBean.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/VMessBean.java new file mode 100644 index 000000000..04b889b18 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/VMessBean.java @@ -0,0 +1,77 @@ +package io.nekohasekai.sagernet.fmt.v2ray; + +import com.esotericsoftware.kryo.io.ByteBufferInput; +import com.esotericsoftware.kryo.io.ByteBufferOutput; + +import cn.hutool.core.util.StrUtil; +import io.nekohasekai.sagernet.fmt.AbstractBean; + +public class VMessBean extends AbstractBean { + + public String uuid; + public String path; + + public String tag; + public boolean tls; + public String network; + public int kcpUpLinkCapacity; + public int kcpDownLinkCapacity; + public String header; + public int mux; + + // custom + + public String requestHost; + public String sni; + public String security; + public int alterId; + + protected void initDefaultValues() { + if (StrUtil.isBlank(network)) { + network = "tls"; + } + if (StrUtil.isBlank(security)) { + security = "auto"; + } + } + + @Override + public void serialize(ByteBufferOutput output) { + output.writeInt(0); + super.serialize(output); + output.writeString(uuid); + output.writeString(tag); + output.writeBoolean(tls); + output.writeString(network); + output.writeInt(kcpUpLinkCapacity); + output.writeInt(kcpDownLinkCapacity); + output.writeString(header); + output.writeInt(mux); + + // custom + output.writeString(requestHost); + output.writeString(sni); + output.writeString(security); + output.writeInt(alterId); + } + + @Override + public void deserialize(ByteBufferInput input) { + int version = input.readInt(); + super.deserialize(input); + uuid = input.readString(); + tag = input.readString(); + tls = input.readBoolean(); + network = input.readString(); + kcpUpLinkCapacity = input.readInt(); + kcpDownLinkCapacity = input.readInt(); + header = input.readString(); + mux = input.readInt(); + + // custom + requestHost = input.readString(); + sni = input.readString(); + security = input.readString(); + alterId = input.readInt(); + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/VMessFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/VMessFmt.kt new file mode 100644 index 000000000..ac17a5d60 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/VMessFmt.kt @@ -0,0 +1,245 @@ +package io.nekohasekai.sagernet.fmt.v2ray + +import cn.hutool.core.codec.Base64 +import cn.hutool.json.JSONObject +import io.nekohasekai.sagernet.fmt.AbstractBean +import io.nekohasekai.sagernet.fmt.gson.gson +import io.nekohasekai.sagernet.fmt.socks.SOCKSBean +import io.nekohasekai.sagernet.fmt.v2ray.V2rayConfig.* +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl + +fun buildV2rayConfig(bean: AbstractBean, listen: String, port: Int): String { + + return V2rayConfig().apply { + + dns = DnsObject().apply { + + log = LogObject().apply { + loglevel = "debug" + } + + servers = listOf( + DnsObject.StringOrServerObject().apply { + valueX = "https+local://doh.dns.sb/dns-query" + } + ) + + policy = PolicyObject().apply { + system = PolicyObject.SystemPolicyObject().apply { + statsOutboundDownlink = true + statsOutboundUplink = true + } + } + + inbounds = listOf( + InboundObject().apply { + tag = "in" + this.listen = listen + this.port = port + protocol = "socks" + settings = LazyInboundConfigurationObject( + SocksInboundConfigurationObject().apply { + auth = "noauth" + udp = bean is SOCKSBean && bean.udp + userLevel = 0 + }) + } + ) + + outbounds = listOf( + OutboundObject().apply { + tag = "out" + if (bean is SOCKSBean) { + protocol = "socks" + settings = LazyOutboundConfigurationObject( + SocksOutboundConfigurationObject().apply { + servers = listOf( + SocksOutboundConfigurationObject.ServerObject().apply { + address = bean.serverAddress + this.port = bean.serverPort + users = if (bean.username.isNullOrBlank()) { + emptyList() + } else { + listOf(SocksOutboundConfigurationObject.ServerObject.UserObject() + .apply { + user = bean.username + pass = bean.password + level = 0 + }) + } + } + ) + }) + } + } + ) + + routing = RoutingObject().apply { + domainStrategy = "IPIfNonMatch" + rules = listOf(RoutingObject.RuleObject().apply { + inboundTag = listOf( + "in" + ) + outboundTag = "out" + type = "field" + }) + } + + } + + }.let { gson.toJson(it) } + +} + +fun parseVmessN(link: String): VMessBean { + val bean = VMessBean() + val json = JSONObject(Base64.decodeStr(link.substringAfter("vmess://"))) + + bean.serverAddress = json.getStr("add") + bean.serverPort = json.getInt("port") + bean.uuid = json.getStr("id") + bean.alterId = json.getInt("aid") + bean.network = json.getStr("network") + bean.header = json.getStr("type") + bean.requestHost = json.getStr("host") + bean.path = json.getStr("path") + bean.name = json.getStr("ps") + bean.sni = json.getStr("sni") + bean.tls = !json.getStr("tls").isNullOrBlank() + + if (json.getInt("v", 2) < 2) { + when (bean.network) { + "ws" -> { + var path = "" + var host = "" + val lstParameter = bean.requestHost.split(";") + if (lstParameter.isNotEmpty()) { + path = lstParameter[0].trim() + } + if (lstParameter.size > 1) { + path = lstParameter[0].trim() + host = lstParameter[1].trim() + } + bean.path = path + bean.requestHost = host + } + "h2" -> { + var path = "" + var host = "" + val lstParameter = bean.requestHost.split(";") + if (lstParameter.isNotEmpty()) { + path = lstParameter[0].trim() + } + if (lstParameter.size > 1) { + path = lstParameter[0].trim() + host = lstParameter[1].trim() + } + bean.path = path + bean.requestHost = host + } + } + } + + bean.initDefaultValues() + return bean + +} + +fun parseVmess1(link: String): VMessBean { + val bean = VMessBean() + val lnk = link.replace("vmess1://", "https://").toHttpUrl() + bean.serverAddress = lnk.host + bean.serverPort = lnk.port + bean.uuid = lnk.username + bean.name = lnk.fragment + lnk.queryParameterNames.forEach { + when (it) { + "tag" -> bean.tag = lnk.queryParameter(it) + "tls" -> bean.tls = lnk.queryParameter(it) == "true" + "network" -> { + bean.network = lnk.queryParameter(it)!! + if (bean.network in arrayOf("http", "ws")) { + bean.path = lnk.pathSegments.joinToString("/", "/") + } + } + "kcp.uplinkcapacity" -> bean.kcpUpLinkCapacity = lnk.queryParameter(it)!!.toInt() + "kcp.downlinkcapacity" -> bean.kcpDownLinkCapacity = lnk.queryParameter(it)!!.toInt() + "header" -> bean.header = lnk.queryParameter(it) + "mux" -> bean.mux = lnk.queryParameter(it)!!.toInt() + // custom + "host" -> bean.requestHost = lnk.queryParameter(it) + "sni" -> bean.sni = lnk.queryParameter(it) + "security" -> bean.security = lnk.queryParameter(it) + "alterid" -> bean.alterId = lnk.queryParameter(it)!!.toInt() + } + } + + bean.initDefaultValues() + return bean +} + +fun VMessBean.toVmess1(): String { + + val builder = HttpUrl.Builder() + .scheme("https") + .host(serverAddress) + .port(serverPort) + + if (!uuid.isNullOrBlank()) { + builder.username(uuid) + } + + if (!path.isNullOrBlank()) { + builder.addPathSegment(path) + } + + if (!tag.isNullOrBlank()) { + builder.addQueryParameter("tag", tag) + } + + if (!network.isNullOrBlank()) { + builder.addQueryParameter("network", network) + } + + if (kcpUpLinkCapacity != 0) { + builder.addQueryParameter("kcp.uplinkcapacity", "$kcpUpLinkCapacity") + } + + if (kcpDownLinkCapacity != 0) { + builder.addQueryParameter("kcp.downlinkcapacity", "$kcpDownLinkCapacity") + } + + if (!header.isNullOrBlank()) { + builder.addQueryParameter("header", header) + } + + if (mux != 0) { + builder.addQueryParameter("mux", "$mux") + } + + if (!name.isNullOrBlank()) { + builder.fragment(name) + } + + // custom + + if (!requestHost.isNullOrBlank()) { + builder.addQueryParameter("host", requestHost) + } + + if (!sni.isNullOrBlank()) { + builder.addQueryParameter("sni", sni) + } + + if (!security.isNullOrBlank()) { + builder.addQueryParameter("security", security) + } + + if (alterId != 0) { + builder.addQueryParameter("alterid", "$alterId") + } + + return builder.build().toString().replace("https://", "vmess1://") + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/Asyncs.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/Asyncs.kt new file mode 100644 index 000000000..528989a9b --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Asyncs.kt @@ -0,0 +1,15 @@ +package io.nekohasekai.sagernet.ktx + +import kotlinx.coroutines.* + +fun runOnIoDispatcher(block: suspend CoroutineScope.() -> Unit) = + GlobalScope.launch(Dispatchers.IO, block = block) + +suspend fun onIoDispatcher(block: suspend CoroutineScope.() -> Unit) = + withContext(Dispatchers.IO, block = block) + +fun runOnMainDispatcher(block: suspend CoroutineScope.() -> Unit) = + GlobalScope.launch(Dispatchers.Main, block = block) + +suspend fun onMainDispatcher(block: suspend CoroutineScope.() -> Unit) = + withContext(Dispatchers.Main, block = block) \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/Dimens.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/Dimens.kt new file mode 100644 index 000000000..1c7a034a7 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Dimens.kt @@ -0,0 +1,14 @@ +package io.nekohasekai.sagernet.ktx + +import android.content.res.Resources +import kotlin.math.ceil + +private val density = Resources.getSystem().displayMetrics.density + +fun dp2pxf(dpValue: Int): Float { + return density * dpValue +} + +fun dp2px(dpValue: Int): Int { + return ceil(dp2pxf(dpValue)).toInt() +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/Kryos.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/Kryos.kt new file mode 100644 index 000000000..669aaf9f7 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Kryos.kt @@ -0,0 +1,10 @@ +package io.nekohasekai.sagernet.ktx + +import com.esotericsoftware.kryo.io.ByteBufferInput +import com.esotericsoftware.kryo.io.ByteBufferOutput +import java.io.InputStream +import java.io.OutputStream + + +fun InputStream.byteBuffer() = ByteBufferInput(this) +fun OutputStream.byteBuffer() = ByteBufferOutput(this) \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/Logs.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/Logs.kt new file mode 100644 index 000000000..9b714dd77 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Logs.kt @@ -0,0 +1,66 @@ +package io.nekohasekai.sagernet.ktx + +import android.util.Log +import cn.hutool.core.util.StrUtil +import io.nekohasekai.sagernet.BuildConfig + +object Logs { + + private fun mkTag(): String { + val stackTrace = Thread.currentThread().stackTrace + return StrUtil.subAfter(stackTrace[4].className, ".", true) + } + + fun v(message: String) { + if (BuildConfig.DEBUG) { + Log.v(mkTag(), message) + } + } + + fun v(message: String, exception: Throwable) { + if (BuildConfig.DEBUG) { + Log.v(mkTag(), message, exception) + } + } + + fun d(message: String) { + if (BuildConfig.DEBUG) { + Log.d(mkTag(), message) + } + } + + fun d(message: String, exception: Throwable) { + if (BuildConfig.DEBUG) { + Log.d(mkTag(), message, exception) + } + } + + fun i(message: String) { + Log.i(mkTag(), message) + } + + fun i(message: String, exception: Throwable) { + Log.i(mkTag(), message, exception) + } + + fun w(message: String) { + Log.w(mkTag(), message) + } + + fun w(message: String, exception: Throwable) { + Log.w(mkTag(), message, exception) + } + + fun w(exception: Throwable) { + Log.w(mkTag(), exception) + } + + fun e(message: String) { + Log.e(mkTag(), message) + } + + fun e(message: String, exception: Throwable) { + Log.e(mkTag(), message, exception) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/Preferences.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/Preferences.kt new file mode 100644 index 000000000..16bb45c40 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Preferences.kt @@ -0,0 +1,36 @@ +package io.nekohasekai.sagernet.ktx + +import androidx.preference.PreferenceDataStore +import kotlin.reflect.KProperty + +fun PreferenceDataStore.string( + name: String, + defaultValue: () -> String = { "" }, +) = PreferenceProxy(name, defaultValue, ::getString, ::putString) + +fun PreferenceDataStore.boolean( + name: String, + defaultValue: () -> Boolean = { false }, +) = PreferenceProxy(name, defaultValue, ::getBoolean, ::putBoolean) + +fun PreferenceDataStore.int( + name: String, + defaultValue: () -> Int = { 0 }, +) = PreferenceProxy(name, defaultValue, ::getInt, ::putInt) + +fun PreferenceDataStore.long( + name: String, + defaultValue: () -> Long = { 0L }, +) = PreferenceProxy(name, defaultValue, ::getLong, ::putLong) + +class PreferenceProxy( + val name: String, + val defaultValue: () -> T, + val getter: (String, T) -> T, + val setter: (String, value: T) -> Unit, +) { + + operator fun setValue(thisObj: Any?, property: KProperty<*>, value: T) = setter(name, value) + operator fun getValue(thisObj: Any?, property: KProperty<*>) = getter(name, defaultValue()) + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/Utils.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/Utils.kt new file mode 100644 index 000000000..e48d2050d --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Utils.kt @@ -0,0 +1,117 @@ +package io.nekohasekai.sagernet.ktx + +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageInfo +import android.content.res.Resources +import android.os.Build +import android.system.Os +import android.system.OsConstants +import android.util.TypedValue +import androidx.annotation.AttrRes +import androidx.preference.Preference +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import java.io.FileDescriptor +import java.net.HttpURLConnection +import java.net.InetAddress +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + + +inline fun Iterable.forEachTry(action: (T) -> Unit) { + var result: Exception? = null + for (element in this) try { + action(element) + } catch (e: Exception) { + if (result == null) result = e else result.addSuppressed(e) + } + if (result != null) { + throw result + } +} + +val Throwable.readableMessage get() = localizedMessage ?: javaClass.name + +/** + * https://android.googlesource.com/platform/prebuilts/runtime/+/94fec32/appcompat/hiddenapi-light-greylist.txt#9466 + */ +private val getInt = FileDescriptor::class.java.getDeclaredMethod("getInt$") +val FileDescriptor.int get() = getInt.invoke(this) as Int + +suspend fun HttpURLConnection.useCancellable(block: suspend HttpURLConnection.() -> T): T { + return suspendCancellableCoroutine { cont -> + cont.invokeOnCancellation { + if (Build.VERSION.SDK_INT >= 26) disconnect() else GlobalScope.launch(Dispatchers.IO) { disconnect() } + } + GlobalScope.launch(Dispatchers.IO) { + try { + cont.resume(block()) + } catch (e: Throwable) { + cont.resumeWithException(e) + } + } + } +} + +fun parsePort(str: String?, default: Int, min: Int = 1025): Int { + val value = str?.toIntOrNull() ?: default + return if (value < min || value > 65535) default else value +} + +fun broadcastReceiver(callback: (Context, Intent) -> Unit): BroadcastReceiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) = callback(context, intent) + } + +fun Context.listenForPackageChanges(onetime: Boolean = true, callback: () -> Unit) = + object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + callback() + if (onetime) context.unregisterReceiver(this) + } + }.apply { + registerReceiver(this, IntentFilter().apply { + addAction(Intent.ACTION_PACKAGE_ADDED) + addAction(Intent.ACTION_PACKAGE_REMOVED) + addDataScheme("package") + }) + } + +val PackageInfo.signaturesCompat + get() = + if (Build.VERSION.SDK_INT >= 28) signingInfo.apkContentsSigners else @Suppress("DEPRECATION") signatures + +/** + * Based on: https://stackoverflow.com/a/26348729/2245107 + */ +fun Resources.Theme.resolveResourceId(@AttrRes resId: Int): Int { + val typedValue = TypedValue() + if (!resolveAttribute(resId, typedValue, true)) throw Resources.NotFoundException() + return typedValue.resourceId +} + +fun Preference.remove() = parent!!.removePreference(this) + +/** + * A slightly more performant variant of parseNumericAddress. + * + * Bug in Android 9.0 and lower: https://issuetracker.google.com/issues/123456213 + */ + +private val parseNumericAddress by lazy @SuppressLint("SoonBlockedPrivateApi") { + InetAddress::class.java.getDeclaredMethod("parseNumericAddress", String::class.java).apply { + isAccessible = true + } +} + +fun String?.parseNumericAddress(): InetAddress? = Os.inet_pton(OsConstants.AF_INET, this) + ?: Os.inet_pton(OsConstants.AF_INET6, this)?.let { + if (Build.VERSION.SDK_INT >= 29) it else parseNumericAddress.invoke(null, + this) as InetAddress + } \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/AboutFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/AboutFragment.kt new file mode 100644 index 000000000..b19b15a2a --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/AboutFragment.kt @@ -0,0 +1,24 @@ +package io.nekohasekai.sagernet.ui + +import android.content.Context +import com.danielstone.materialaboutlibrary.MaterialAboutFragment +import com.danielstone.materialaboutlibrary.model.MaterialAboutCard +import com.danielstone.materialaboutlibrary.model.MaterialAboutList +import io.nekohasekai.sagernet.R + +class AboutFragment : MaterialAboutFragment() { + + override fun getMaterialAboutList(activityContext: Context?): MaterialAboutList { + + return MaterialAboutList.Builder() + .addCard( + MaterialAboutCard.Builder() + .title(R.string.app_name) + .outline(false) + .build() + ) + .build() + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt new file mode 100644 index 000000000..dc09aeeb8 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -0,0 +1,85 @@ +package io.nekohasekai.sagernet.ui + +import android.os.Bundle +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.viewpager2.widget.ViewPager2 +import com.github.zawadz88.materialpopupmenu.popupMenu +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.SagerDatabase +import io.nekohasekai.sagernet.ktx.dp2px +import io.nekohasekai.sagernet.ktx.runOnIoDispatcher +import io.nekohasekai.sagernet.ui.configuration.GroupPagerAdapter + +class ConfigurationFragment : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? { + return inflater.inflate(R.layout.group_list_main, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val groupPager = view.findViewById(R.id.group_pager) + val tabLayout = view.findViewById(R.id.group_tab) + val adapter = GroupPagerAdapter(this) + groupPager.adapter = adapter + + TabLayoutMediator(tabLayout, groupPager) { tab, position -> + tab.text = adapter.groupList[position].name + .takeIf { !it.isNullOrBlank() } ?: getString(R.string.group_default) + tab.view.setOnLongClickListener { + popupMenu { + dropDownVerticalOffset = 100 + if (position == 0) { + dropdownGravity = Gravity.TOP or Gravity.START + dropDownHorizontalOffset = dp2px(16) + } else if (position == adapter.itemCount - 1) { + dropdownGravity = Gravity.TOP or Gravity.END + dropDownHorizontalOffset = -dp2px(16) + } + section { + item { + icon = R.drawable.ic_action_dns + label = "Hello" + } + item { + icon = R.drawable.ic_action_lock + label = "Hello W0rld!!!!" + } + item { + icon = R.drawable.ic_action_description + label = "1145141919" + } + val group = adapter.groupList[position] + //if (!group.isDefault) { + item { + icon = R.drawable.ic_action_delete + label = "Delete Group" + callback = { + runOnIoDispatcher { + SagerDatabase.groupDao.delete(group) + adapter.reloadList() + } + } + } + // } + } + }.show(requireContext(), it) + + true + } + }.attach() + + adapter.reloadList() + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt new file mode 100644 index 000000000..1272abd3f --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt @@ -0,0 +1,182 @@ +package io.nekohasekai.sagernet.ui + +import android.os.Bundle +import android.os.RemoteException +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.Toolbar +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updateLayoutParams +import androidx.drawerlayout.widget.DrawerLayout +import androidx.navigation.findNavController +import androidx.navigation.ui.AppBarConfiguration +import androidx.navigation.ui.navigateUp +import androidx.navigation.ui.setupActionBarWithNavController +import androidx.navigation.ui.setupWithNavController +import androidx.preference.PreferenceDataStore +import com.github.shadowsocks.aidl.IShadowsocksService +import com.github.shadowsocks.aidl.TrafficStats +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.navigation.NavigationView +import com.google.android.material.snackbar.Snackbar +import io.nekohasekai.sagernet.Key +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.SagerApp +import io.nekohasekai.sagernet.bg.BaseService +import io.nekohasekai.sagernet.bg.SagerConnection +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.preference.OnPreferenceDataStoreChangeListener +import io.nekohasekai.sagernet.ktx.dp2pxf +import io.nekohasekai.sagernet.widget.ListHolderListener +import io.nekohasekai.sagernet.widget.ServiceButton +import io.nekohasekai.sagernet.widget.StatsBar + + +class MainActivity : AppCompatActivity(), SagerConnection.Callback, + OnPreferenceDataStoreChangeListener { + + private lateinit var appBarConfiguration: AppBarConfiguration + lateinit var fab: ServiceButton + lateinit var stats: StatsBar + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + val toolbar: Toolbar = findViewById(R.id.toolbar) + setSupportActionBar(toolbar) + + snackbar = findViewById(R.id.snackbar) + ViewCompat.setOnApplyWindowInsetsListener(snackbar, ListHolderListener) + + val appBarLayout: AppBarLayout = findViewById(R.id.appbar) + val elevation = dp2pxf(4) + + fab = findViewById(R.id.fab) + stats = findViewById(R.id.stats) + + val drawerLayout: DrawerLayout = findViewById(R.id.drawer_layout) + val navView: NavigationView = findViewById(R.id.nav_view) + val navController = findNavController(R.id.nav_host_fragment) + + appBarConfiguration = AppBarConfiguration(setOf( + R.id.nav_configuration, R.id.nav_about + ), drawerLayout) + + setupActionBarWithNavController(navController, appBarConfiguration) + navView.setupWithNavController(navController) + + navController.addOnDestinationChangedListener { _, destination, _ -> + appBarLayout.elevation = if (destination.id == R.id.nav_configuration) 0f else elevation + } + + ViewCompat.setOnApplyWindowInsetsListener(fab) { view, insets -> + view.updateLayoutParams { + bottomMargin = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom + + resources.getDimensionPixelOffset(R.dimen.mtrl_bottomappbar_fab_bottom_margin) + } + insets + } + + fab.setOnClickListener { toggle() } + stats.setOnClickListener { if (state == BaseService.State.Connected) stats.testConnection() } + + changeState(BaseService.State.Idle) // reset everything to init state + + connection.connect(this, this) + DataStore.publicStore.registerChangeListener(this) + } + + + var state = BaseService.State.Idle + + private fun changeState( + state: BaseService.State, + msg: String? = null, + animate: Boolean = false, + ) { + fab.changeState(state, this.state, animate) + stats.changeState(state) + if (msg != null) snackbar(getString(R.string.vpn_error, msg)).show() + this.state = state + /* ProfilesFragment.instance?.profilesAdapter?.notifyDataSetChanged() // refresh button enabled state + stateListener?.invoke(state)*/ + } + + lateinit var snackbar: CoordinatorLayout private set + fun snackbar(text: CharSequence = "") = + Snackbar.make(snackbar, text, Snackbar.LENGTH_LONG).apply { + anchorView = fab + } + + + override fun stateChanged(state: BaseService.State, profileName: String?, msg: String?) { + changeState(state, msg, true) + } + + private fun toggle() { + if (state.canStop) SagerApp.stopService() else connect.launch(null) + } + + private val connection = SagerConnection(true) + override fun onServiceConnected(service: IShadowsocksService) = changeState(try { + BaseService.State.values()[service.state] + } catch (_: RemoteException) { + BaseService.State.Idle + }) + + override fun onServiceDisconnected() = changeState(BaseService.State.Idle) + override fun onBinderDied() { + connection.disconnect(this) + connection.connect(this, this) + } + + private val connect = registerForActivityResult(VpnRequestActivity.StartService()) { + if (it) snackbar().setText(R.string.vpn_permission_denied).show() + } + + override fun trafficUpdated(profileId: Long, stats: TrafficStats) { + if (profileId == 0L) this@MainActivity.stats.updateTraffic( + stats.txRate, stats.rxRate, stats.txTotal, stats.rxTotal) + if (state != BaseService.State.Stopping) { + (supportFragmentManager.findFragmentById(R.id.nav_configuration) as? ConfigurationFragment) +// ?.onTrafficUpdated(profileId, stats) + } + } + + override fun trafficPersisted(profileId: Long) { +// ProfilesFragment.instance?.onTrafficPersisted(profileId) + } + + override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) { + when (key) { + Key.SERVICE_MODE -> { + connection.disconnect(this) + connection.connect(this, this) + } + } + } + + override fun onStart() { + super.onStart() + connection.bandwidthTimeout = 1000 + } + + override fun onStop() { + connection.bandwidthTimeout = 0 + super.onStop() + } + + override fun onDestroy() { + super.onDestroy() + DataStore.publicStore.unregisterChangeListener(this) + connection.disconnect(this) + } + + override fun onSupportNavigateUp(): Boolean { + val navController = findNavController(R.id.nav_host_fragment) + return navController.navigateUp(appBarConfiguration) || super.onSupportNavigateUp() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/VpnRequestActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/VpnRequestActivity.kt new file mode 100644 index 000000000..c27db4ddb --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/VpnRequestActivity.kt @@ -0,0 +1,71 @@ +package io.nekohasekai.sagernet.ui + +import android.app.Activity +import android.app.KeyguardManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.VpnService +import android.os.Bundle +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContract +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.getSystemService +import io.nekohasekai.sagernet.Key +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.SagerApp +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.ktx.broadcastReceiver + +class VpnRequestActivity : AppCompatActivity() { + private var receiver: BroadcastReceiver? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (getSystemService()!!.isKeyguardLocked) { + receiver = broadcastReceiver { _, _ -> connect.launch(null) } + registerReceiver(receiver, IntentFilter(Intent.ACTION_USER_PRESENT)) + } else connect.launch(null) + } + + private val connect = registerForActivityResult(StartService()) { + if (it) Toast.makeText(this, R.string.vpn_permission_denied, Toast.LENGTH_LONG).show() + finish() + } + + override fun onDestroy() { + super.onDestroy() + if (receiver != null) unregisterReceiver(receiver) + } + + class StartService : ActivityResultContract() { + private var cachedIntent: Intent? = null + + override fun getSynchronousResult( + context: Context, + input: Void?, + ): SynchronousResult? { + if (DataStore.serviceMode == Key.MODE_VPN) VpnService.prepare(context)?.let { intent -> + cachedIntent = intent + return null + } + SagerApp.startService() + return SynchronousResult(false) + } + + override fun createIntent(context: Context, input: Void?) = + cachedIntent!!.also { cachedIntent = null } + + override fun parseResult(resultCode: Int, intent: Intent?) = + if (resultCode == Activity.RESULT_OK) { + SagerApp.startService() + false + } else { +// Timber.e("Failed to start VpnService: $intent") + true + } + } + + +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/configuration/ConfigurationAdapter.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/configuration/ConfigurationAdapter.kt new file mode 100644 index 000000000..59298fe47 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/configuration/ConfigurationAdapter.kt @@ -0,0 +1,60 @@ +package io.nekohasekai.sagernet.ui.configuration + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.ProxyEntity +import io.nekohasekai.sagernet.database.SagerDatabase +import io.nekohasekai.sagernet.fmt.socks.SOCKSBean +import io.nekohasekai.sagernet.ktx.onMainDispatcher +import io.nekohasekai.sagernet.ktx.runOnIoDispatcher + +class ConfigurationAdapter(private val groupIdToQuery: Long) : + RecyclerView.Adapter() { + + var configurationList: List = listOf() + + fun reloadList() { + runOnIoDispatcher { + configurationList = SagerDatabase.proxyDao.getByGroup(groupIdToQuery) + if (configurationList.isEmpty() && + (SagerDatabase.groupDao.getById(groupIdToQuery) + ?: return@runOnIoDispatcher).isDefault + ) { + SagerDatabase.proxyDao.addProxy(ProxyEntity( + groupId = groupIdToQuery, + type = "socks", + socksBean = SOCKSBean().apply { + serverAddress = "127.0.0.1" + serverPort = 1080 + name = "Hello W0rld!" + } + )) + configurationList = SagerDatabase.proxyDao.getByGroup(groupIdToQuery) + } + onMainDispatcher { + notifyDataSetChanged() + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConfigurationHolder { + return ConfigurationHolder( + LayoutInflater.from(parent.context).inflate(R.layout.layout_profile, parent, false) + ) + } + + override fun getItemId(position: Int): Long { + return configurationList[position].id + } + + override fun onBindViewHolder(holder: ConfigurationHolder, position: Int) { + holder.bind(configurationList[position]) + } + + override fun getItemCount(): Int { + return configurationList.size + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/configuration/ConfigurationHolder.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/configuration/ConfigurationHolder.kt new file mode 100644 index 000000000..2c3d7c24e --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/configuration/ConfigurationHolder.kt @@ -0,0 +1,68 @@ +package io.nekohasekai.sagernet.ui.configuration + +import android.content.Intent +import android.text.format.Formatter +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.view.isGone +import androidx.recyclerview.widget.RecyclerView +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.ProxyEntity +import io.nekohasekai.sagernet.ktx.onMainDispatcher +import io.nekohasekai.sagernet.ktx.runOnIoDispatcher +import io.nekohasekai.sagernet.ui.settings.SettingsActivity + +class ConfigurationHolder(val view: View) : RecyclerView.ViewHolder(view) { + + val profileName: TextView = view.findViewById(R.id.profile_name) + val profileType: TextView = view.findViewById(R.id.profile_type) + val trafficText: TextView = view.findViewById(R.id.traffic_text) + val selectedView: LinearLayout = view.findViewById(R.id.selected_view) + val editButton: ImageView = view.findViewById(R.id.edit) + + fun bind(proxyEntity: ProxyEntity) { + view.setOnClickListener { + runOnIoDispatcher { + if (DataStore.selectedProxy != proxyEntity.id) { + DataStore.selectedProxy = proxyEntity.id + onMainDispatcher { + bind(proxyEntity) + } + } + } + } + + profileName.text = proxyEntity.requireBean().name + profileType.text = proxyEntity.displayType() + val showTraffic = proxyEntity.rx + proxyEntity.tx != 0L + trafficText.isGone = !showTraffic + if (showTraffic) { + trafficText.text = view.context.getString(R.string.traffic, + Formatter.formatFileSize(view.context, proxyEntity.rx), + Formatter.formatFileSize(view.context, proxyEntity.tx)) + } + + editButton.setOnClickListener { + it.context.startActivity(Intent(it.context, SettingsActivity::class.java).apply { + putExtra("id", proxyEntity.id) + }) + } + + runOnIoDispatcher { + if (DataStore.selectedProxy == proxyEntity.id) { + onMainDispatcher { + selectedView.visibility = View.VISIBLE + } + } else { + onMainDispatcher { + selectedView.visibility = View.INVISIBLE + } + } + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/configuration/GroupFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/configuration/GroupFragment.kt new file mode 100644 index 000000000..9de6a0c7e --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/configuration/GroupFragment.kt @@ -0,0 +1,39 @@ +package io.nekohasekai.sagernet.ui.configuration + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.ProxyGroup + +class GroupFragment @JvmOverloads constructor(private val proxyGroup: ProxyGroup? = null) : + Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? { + return inflater.inflate(R.layout.configurtion_list_main, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val configurationList = view.findViewById(R.id.configuration_list) + if (proxyGroup == null) return + + val adapter = ConfigurationAdapter(proxyGroup.id) + + configurationList.layoutManager = when (proxyGroup.layout) { + else -> LinearLayoutManager(view.context) + } + configurationList.adapter = adapter + + adapter.reloadList() + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/configuration/GroupPagerAdapter.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/configuration/GroupPagerAdapter.kt new file mode 100644 index 000000000..a587e01a5 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/configuration/GroupPagerAdapter.kt @@ -0,0 +1,45 @@ +package io.nekohasekai.sagernet.ui.configuration + +import androidx.fragment.app.Fragment +import androidx.viewpager2.adapter.FragmentStateAdapter +import io.nekohasekai.sagernet.database.ProxyGroup +import io.nekohasekai.sagernet.database.SagerDatabase +import io.nekohasekai.sagernet.ktx.onMainDispatcher +import io.nekohasekai.sagernet.ktx.runOnIoDispatcher + +class GroupPagerAdapter( + activity: Fragment, +) : FragmentStateAdapter(activity) { + + var groupList: List = listOf() + + fun reloadList() { + runOnIoDispatcher { + groupList = SagerDatabase.groupDao.allGroups() + if (groupList.isEmpty()) { + SagerDatabase.groupDao.createGroup(ProxyGroup(isDefault = true)) + groupList = SagerDatabase.groupDao.allGroups() + } + onMainDispatcher { + notifyDataSetChanged() + } + } + } + + override fun getItemCount(): Int { + return groupList.size + } + + override fun createFragment(position: Int): Fragment { + return GroupFragment(groupList[position]) + } + + override fun getItemId(position: Int): Long { + return groupList[position].id + } + + override fun containsItem(itemId: Long): Boolean { + return groupList.any { it.id == itemId } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/settings/SettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/settings/SettingsActivity.kt new file mode 100644 index 000000000..627abb45e --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/settings/SettingsActivity.kt @@ -0,0 +1,44 @@ +package io.nekohasekai.sagernet.ui.settings + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.preference.PreferenceFragmentCompat +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.SagerDatabase +import io.nekohasekai.sagernet.ktx.onMainDispatcher +import io.nekohasekai.sagernet.ktx.runOnIoDispatcher + +class SettingsActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.settings_activity) + if (savedInstanceState == null) { + val entityId = intent.getLongExtra("id", -1) + if (entityId == -1L) { + finish() + return + } + runOnIoDispatcher { + val entity = SagerDatabase.proxyDao.getById(entityId) + if (entity == null) { + onMainDispatcher { + finish() + } + } + + } + supportFragmentManager + .beginTransaction() + .replace(R.id.settings, SettingsFragment()) + .commit() + } + supportActionBar?.setDisplayHomeAsUpEnabled(true) + } + + class SettingsFragment : PreferenceFragmentCompat() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.root_preferences, rootKey) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/utils/Commandline.kt b/app/src/main/java/io/nekohasekai/sagernet/utils/Commandline.kt new file mode 100644 index 000000000..03c32f6e5 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/utils/Commandline.kt @@ -0,0 +1,140 @@ +package io.nekohasekai.sagernet.utils + +import java.util.* + +/** + * Commandline objects help handling command lines specifying processes to + * execute. + * + * The class can be used to define a command line as nested elements or as a + * helper to define a command line by an application. + * + * + * ` + *

+ *   

+ *     

+ *     

+ *     

+ *   


+ *


+` * + * + * Based on: https://github.com/apache/ant/blob/588ce1f/src/main/org/apache/tools/ant/types/Commandline.java + * + * Adds support for escape character '\'. + */ +object Commandline { + + /** + * Quote the parts of the given array in way that makes them + * usable as command line arguments. + * @param args the list of arguments to quote. + * @return empty string for null or no command, else every argument split + * by spaces and quoted by quoting rules. + */ + fun toString(args: Iterable?): String { + // empty path return empty string + args ?: return "" + // path containing one or more elements + val result = StringBuilder() + for (arg in args) { + if (result.isNotEmpty()) result.append(' ') + arg.indices.map { arg[it] }.forEach { + when (it) { + ' ', '\\', '"', '\'' -> { + result.append('\\') // intentionally no break + result.append(it) + } + else -> result.append(it) + } + } + } + return result.toString() + } + + /** + * Quote the parts of the given array in way that makes them + * usable as command line arguments. + * @param args the list of arguments to quote. + * @return empty string for null or no command, else every argument split + * by spaces and quoted by quoting rules. + */ + fun toString(args: Array) = + toString(args.asIterable()) // thanks to Java, arrays aren't iterable + + /** + * Crack a command line. + * @param toProcess the command line to process. + * @return the command line broken into strings. + * An empty or null toProcess parameter results in a zero sized array. + */ + fun translateCommandline(toProcess: String?): Array { + if (toProcess == null || toProcess.isEmpty()) { + //no command? no string + return arrayOf() + } + // parse with a simple finite state machine + + val normal = 0 + val inQuote = 1 + val inDoubleQuote = 2 + var state = normal + val tok = StringTokenizer(toProcess, "\\\"\' ", true) + val result = ArrayList() + val current = StringBuilder() + var lastTokenHasBeenQuoted = false + var lastTokenIsSlash = false + + while (tok.hasMoreTokens()) { + val nextTok = tok.nextToken() + when (state) { + inQuote -> if ("\'" == nextTok) { + lastTokenHasBeenQuoted = true + state = normal + } else current.append(nextTok) + inDoubleQuote -> when (nextTok) { + "\"" -> if (lastTokenIsSlash) { + current.append(nextTok) + lastTokenIsSlash = false + } else { + lastTokenHasBeenQuoted = true + state = normal + } + "\\" -> lastTokenIsSlash = if (lastTokenIsSlash) { + current.append(nextTok) + false + } else true + else -> { + if (lastTokenIsSlash) { + current.append("\\") // unescaped + lastTokenIsSlash = false + } + current.append(nextTok) + } + } + else -> { + when { + lastTokenIsSlash -> { + current.append(nextTok) + lastTokenIsSlash = false + } + "\\" == nextTok -> lastTokenIsSlash = true + "\'" == nextTok -> state = inQuote + "\"" == nextTok -> state = inDoubleQuote + " " == nextTok -> if (lastTokenHasBeenQuoted || current.isNotEmpty()) { + result.add(current.toString()) + current.setLength(0) + } + else -> current.append(nextTok) + } + lastTokenHasBeenQuoted = false + } + } + } + if (lastTokenHasBeenQuoted || current.isNotEmpty()) result.add(current.toString()) + require(state != inQuote && state != inDoubleQuote) { "unbalanced quotes in $toProcess" } + require(!lastTokenIsSlash) { "escape character following nothing in $toProcess" } + return result.toTypedArray() + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/utils/DeviceStorageApp.kt b/app/src/main/java/io/nekohasekai/sagernet/utils/DeviceStorageApp.kt new file mode 100644 index 000000000..fc4f63244 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/utils/DeviceStorageApp.kt @@ -0,0 +1,20 @@ +package io.nekohasekai.sagernet.utils + +import android.annotation.SuppressLint +import android.annotation.TargetApi +import android.app.Application +import android.content.Context + +@SuppressLint("Registered") +@TargetApi(24) +class DeviceStorageApp(context: Context) : Application() { + init { + attachBaseContext(context.createDeviceProtectedStorageContext()) + } + + /** + * Thou shalt not get the REAL underlying application context which would no longer be operating under device + * protected storage. + */ + override fun getApplicationContext() = this +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/utils/Subnet.kt b/app/src/main/java/io/nekohasekai/sagernet/utils/Subnet.kt new file mode 100644 index 000000000..80af40aa5 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/utils/Subnet.kt @@ -0,0 +1,86 @@ +package io.nekohasekai.sagernet.utils + +import io.nekohasekai.sagernet.ktx.parseNumericAddress +import java.net.InetAddress +import java.util.* + +class Subnet(val address: InetAddress, val prefixSize: Int) : Comparable { + companion object { + fun fromString(value: String, lengthCheck: Int = -1): Subnet? { + val parts = value.split('/', limit = 2) + val addr = parts[0].parseNumericAddress() ?: return null + check(lengthCheck < 0 || addr.address.size == lengthCheck) + return if (parts.size == 2) try { + val prefixSize = parts[1].toInt() + if (prefixSize < 0 || prefixSize > addr.address.size shl 3) null else Subnet(addr, + prefixSize) + } catch (_: NumberFormatException) { + null + } else Subnet(addr, addr.address.size shl 3) + } + } + + private val addressLength get() = address.address.size shl 3 + + init { + require(prefixSize in 0..addressLength) { "prefixSize $prefixSize not in 0..$addressLength" } + } + + class Immutable(private val a: ByteArray, private val prefixSize: Int = 0) { + companion object : Comparator { + override fun compare(a: Immutable, b: Immutable): Int { + check(a.a.size == b.a.size) + for (i in a.a.indices) { + val result = a.a[i].compareTo(b.a[i]) + if (result != 0) return result + } + return 0 + } + } + + fun matches(b: Immutable) = matches(b.a) + fun matches(b: ByteArray): Boolean { + if (a.size != b.size) return false + var i = 0 + while (i * 8 < prefixSize && i * 8 + 8 <= prefixSize) { + if (a[i] != b[i]) return false + ++i + } + return i * 8 == prefixSize || a[i] == (b[i].toInt() and -(1 shl i * 8 + 8 - prefixSize)).toByte() + } + } + + fun toImmutable() = Immutable(address.address.also { + var i = prefixSize / 8 + if (prefixSize % 8 > 0) { + it[i] = (it[i].toInt() and -(1 shl i * 8 + 8 - prefixSize)).toByte() + ++i + } + while (i < it.size) it[i++] = 0 + }, prefixSize) + + override fun toString(): String = + if (prefixSize == addressLength) address.hostAddress else address.hostAddress + '/' + prefixSize + + private fun Byte.unsigned() = toInt() and 0xFF + override fun compareTo(other: Subnet): Int { + val addrThis = address.address + val addrThat = other.address.address + var result = + addrThis.size.compareTo(addrThat.size) // IPv4 address goes first + if (result != 0) return result + for (i in addrThis.indices) { + result = addrThis[i].unsigned() + .compareTo(addrThat[i].unsigned()) // undo sign extension of signed byte + if (result != 0) return result + } + return prefixSize.compareTo(other.prefixSize) + } + + override fun equals(other: Any?): Boolean { + val that = other as? Subnet + return address == that?.address && prefixSize == that.prefixSize + } + + override fun hashCode(): Int = Objects.hash(address, prefixSize) +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/AutoCollapseTextView.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/AutoCollapseTextView.kt new file mode 100644 index 000000000..0d841fc75 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/widget/AutoCollapseTextView.kt @@ -0,0 +1,57 @@ +/******************************************************************************* + * * + * Copyright (C) 2018 by Max Lv * + * Copyright (C) 2018 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package io.nekohasekai.sagernet.widget + +import android.content.Context +import android.graphics.Rect +import android.util.AttributeSet +import android.view.MotionEvent +import androidx.appcompat.widget.AppCompatTextView +import androidx.core.view.isGone + +class AutoCollapseTextView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : + AppCompatTextView(context, attrs, defStyleAttr) { + override fun onTextChanged( + text: CharSequence?, + start: Int, + lengthBefore: Int, + lengthAfter: Int, + ) { + super.onTextChanged(text, start, lengthBefore, lengthAfter) + isGone = text.isNullOrEmpty() + } + + // #1874 + override fun onFocusChanged(focused: Boolean, direction: Int, previouslyFocusedRect: Rect?) = + try { + super.onFocusChanged(focused, direction, previouslyFocusedRect) + } catch (e: IndexOutOfBoundsException) { + } + + override fun onTouchEvent(event: MotionEvent?) = try { + super.onTouchEvent(event) + } catch (e: IndexOutOfBoundsException) { + false + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/ServiceButton.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/ServiceButton.kt new file mode 100644 index 000000000..349d609a2 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/widget/ServiceButton.kt @@ -0,0 +1,100 @@ +package io.nekohasekai.sagernet.widget + +import android.content.Context +import android.graphics.drawable.Drawable +import android.os.Build +import android.util.AttributeSet +import android.view.PointerIcon +import android.view.View +import androidx.annotation.DrawableRes +import androidx.appcompat.widget.TooltipCompat +import androidx.vectordrawable.graphics.drawable.Animatable2Compat +import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat +import com.google.android.material.floatingactionbutton.FloatingActionButton +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.bg.BaseService +import java.util.* + +class ServiceButton @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : + FloatingActionButton(context, attrs, defStyleAttr) { + private val callback = object : Animatable2Compat.AnimationCallback() { + override fun onAnimationEnd(drawable: Drawable) { + super.onAnimationEnd(drawable) + var next = animationQueue.peek() ?: return + if (next.current == drawable) { + animationQueue.pop() + next = animationQueue.peek() ?: return + } + setImageDrawable(next) + next.start() + } + } + + private fun createIcon(@DrawableRes resId: Int): AnimatedVectorDrawableCompat { + val result = AnimatedVectorDrawableCompat.create(context, resId)!! + result.registerAnimationCallback(callback) + return result + } + + private val iconStopped by lazy { createIcon(R.drawable.ic_service_stopped) } + private val iconConnecting by lazy { createIcon(R.drawable.ic_service_connecting) } + private val iconConnected by lazy { createIcon(R.drawable.ic_service_connected) } + private val iconStopping by lazy { createIcon(R.drawable.ic_service_stopping) } + private val animationQueue = ArrayDeque() + + private var checked = false + + override fun onCreateDrawableState(extraSpace: Int): IntArray { + val drawableState = super.onCreateDrawableState(extraSpace + 1) + if (checked) View.mergeDrawableStates(drawableState, + intArrayOf(android.R.attr.state_checked)) + return drawableState + } + + fun changeState(state: BaseService.State, previousState: BaseService.State, animate: Boolean) { + when (state) { + BaseService.State.Connecting -> changeState(iconConnecting, animate) + BaseService.State.Connected -> changeState(iconConnected, animate) + BaseService.State.Stopping -> { + changeState(iconStopping, animate && previousState == BaseService.State.Connected) + } + else -> changeState(iconStopped, animate) + } + checked = state == BaseService.State.Connected + refreshDrawableState() + val description = context.getText(if (state.canStop) R.string.stop else R.string.connect) + contentDescription = description + TooltipCompat.setTooltipText(this, description) + val enabled = state.canStop || state == BaseService.State.Stopped + isEnabled = enabled + if (Build.VERSION.SDK_INT >= 24) pointerIcon = PointerIcon.getSystemIcon(context, + if (enabled) PointerIcon.TYPE_HAND else PointerIcon.TYPE_WAIT) + } + + private fun changeState(icon: AnimatedVectorDrawableCompat, animate: Boolean) { + fun counters(a: AnimatedVectorDrawableCompat, b: AnimatedVectorDrawableCompat): Boolean = + a == iconStopped && b == iconConnecting || + a == iconConnecting && b == iconStopped || + a == iconConnected && b == iconStopping || + a == iconStopping && b == iconConnected + if (animate) { + if (animationQueue.size < 2 || !counters(animationQueue.last, icon)) { + animationQueue.add(icon) + if (animationQueue.size == 1) { + setImageDrawable(icon) + icon.start() + } + } else animationQueue.removeLast() + } else { + animationQueue.peekFirst()?.stop() + animationQueue.clear() + setImageDrawable(icon) + icon.start() // force ensureAnimatorSet to be called so that stop() will work + icon.stop() + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt new file mode 100644 index 000000000..36c02bb03 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt @@ -0,0 +1,121 @@ +/******************************************************************************* + * * + * Copyright (C) 2018 by Max Lv * + * Copyright (C) 2018 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package io.nekohasekai.sagernet.widget + +import android.content.Context +import android.text.format.Formatter +import android.util.AttributeSet +import android.view.View +import android.widget.TextView +import androidx.appcompat.widget.TooltipCompat +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.whenStarted +import com.google.android.material.bottomappbar.BottomAppBar +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.bg.BaseService +import io.nekohasekai.sagernet.ui.MainActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class StatsBar @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, + defStyleAttr: Int = R.attr.bottomAppBarStyle, +) : + BottomAppBar(context, attrs, defStyleAttr) { + private lateinit var statusText: TextView + private lateinit var txText: TextView + private lateinit var rxText: TextView + private lateinit var behavior: Behavior + override fun getBehavior(): Behavior { + if (!this::behavior.isInitialized) behavior = object : Behavior() { + override fun onNestedScroll( + coordinatorLayout: CoordinatorLayout, child: BottomAppBar, target: View, + dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int, + type: Int, consumed: IntArray, + ) { + super.onNestedScroll(coordinatorLayout, + child, + target, + dxConsumed, + dyConsumed + dyUnconsumed, + dxUnconsumed, + 0, + type, + consumed) + } + } + return behavior + } + + override fun setOnClickListener(l: OnClickListener?) { + statusText = findViewById(R.id.status) + txText = findViewById(R.id.tx) + rxText = findViewById(R.id.rx) + super.setOnClickListener(l) + } + + private fun setStatus(text: CharSequence) { + statusText.text = text + TooltipCompat.setTooltipText(this, text) + } + + fun changeState(state: BaseService.State) { + val activity = context as MainActivity + fun postWhenStarted(what: () -> Unit) = activity.lifecycleScope.launch(Dispatchers.Main) { + activity.whenStarted { what() } + } + if ((state == BaseService.State.Connected).also { hideOnScroll = it }) { + postWhenStarted { performShow() } + /* tester.status.observe(activity) { + it.retrieve(this::setStatus) { msg -> + activity.snackbar(msg).show() + } + }*/ + } else { + postWhenStarted { performHide() } + updateTraffic(0, 0, 0, 0) + /* tester.status.removeObservers(activity) + if (state != BaseService.State.Idle) tester.invalidate()*/ + setStatus(context.getText(when (state) { + BaseService.State.Connecting -> R.string.connecting + BaseService.State.Stopping -> R.string.stopping + else -> R.string.not_connected + })) + } + } + + fun updateTraffic(txRate: Long, rxRate: Long, txTotal: Long, rxTotal: Long) { + txText.text = "▲ ${Formatter.formatFileSize(context, txTotal)} | ${ + context.getString(R.string.speed, + Formatter.formatFileSize(context, txRate)) + }" + rxText.text = "▼ ${Formatter.formatFileSize(context, rxTotal)} | ${ + context.getString(R.string.speed, + Formatter.formatFileSize(context, rxRate)) + }" + } + + fun testConnection() { + + } + +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/WindowInsetsListeners.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/WindowInsetsListeners.kt new file mode 100644 index 000000000..8f78831f9 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/widget/WindowInsetsListeners.kt @@ -0,0 +1,42 @@ + + +package io.nekohasekai.sagernet.widget + +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.core.graphics.Insets +import androidx.core.view.* +import io.nekohasekai.sagernet.R + +object ListHolderListener : OnApplyWindowInsetsListener { + override fun onApplyWindowInsets(view: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val statusBarInsets = insets.getInsets(WindowInsetsCompat.Type.statusBars()) + view.setPadding(statusBarInsets.left, + statusBarInsets.top, + statusBarInsets.right, + statusBarInsets.bottom) + return WindowInsetsCompat.Builder(insets).apply { + setInsets(WindowInsetsCompat.Type.statusBars(), Insets.NONE) + setInsets(WindowInsetsCompat.Type.navigationBars(), + insets.getInsets(WindowInsetsCompat.Type.navigationBars())) + }.build() + } + + fun setup(activity: AppCompatActivity) = activity.findViewById(android.R.id.content).let { + ViewCompat.setOnApplyWindowInsetsListener(it, ListHolderListener) + WindowCompat.setDecorFitsSystemWindows(activity.window, false) + } +} + +object MainListListener : OnApplyWindowInsetsListener { + override fun onApplyWindowInsets(view: View, insets: WindowInsetsCompat) = insets.apply { + view.updatePadding(bottom = view.resources.getDimensionPixelOffset(R.dimen.main_list_padding_bottom) + + insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom) + } +} + +object ListListener : OnApplyWindowInsetsListener { + override fun onApplyWindowInsets(view: View, insets: WindowInsetsCompat) = insets.apply { + view.updatePadding(bottom = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom) + } +} diff --git a/app/src/main/jni/Android.mk b/app/src/main/jni/Android.mk new file mode 100755 index 000000000..26a548c98 --- /dev/null +++ b/app/src/main/jni/Android.mk @@ -0,0 +1,128 @@ +# Copyright (C) 2009 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# +LOCAL_PATH := $(call my-dir) +ROOT_PATH := $(LOCAL_PATH) + +BUILD_SHARED_EXECUTABLE := $(LOCAL_PATH)/build-shared-executable.mk + +######################################################## +## libancillary +######################################################## + +include $(CLEAR_VARS) + +ANCILLARY_SOURCE := fd_recv.c fd_send.c + +LOCAL_MODULE := libancillary +LOCAL_CFLAGS += -I$(LOCAL_PATH)/libancillary + +LOCAL_SRC_FILES := $(addprefix libancillary/, $(ANCILLARY_SOURCE)) + +include $(BUILD_STATIC_LIBRARY) + +######################################################## +## tun2socks +######################################################## + +include $(CLEAR_VARS) + +LOCAL_CFLAGS := -std=gnu99 +LOCAL_CFLAGS += -DBADVPN_THREADWORK_USE_PTHREAD -DBADVPN_LINUX -DBADVPN_BREACTOR_BADVPN -D_GNU_SOURCE +LOCAL_CFLAGS += -DBADVPN_USE_SIGNALFD -DBADVPN_USE_EPOLL +LOCAL_CFLAGS += -DBADVPN_LITTLE_ENDIAN -DBADVPN_THREAD_SAFE +LOCAL_CFLAGS += -DNDEBUG -DANDROID +# LOCAL_CFLAGS += -DTUN2SOCKS_JNI + +LOCAL_STATIC_LIBRARIES := libancillary + +LOCAL_C_INCLUDES:= \ + $(LOCAL_PATH)/libancillary \ + $(LOCAL_PATH)/badvpn/lwip/src/include/ipv4 \ + $(LOCAL_PATH)/badvpn/lwip/src/include/ipv6 \ + $(LOCAL_PATH)/badvpn/lwip/src/include \ + $(LOCAL_PATH)/badvpn/lwip/custom \ + $(LOCAL_PATH)/badvpn/ + +TUN2SOCKS_SOURCES := \ + base/BLog_syslog.c \ + system/BReactor_badvpn.c \ + system/BSignal.c \ + system/BConnection_common.c \ + system/BConnection_unix.c \ + system/BTime.c \ + system/BUnixSignal.c \ + system/BNetwork.c \ + flow/StreamRecvInterface.c \ + flow/PacketRecvInterface.c \ + flow/PacketPassInterface.c \ + flow/StreamPassInterface.c \ + flow/SinglePacketBuffer.c \ + flow/BufferWriter.c \ + flow/PacketBuffer.c \ + flow/PacketStreamSender.c \ + flow/PacketPassConnector.c \ + flow/PacketProtoFlow.c \ + flow/PacketPassFairQueue.c \ + flow/PacketProtoEncoder.c \ + flow/PacketProtoDecoder.c \ + socksclient/BSocksClient.c \ + tuntap/BTap.c \ + lwip/src/core/udp.c \ + lwip/src/core/memp.c \ + lwip/src/core/init.c \ + lwip/src/core/pbuf.c \ + lwip/src/core/tcp.c \ + lwip/src/core/tcp_out.c \ + lwip/src/core/netif.c \ + lwip/src/core/def.c \ + lwip/src/core/ip.c \ + lwip/src/core/mem.c \ + lwip/src/core/tcp_in.c \ + lwip/src/core/stats.c \ + lwip/src/core/inet_chksum.c \ + lwip/src/core/timeouts.c \ + lwip/src/core/ipv4/icmp.c \ + lwip/src/core/ipv4/igmp.c \ + lwip/src/core/ipv4/ip4_addr.c \ + lwip/src/core/ipv4/ip4_frag.c \ + lwip/src/core/ipv4/ip4.c \ + lwip/src/core/ipv4/autoip.c \ + lwip/src/core/ipv6/ethip6.c \ + lwip/src/core/ipv6/inet6.c \ + lwip/src/core/ipv6/ip6_addr.c \ + lwip/src/core/ipv6/mld6.c \ + lwip/src/core/ipv6/dhcp6.c \ + lwip/src/core/ipv6/icmp6.c \ + lwip/src/core/ipv6/ip6.c \ + lwip/src/core/ipv6/ip6_frag.c \ + lwip/src/core/ipv6/nd6.c \ + lwip/custom/sys.c \ + tun2socks/tun2socks.c \ + base/DebugObject.c \ + base/BLog.c \ + base/BPending.c \ + system/BDatagram_unix.c \ + flowextra/PacketPassInactivityMonitor.c \ + tun2socks/SocksUdpGwClient.c \ + udpgw_client/UdpGwClient.c + +LOCAL_MODULE := tun2socks + +LOCAL_LDLIBS := -ldl -llog + +LOCAL_SRC_FILES := $(addprefix badvpn/, $(TUN2SOCKS_SOURCES)) + +include $(BUILD_SHARED_EXECUTABLE) diff --git a/app/src/main/jni/Application.mk b/app/src/main/jni/Application.mk new file mode 100644 index 000000000..ce095350e --- /dev/null +++ b/app/src/main/jni/Application.mk @@ -0,0 +1 @@ +APP_STL := c++_static diff --git a/app/src/main/jni/badvpn b/app/src/main/jni/badvpn new file mode 160000 index 000000000..fb01eb983 --- /dev/null +++ b/app/src/main/jni/badvpn @@ -0,0 +1 @@ +Subproject commit fb01eb983915c9a09a690ad44c6028dd87500ec7 diff --git a/app/src/main/jni/build-shared-executable.mk b/app/src/main/jni/build-shared-executable.mk new file mode 100644 index 000000000..05239df18 --- /dev/null +++ b/app/src/main/jni/build-shared-executable.mk @@ -0,0 +1,31 @@ +# Copyright (C) 2009 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# this file is included from Android.mk files to build a target-specific +# executable program +# +# Modified by @Mygod, based on: +# https://android.googlesource.com/platform/ndk/+/a355a4e/build/core/build-shared-library.mk +# https://android.googlesource.com/platform/ndk/+/a355a4e/build/core/build-executable.mk +LOCAL_BUILD_SCRIPT := BUILD_EXECUTABLE +LOCAL_MAKEFILE := $(local-makefile) +$(call check-defined-LOCAL_MODULE,$(LOCAL_BUILD_SCRIPT)) +$(call check-LOCAL_MODULE,$(LOCAL_MAKEFILE)) +$(call check-LOCAL_MODULE_FILENAME) +# we are building target objects +my := TARGET_ +$(call handle-module-filename,lib,$(TARGET_SONAME_EXTENSION)) +$(call handle-module-built) +LOCAL_MODULE_CLASS := EXECUTABLE +include $(BUILD_SYSTEM)/build-module.mk diff --git a/app/src/main/jni/libancillary b/app/src/main/jni/libancillary new file mode 160000 index 000000000..311e5d14f --- /dev/null +++ b/app/src/main/jni/libancillary @@ -0,0 +1 @@ +Subproject commit 311e5d14f593f16c785bc6605220517eb1f21f6b diff --git a/app/src/main/res/drawable-v21/ic_menu_camera.xml b/app/src/main/res/drawable-v21/ic_menu_camera.xml new file mode 100644 index 000000000..634fe9221 --- /dev/null +++ b/app/src/main/res/drawable-v21/ic_menu_camera.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable-v21/ic_menu_gallery.xml b/app/src/main/res/drawable-v21/ic_menu_gallery.xml new file mode 100644 index 000000000..03c77099f --- /dev/null +++ b/app/src/main/res/drawable-v21/ic_menu_gallery.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-v21/ic_menu_slideshow.xml b/app/src/main/res/drawable-v21/ic_menu_slideshow.xml new file mode 100644 index 000000000..5e9e163a5 --- /dev/null +++ b/app/src/main/res/drawable-v21/ic_menu_slideshow.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/background_profile.xml b/app/src/main/res/drawable/background_profile.xml new file mode 100644 index 000000000..2b4d68ab8 --- /dev/null +++ b/app/src/main/res/drawable/background_profile.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/background_selectable.xml b/app/src/main/res/drawable/background_selectable.xml new file mode 100644 index 000000000..b888994bd --- /dev/null +++ b/app/src/main/res/drawable/background_selectable.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_action_assignment.xml b/app/src/main/res/drawable/ic_action_assignment.xml new file mode 100644 index 000000000..9706a6ca1 --- /dev/null +++ b/app/src/main/res/drawable/ic_action_assignment.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_action_copyright.xml b/app/src/main/res/drawable/ic_action_copyright.xml new file mode 100644 index 000000000..fad6892cf --- /dev/null +++ b/app/src/main/res/drawable/ic_action_copyright.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_action_delete.xml b/app/src/main/res/drawable/ic_action_delete.xml new file mode 100644 index 000000000..0e9d1eb3d --- /dev/null +++ b/app/src/main/res/drawable/ic_action_delete.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_action_description.xml b/app/src/main/res/drawable/ic_action_description.xml new file mode 100644 index 000000000..98bda1ab6 --- /dev/null +++ b/app/src/main/res/drawable/ic_action_description.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_action_dns.xml b/app/src/main/res/drawable/ic_action_dns.xml new file mode 100644 index 000000000..dd725bed8 --- /dev/null +++ b/app/src/main/res/drawable/ic_action_dns.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_action_done.xml b/app/src/main/res/drawable/ic_action_done.xml new file mode 100644 index 000000000..8bf04e3f3 --- /dev/null +++ b/app/src/main/res/drawable/ic_action_done.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_action_help_outline.xml b/app/src/main/res/drawable/ic_action_help_outline.xml new file mode 100644 index 000000000..e7cf8ea21 --- /dev/null +++ b/app/src/main/res/drawable/ic_action_help_outline.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_action_lock.xml b/app/src/main/res/drawable/ic_action_lock.xml new file mode 100644 index 000000000..811a5b6b9 --- /dev/null +++ b/app/src/main/res/drawable/ic_action_lock.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_action_lock_open.xml b/app/src/main/res/drawable/ic_action_lock_open.xml new file mode 100644 index 000000000..169e93a75 --- /dev/null +++ b/app/src/main/res/drawable/ic_action_lock_open.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_action_note_add.xml b/app/src/main/res/drawable/ic_action_note_add.xml new file mode 100644 index 000000000..c8ffa8884 --- /dev/null +++ b/app/src/main/res/drawable/ic_action_note_add.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_action_settings.xml b/app/src/main/res/drawable/ic_action_settings.xml new file mode 100644 index 000000000..1f0802aa3 --- /dev/null +++ b/app/src/main/res/drawable/ic_action_settings.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_app_shortcut_background.xml b/app/src/main/res/drawable/ic_app_shortcut_background.xml new file mode 100644 index 000000000..d949e9f7a --- /dev/null +++ b/app/src/main/res/drawable/ic_app_shortcut_background.xml @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_av_playlist_add.xml b/app/src/main/res/drawable/ic_av_playlist_add.xml new file mode 100644 index 000000000..729667359 --- /dev/null +++ b/app/src/main/res/drawable/ic_av_playlist_add.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_info_24.xml b/app/src/main/res/drawable/ic_baseline_info_24.xml new file mode 100644 index 000000000..17255b7ae --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_info_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_insert_drive_file_24.xml b/app/src/main/res/drawable/ic_baseline_insert_drive_file_24.xml new file mode 100644 index 000000000..18b306228 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_insert_drive_file_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_communication_phonelink_ring.xml b/app/src/main/res/drawable/ic_communication_phonelink_ring.xml new file mode 100644 index 000000000..beaf1380f --- /dev/null +++ b/app/src/main/res/drawable/ic_communication_phonelink_ring.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_device_data_usage.xml b/app/src/main/res/drawable/ic_device_data_usage.xml new file mode 100644 index 000000000..6431414dd --- /dev/null +++ b/app/src/main/res/drawable/ic_device_data_usage.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_device_developer_mode.xml b/app/src/main/res/drawable/ic_device_developer_mode.xml new file mode 100644 index 000000000..1a41f6597 --- /dev/null +++ b/app/src/main/res/drawable/ic_device_developer_mode.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_file_cloud_queue.xml b/app/src/main/res/drawable/ic_file_cloud_queue.xml new file mode 100644 index 000000000..28d1f89c2 --- /dev/null +++ b/app/src/main/res/drawable/ic_file_cloud_queue.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_file_file_upload.xml b/app/src/main/res/drawable/ic_file_file_upload.xml new file mode 100644 index 000000000..11e906a05 --- /dev/null +++ b/app/src/main/res/drawable/ic_file_file_upload.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_hardware_router.xml b/app/src/main/res/drawable/ic_hardware_router.xml new file mode 100644 index 000000000..d6afc526d --- /dev/null +++ b/app/src/main/res/drawable/ic_hardware_router.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_image_camera_alt.xml b/app/src/main/res/drawable/ic_image_camera_alt.xml new file mode 100644 index 000000000..fb564d3e0 --- /dev/null +++ b/app/src/main/res/drawable/ic_image_camera_alt.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_image_edit.xml b/app/src/main/res/drawable/ic_image_edit.xml new file mode 100644 index 000000000..7e3c1b64a --- /dev/null +++ b/app/src/main/res/drawable/ic_image_edit.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_image_looks_6.xml b/app/src/main/res/drawable/ic_image_looks_6.xml new file mode 100644 index 000000000..441f83efb --- /dev/null +++ b/app/src/main/res/drawable/ic_image_looks_6.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_image_photo.xml b/app/src/main/res/drawable/ic_image_photo.xml new file mode 100644 index 000000000..722eadb9e --- /dev/null +++ b/app/src/main/res/drawable/ic_image_photo.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..d1ef753df --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_maps_360.xml b/app/src/main/res/drawable/ic_maps_360.xml new file mode 100644 index 000000000..c507034c7 --- /dev/null +++ b/app/src/main/res/drawable/ic_maps_360.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_maps_directions.xml b/app/src/main/res/drawable/ic_maps_directions.xml new file mode 100644 index 000000000..5b549dfba --- /dev/null +++ b/app/src/main/res/drawable/ic_maps_directions.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_maps_directions_boat.xml b/app/src/main/res/drawable/ic_maps_directions_boat.xml new file mode 100644 index 000000000..4b67183bc --- /dev/null +++ b/app/src/main/res/drawable/ic_maps_directions_boat.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_navigation_apps.xml b/app/src/main/res/drawable/ic_navigation_apps.xml new file mode 100644 index 000000000..941ab03b6 --- /dev/null +++ b/app/src/main/res/drawable/ic_navigation_apps.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_navigation_close.xml b/app/src/main/res/drawable/ic_navigation_close.xml new file mode 100644 index 000000000..7caff12f8 --- /dev/null +++ b/app/src/main/res/drawable/ic_navigation_close.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_navigation_menu.xml b/app/src/main/res/drawable/ic_navigation_menu.xml new file mode 100644 index 000000000..f170e1fae --- /dev/null +++ b/app/src/main/res/drawable/ic_navigation_menu.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_notification_enhanced_encryption.xml b/app/src/main/res/drawable/ic_notification_enhanced_encryption.xml new file mode 100644 index 000000000..b186b2254 --- /dev/null +++ b/app/src/main/res/drawable/ic_notification_enhanced_encryption.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_qu_camera_launcher.xml b/app/src/main/res/drawable/ic_qu_camera_launcher.xml new file mode 100644 index 000000000..d0a978198 --- /dev/null +++ b/app/src/main/res/drawable/ic_qu_camera_launcher.xml @@ -0,0 +1,18 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_qu_shadowsocks_foreground.xml b/app/src/main/res/drawable/ic_qu_shadowsocks_foreground.xml new file mode 100644 index 000000000..8262da94b --- /dev/null +++ b/app/src/main/res/drawable/ic_qu_shadowsocks_foreground.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_qu_shadowsocks_launcher.xml b/app/src/main/res/drawable/ic_qu_shadowsocks_launcher.xml new file mode 100755 index 000000000..f8bdfcfe3 --- /dev/null +++ b/app/src/main/res/drawable/ic_qu_shadowsocks_launcher.xml @@ -0,0 +1,18 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_service_active.xml b/app/src/main/res/drawable/ic_service_active.xml new file mode 100755 index 000000000..33062676e --- /dev/null +++ b/app/src/main/res/drawable/ic_service_active.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_service_busy.xml b/app/src/main/res/drawable/ic_service_busy.xml new file mode 100755 index 000000000..3adac9f34 --- /dev/null +++ b/app/src/main/res/drawable/ic_service_busy.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_service_connected.xml b/app/src/main/res/drawable/ic_service_connected.xml new file mode 100644 index 000000000..18c896070 --- /dev/null +++ b/app/src/main/res/drawable/ic_service_connected.xml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_service_connecting.xml b/app/src/main/res/drawable/ic_service_connecting.xml new file mode 100644 index 000000000..45f8f1559 --- /dev/null +++ b/app/src/main/res/drawable/ic_service_connecting.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_service_idle.xml b/app/src/main/res/drawable/ic_service_idle.xml new file mode 100755 index 000000000..205b81d48 --- /dev/null +++ b/app/src/main/res/drawable/ic_service_idle.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_service_stopped.xml b/app/src/main/res/drawable/ic_service_stopped.xml new file mode 100644 index 000000000..3d2e28bc9 --- /dev/null +++ b/app/src/main/res/drawable/ic_service_stopped.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_service_stopping.xml b/app/src/main/res/drawable/ic_service_stopping.xml new file mode 100644 index 000000000..87f9233b6 --- /dev/null +++ b/app/src/main/res/drawable/ic_service_stopping.xml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_settings_password.xml b/app/src/main/res/drawable/ic_settings_password.xml new file mode 100644 index 000000000..50b08a264 --- /dev/null +++ b/app/src/main/res/drawable/ic_settings_password.xml @@ -0,0 +1,31 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_social_emoji_symbols.xml b/app/src/main/res/drawable/ic_social_emoji_symbols.xml new file mode 100644 index 000000000..77549efdc --- /dev/null +++ b/app/src/main/res/drawable/ic_social_emoji_symbols.xml @@ -0,0 +1,28 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_social_share.xml b/app/src/main/res/drawable/ic_social_share.xml new file mode 100644 index 000000000..da0c4fb6c --- /dev/null +++ b/app/src/main/res/drawable/ic_social_share.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 000000000..a32a1d276 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/app_bar_main.xml b/app/src/main/res/layout/app_bar_main.xml new file mode 100644 index 000000000..2a1565abf --- /dev/null +++ b/app/src/main/res/layout/app_bar_main.xml @@ -0,0 +1,23 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/configurtion_list_main.xml b/app/src/main/res/layout/configurtion_list_main.xml new file mode 100644 index 000000000..e82b5ee74 --- /dev/null +++ b/app/src/main/res/layout/configurtion_list_main.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/content_main.xml b/app/src/main/res/layout/content_main.xml new file mode 100644 index 000000000..23466cc4c --- /dev/null +++ b/app/src/main/res/layout/content_main.xml @@ -0,0 +1,22 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/group_list_main.xml b/app/src/main/res/layout/group_list_main.xml new file mode 100644 index 000000000..ccaeed1ba --- /dev/null +++ b/app/src/main/res/layout/group_list_main.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/layout_profile.xml b/app/src/main/res/layout/layout_profile.xml new file mode 100644 index 000000000..faea1f399 --- /dev/null +++ b/app/src/main/res/layout/layout_profile.xml @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/settings_activity.xml b/app/src/main/res/layout/settings_activity.xml new file mode 100644 index 000000000..9ccbda590 --- /dev/null +++ b/app/src/main/res/layout/settings_activity.xml @@ -0,0 +1,19 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/activity_main_drawer.xml b/app/src/main/res/menu/activity_main_drawer.xml new file mode 100644 index 000000000..eb16e4313 --- /dev/null +++ b/app/src/main/res/menu/activity_main_drawer.xml @@ -0,0 +1,18 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/main.xml b/app/src/main/res/menu/main.xml new file mode 100644 index 000000000..412d5f844 --- /dev/null +++ b/app/src/main/res/menu/main.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..7353dbd1f --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..7353dbd1f --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..382595236 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..94e67db82 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 000000000..3c858780b Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..7170cbfe2 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..b05cd8ab8 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 000000000..06efdec58 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..b84ff3867 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..14553839f Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 000000000..f87ab4e80 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..c2359fcb8 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..a25c92f9a Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..283b71201 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..cf5c82020 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..15ab3d100 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..b50793409 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/navigation/mobile_navigation.xml b/app/src/main/res/navigation/mobile_navigation.xml new file mode 100644 index 000000000..68f3b5602 --- /dev/null +++ b/app/src/main/res/navigation/mobile_navigation.xml @@ -0,0 +1,19 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml new file mode 100644 index 000000000..6cf9ed481 --- /dev/null +++ b/app/src/main/res/values/arrays.xml @@ -0,0 +1,12 @@ + + + + Reply + Reply to all + + + + reply + reply_all + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 000000000..257adf9f3 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,39 @@ + + + + #F8BBD0 + #F06292 + #E91E63 + #D81B60 + #C2185B + #AD1457 + #880E4F + #C51162 + + @color/material_pink_100 + @color/material_pink_300 + @color/material_pink_500 + @color/material_pink_600 + @color/material_pink_700 + @color/material_pink_800 + @color/material_pink_900 + @color/material_pink_a700 + + @color/material_primary_500 + @color/material_primary_700 + @color/material_primary_500 + @color/material_primary_800 + @color/material_primary_900 + @color/material_primary_300 + + @color/light_color_primary + @color/light_color_primary_dark + @color/light_color_primary_text + + @color/material_primary_100 + @color/material_primary_300 + #ffab00 + + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 000000000..61f73fd8e --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,14 @@ + + 16dp + 16dp + 8dp + 176dp + 16dp + 264dp + 8dp + 88dp + 8dp + 0dp + 0dp + 48dp + \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 000000000..fbd18fe02 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFF00 + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..d3dcf1ceb --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,188 @@ + + SagerNet + Open navigation drawer + Close navigation drawer + Navigation header + Settings + + Configuration + About + + Default + 127.0.0.1:1080 + socks5 + + Toggle + Send email + + + Service mode + Proxy only + VPN + Transproxy + Share over LAN + SOCKS5 proxy port + Local DNS port + Transproxy port + + Remote DNS + %1$s↑\t%2$s↓ + Sent: \t\t\t\t\t%3$s\t↑\t%1$s\nReceived: \t%4$s\t↓\t%2$s + %s/s + Check Connectivity + Testing… + Success: HTTPS handshake took %dms + Fail to detect internet connection: %s + Internet Unavailable + Error code: #%d + + + Profile Name + Server + Remote Port + Password + Encrypt Method + + + IPv6 Route + Redirect IPv6 traffic to remote + Metered Hint + Hint system to treat VPN as metered + Route + All + Bypass LAN + Bypass mainland China + Bypass LAN & mainland China + GFW List + China List + Apps VPN mode + Configure VPN mode for selected apps + On + Off + Mode + Bypass + Enable this option to bypass selected apps + Auto Connect + Enable Shadowsocks on startup/app update if it was running before + Allow Toggling in Lock Screen + Your selected profile information will be less protected + + 1 hostname configured + %d hostnames configured + + Send DNS over UDP + Requires UDP forwarding on server side + UDP Fallback + + + VPN Service + Proxy Service + Transproxy Service + SagerNet started. + Invalid server name + Failed to connect the remote server + Stop + Shutting down… + %s + Permission denied to create a VPN service + Failed to start VPN service. You might need to reboot your device. + No valid profile data found. + + + Please select a profile + Proxy/Password should not be empty + Connect + + + Profiles + Settings + FAQ + https://github.com/shadowsocks/shadowsocks-android/blob/master/.github/faq.md + About + Shadowsocks %s + Edit + Share + Add Profile + Apply Settings to All Profiles + Export… + Export to file… + Export to Clipboard + Import from Clipboard + Import from file… + Replace from file… + Successfully export! + Failed to export. + Successfully import! + Failed to import. + + + Profile config + Remove + Are you sure you want to remove this profile? + QR code + Add this Shadowsocks Profile? + Scan QR code + Manual Settings + Camera permission is required for scanning QR code. + + Removed + %d items removed + + Undo + + + Start the service + Connect to the current server + Connect to %s + Switch to %s + Use the current profile + + + Connecting… + Connected, tap to check connection + Not connected + + + Subscriptions + Add a subscription + Edit subscription + Refresh servers from subscription + Subscription Service + Syncing subscriptions… (%d of %d) + Finishing up… + + + Custom rules + Add rule(s)… + Subnet or Hostname PCRE pattern + Domain name and all its subdomain names + URL to online config + Edit rule + Cleartext HTTP traffic is insecure + + + Plugin + Configure… + Disabled + Unknown plugin %s + Warning: This plugin does not seem to come from a known trusted source. + This plugin might not work with Auto Connect + SocksSettingsActivity + + + Messages + Sync + + + Your signature + Default reply action + + + Sync email periodically + Download incoming attachments + Automatically download attachments for incoming emails + + Only download attachments when manually requested + SettingsActivity + + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 000000000..45e4e0998 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/root_preferences.xml b/app/src/main/res/xml/root_preferences.xml new file mode 100644 index 000000000..8a3865721 --- /dev/null +++ b/app/src/main/res/xml/root_preferences.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/test/java/io/nekohasekai/sagernet/ExampleUnitTest.kt b/app/src/test/java/io/nekohasekai/sagernet/ExampleUnitTest.kt new file mode 100644 index 000000000..40d34075f --- /dev/null +++ b/app/src/test/java/io/nekohasekai/sagernet/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package io.nekohasekai.sagernet + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/bin/init/env.sh b/bin/init/env.sh new file mode 100755 index 000000000..f64975d4a --- /dev/null +++ b/bin/init/env.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +if [ -z "$ANDROID_HOME" ]; then + if [ -d "$HOME/Android/Sdk" ]; then + export ANDROID_HOME="$HOME/Android/Sdk" + elif [ -d "$HOME/.local/lib/android/sdk" ]; then + export ANDROID_HOME="$HOME/.local/lib/android/sdk" + fi +fi + +_NDK="$ANDROID_HOME/ndk/21.4.7075529" +[ -f "$_NDK/source.properties" ] || _NDK="$ANDROID_NDK_HOME" +[ -f "$_NDK/source.properties" ] || _NDK="$NDK" +[ -f "$_NDK/source.properties" ] || _NDK="$ANDROID_HOME/ndk-bundle" + +if [ ! -f "$_NDK/source.properties" ]; then + echo "Error: NDK not found." + exit 1 +fi + +export ANDROID_NDK_HOME=$_NDK +export NDK=$_NDK +export PROJECT=$(realpath .) + +if [ ! $(command -v go) ]; then + if [ -d /usr/lib/go-1.16 ]; then + export PATH=$PATH:/usr/lib/go-1.16/bin + elif [ -d $HOME/.go ]; then + export PATH=$PATH:$HOME/.go/bin + fi +fi + +if [ $(command -v go) ]; then + export PATH=$PATH:$(go env GOPATH)/bin +fi diff --git a/bin/libs/v2ray.sh b/bin/libs/v2ray.sh new file mode 100755 index 000000000..36b80da53 --- /dev/null +++ b/bin/libs/v2ray.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +bin/libs/v2ray/init.sh +bin/libs/v2ray/build.sh \ No newline at end of file diff --git a/bin/libs/v2ray/build.sh b/bin/libs/v2ray/build.sh new file mode 100755 index 000000000..141348136 --- /dev/null +++ b/bin/libs/v2ray/build.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +source "bin/init/env.sh" +export GO111MOUDLE=on +export GO386=softfloat + +cd "$PROJECT/v2ray" +gomobile init +gomobile bind -v -ldflags='-s -w' . || exit 1 + +mkdir -p "$PROJECT/app/libs" +/bin/cp -f libv2ray.aar "$PROJECT/app/libs" diff --git a/bin/libs/v2ray/init.sh b/bin/libs/v2ray/init.sh new file mode 100755 index 000000000..2f37ac04a --- /dev/null +++ b/bin/libs/v2ray/init.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +source "bin/init/env.sh" +export GO111MOUDLE=on +export PATH="$PATH:$(go env GOPATH)/bin" + +cd $PROJECT +[ -f v2ray/go.mod ] || git submodule update --init v2ray +cd v2ray +git reset --hard && git clean -fdx +go mod download -x && go get -v golang.org/x/mobile/cmd/... || exit 1 diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000..9f1156b22 --- /dev/null +++ b/build.gradle @@ -0,0 +1,29 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +buildscript { + ext { + kotlin_version = "1.4.32" + } + repositories { + google() + jcenter() + } + dependencies { + classpath "com.android.tools.build:gradle:4.1.3" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +allprojects { + repositories { + google() + jcenter() + maven { url 'https://jitpack.io' } + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 000000000..98bed167d --- /dev/null +++ b/gradle.properties @@ -0,0 +1,21 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Automatically convert third-party libraries to use AndroidX +android.enableJetifier=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..f6b961fd5 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..014b41d2f --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue Mar 02 12:11:09 CST 2021 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.3-bin.zip diff --git a/gradlew b/gradlew new file mode 100755 index 000000000..cccdd3d51 --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000..f9553162f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/run b/run new file mode 100755 index 000000000..df596d7de --- /dev/null +++ b/run @@ -0,0 +1,14 @@ +#!/bin/bash + +EXEC="" +TARGET="bin" +for e in $@; do + TARGET="$TARGET/$e" + shift + if [ -x "${TARGET}.sh" ]; then + EXEC="${TARGET}.sh" + fi +done + +echo ">> $EXEC" +exec "$EXEC" $@ \ No newline at end of file diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 000000000..f2b9cecfb --- /dev/null +++ b/settings.gradle @@ -0,0 +1,2 @@ +include ':app' +rootProject.name = "SagerNet" \ No newline at end of file diff --git a/v2ray b/v2ray new file mode 160000 index 000000000..a697372e9 --- /dev/null +++ b/v2ray @@ -0,0 +1 @@ +Subproject commit a697372e996000fa748f87b4c2bad4da038b5ddf