commit 7d9798cc271a1dc28e930feb9d1ab463cb777ce6 Author: arm64v8a <48624112+arm64v8a@users.noreply.github.com> Date: Wed Mar 15 00:00:00 2023 +0000 upload code diff --git a/.github/ISSUE_TEMPLATE/bug-report-zh_cn.md b/.github/ISSUE_TEMPLATE/bug-report-zh_cn.md new file mode 100644 index 0000000..d9bbafd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report-zh_cn.md @@ -0,0 +1,22 @@ +--- +name: Bug Report zh_CN +about: 问题反馈,在提出问题前请先自行排除服务器端问题和升级到最新客户端。 +title: '' +labels: '' +assignees: '' + +--- + +**描述问题** + +预期行为: + +实际行为: + +**如何复现** + +提供有帮助的截图,录像,文字说明,订阅链接等。 + +**日志** + +如果有日志,请上传。请在文档内查看导出日志的详细步骤。 diff --git a/.github/ISSUE_TEMPLATE/feature_request-zh_cn.md b/.github/ISSUE_TEMPLATE/feature_request-zh_cn.md new file mode 100644 index 0000000..b8a79e8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request-zh_cn.md @@ -0,0 +1,12 @@ +--- +name: Feature Request zh_CN +about: 功能请求,提出建议。 +title: '' +labels: '' +assignees: '' + +--- + +**描述建议** + +**建议的必要性** diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e8f6148 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,171 @@ +name: Release Build +on: + workflow_dispatch: + inputs: + tag: + description: 'Release Tag' + required: true + upload: + description: 'Upload: If want ignore' + required: false + publish: + description: 'Publish: If want ignore' + required: false + play: + description: 'Play: If want ignore' + required: false +jobs: + libcore: + name: Native Build (LibCore) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Golang Status + run: find buildScript libcore/*.sh | xargs cat | sha1sum > golang_status + - name: Libcore Status + run: git ls-files libcore | xargs cat | sha1sum > libcore_status + - name: LibCore Cache + id: cache + uses: actions/cache@v3 + with: + path: | + app/libs/libcore.aar + key: ${{ hashFiles('.github/workflows/*', 'golang_status', 'libcore_status') }} + - name: Golang Cache + if: steps.cache.outputs.cache-hit != 'true' + uses: actions/cache@v3 + with: + path: build/golang + key: go-${{ hashFiles('.github/workflows/*', 'golang_status') }} + - name: Native Build + if: steps.cache.outputs.cache-hit != 'true' + run: ./run init action go && ./run lib core + build: + name: Build OSS APK + runs-on: ubuntu-latest + needs: + - libcore + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Golang Status + run: find buildScript libcore/*.sh | xargs cat | sha1sum > golang_status + - name: Libcore Status + run: git ls-files libcore | xargs cat | sha1sum > libcore_status + - name: LibCore Cache + uses: actions/cache@v3 + with: + path: | + app/libs/libcore.aar + key: ${{ hashFiles('.github/workflows/*', 'golang_status', 'libcore_status') }} + - name: Gradle cache + uses: actions/cache@v3 + with: + path: ~/.gradle + key: gradle-oss-${{ hashFiles('**/*.gradle.kts') }} + - name: Gradle Build + env: + BUILD_PLUGIN: none + run: | + echo "sdk.dir=${ANDROID_HOME}" > local.properties + echo "ndk.dir=${ANDROID_HOME}/ndk/25.0.8775105" >> local.properties + export LOCAL_PROPERTIES="${{ secrets.LOCAL_PROPERTIES }}" + ./run init action gradle + ./gradlew app:assembleOssRelease + APK=$(find app/build/outputs/apk -name '*arm64-v8a*.apk') + APK=$(dirname $APK) + echo "APK=$APK" >> $GITHUB_ENV + - uses: actions/upload-artifact@v3 + with: + name: APKs + path: ${{ env.APK }} + publish: + name: Publish Release + if: github.event.inputs.publish != 'y' + runs-on: ubuntu-latest + needs: build + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Donwload Artifacts + uses: actions/download-artifact@v3 + with: + name: APKs + path: artifacts + - name: Release + run: | + wget -O ghr.tar.gz https://github.com/tcnksm/ghr/releases/download/v0.13.0/ghr_v0.13.0_linux_amd64.tar.gz + tar -xvf ghr.tar.gz + mv ghr*linux_amd64/ghr . + mkdir apks + find artifacts -name "*.apk" -exec cp {} apks \; + ./ghr -delete -t "${{ github.token }}" -n "${{ github.event.inputs.tag }}" "${{ github.event.inputs.tag }}" apks + upload: + name: Upload Release + if: github.event.inputs.upload != 'y' + runs-on: ubuntu-latest + needs: build + steps: + - name: Donwload Artifacts + uses: actions/download-artifact@v3 + with: + name: APKs + path: artifacts + - name: Release + run: | + mkdir apks + find artifacts -name "*.apk" -exec cp {} apks \; + + function upload() { + for apk in $@; do + echo ">> Uploading $apk" + curl https://api.telegram.org/bot${{ secrets.TELEGRAM_TOKEN }}/sendDocument \ + -X POST \ + -F chat_id="${{ secrets.TELEGRAM_CHANNEL }}" \ + -F document="@$apk" \ + --silent --show-error --fail >/dev/null & + done + for job in $(jobs -p); do + wait $job || exit 1 + done + } + upload apks/* + play: + name: Build Play Bundle + if: github.event.inputs.play != 'y' + runs-on: ubuntu-latest + needs: + - libcore + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Golang Status + run: find buildScript libcore/*.sh | xargs cat | sha1sum > golang_status + - name: Libcore Status + run: git ls-files libcore | xargs cat | sha1sum > libcore_status + - name: LibCore Cache + uses: actions/cache@v3 + with: + path: | + app/libs/libcore.aar + key: ${{ hashFiles('.github/workflows/*', 'golang_status', 'libcore_status') }} + - name: Gradle cache + uses: actions/cache@v3 + with: + path: ~/.gradle + key: gradle-play-${{ hashFiles('**/*.gradle.kts') }} + - name: Checkout Library + run: | + git submodule update --init 'app/*' + - name: Gradle Build + run: | + echo "sdk.dir=${ANDROID_HOME}" > local.properties + echo "ndk.dir=${ANDROID_HOME}/ndk/25.0.8775105" >> local.properties + export LOCAL_PROPERTIES="${{ secrets.LOCAL_PROPERTIES }}" + ./run init action gradle + ./gradlew bundlePlayRelease + - uses: actions/upload-artifact@v3 + with: + name: AAB + path: app/build/outputs/bundle/playRelease/app-play-release.aab diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c1fe39 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +*.iml +.gradle +.idea +.vscode +.DS_Store +build/ +/captures +.externalNativeBuild +.cxx +local.properties +/app/libs/ +/app/src/main/assets/sing-box +/service_account_credentials.json +jniLibs/ +/library/libcore_build/ +.idea/deploymentTargetDropDown.xml +/nkmr + +# submodules +/external diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000..4136f4f --- /dev/null +++ b/AUTHORS @@ -0,0 +1,8 @@ +SagerNet was originally created in late 2021, by +nekohasekai . + +Here is an inevitably incomplete list of MUCH-APPRECIATED CONTRIBUTORS -- +people who have submitted patches, fixed bugs, added translations, and +generally made SagerNet that much better: + +https://github.com/SagerNet/SagerNet/graphs/contributors diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..16f27f1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,14 @@ +Copyright (C) 2021 by nekohasekai + +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 . \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..577a519 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# NekoBox for Android + +[![API](https://img.shields.io/badge/API-21%2B-brightgreen.svg?style=flat)](https://android-arsenal.com/api?level=21) +[![Releases](https://img.shields.io/github/v/release/MatsuriDayo/NekoBoxForAndroid)](https://github.com/MatsuriDayo/NekoBoxForAndroid/releases) +[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-orange.svg)](https://www.gnu.org/licenses/gpl-3.0) + +sing-box / universal proxy toolchain for Android. + +## 下载 / Downloads + +### GitHub Releases + +[![GitHub All Releases](https://img.shields.io/github/downloads/Matsuridayo/NekoBoxForAndroid/total?label=downloads-total&logo=github&style=flat-square)](https://github.com/Matsuridayo/NekoBoxForAndroid/releases) + +[下载](https://github.com/Matsuridayo/NekoBoxForAndroid/releases) + +## 更改记录 & 发布频道 / Changelog & Telegram channel + +https://t.me/Matsuridayo + +## 项目主页 & 文档 / Homepage & Documents + +https://matsuridayo.github.io + +## 代理 / Proxy + +* SOCKS (4/4a/5) +* HTTP(S) +* SSH +* Shadowsocks +* VMess +* VLESS +* WireGuard +* Trojan +* Trojan-Go ( trojan-go-plugin ) +* NaïveProxy ( naive-plugin ) +* Hysteria ( hysteria-plugin ) + +请到项目主页下载插件。 + +Please go to the project homepage to download plugins. + +### 订阅 / Subscription + +* Raw: some widely used formats (like shadowsocks, clash and v2rayN) +* 原始格式:一些广泛使用的格式(如 shadowsocks、clash 和 v2rayN) +* [Open Online Config](https://github.com/Shadowsocks-NET/OpenOnlineConfig) +* [Shadowsocks SIP008](https://shadowsocks.org/guide/sip008.html) + +### 捐助 / Donate + +欢迎捐赠以支持项目开发。 + +USDT TRC20 + +`TRhnA7SXE5Sap5gSG3ijxRmdYFiD4KRhPs` + +XMR + +`49bwESYQjoRL3xmvTcjZKHEKaiGywjLYVQJMUv79bXonGiyDCs8AzE3KiGW2ytTybBCpWJUvov8SjZZEGg66a4e59GXa6k5` diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..3f2a4d9 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,2 @@ +/build +/schemas diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..daee17b --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,81 @@ +plugins { + id("com.android.application") + id("kotlin-android") + id("kotlin-kapt") + id("kotlin-parcelize") +} + +setupApp() + +android { + compileOptions { + isCoreLibraryDesugaringEnabled = true + } + kapt.arguments { + arg("room.incremental", true) + arg("room.schemaLocation", "$projectDir/schemas") + } + bundle { + language { + enableSplit = false + } + } + buildFeatures { + viewBinding = true + } + namespace = "io.nekohasekai.sagernet" +} + +dependencies { + + implementation(fileTree("libs")) + + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.3") + implementation("androidx.core:core-ktx:1.7.0") + implementation("androidx.recyclerview:recyclerview:1.2.1") + implementation("androidx.activity:activity-ktx:1.4.0") + implementation("androidx.fragment:fragment-ktx:1.4.1") + implementation("androidx.browser:browser:1.4.0") + implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + implementation("androidx.navigation:navigation-fragment-ktx:2.4.2") + implementation("androidx.navigation:navigation-ui-ktx:2.4.2") + implementation("androidx.preference:preference-ktx:1.2.0") + implementation("androidx.appcompat:appcompat:1.4.1") + implementation("androidx.work:work-runtime-ktx:2.7.1") + implementation("androidx.work:work-multiprocess:2.7.1") + + implementation(project(":external:preferencex:preferencex")) + implementation(project(":external:preferencex:preferencex-simplemenu")) + + implementation("com.google.android.material:material:1.6.0") + implementation("com.google.code.gson:gson:2.8.9") + + implementation("com.github.jenly1314:zxing-lite:2.1.1") + implementation("com.afollestad.material-dialogs:core:3.3.0") + implementation("com.afollestad.material-dialogs:input:3.3.0") + + implementation("com.squareup.okhttp3:okhttp:5.0.0-alpha.3") + implementation("org.yaml:snakeyaml:1.30") + implementation("com.github.daniel-stoneuk:material-about-library:3.2.0-rc01") + implementation("com.jakewharton:process-phoenix:2.1.2") + implementation("com.esotericsoftware:kryo:5.2.1") + implementation("com.google.guava:guava:31.0.1-android") + implementation("org.ini4j:ini4j:0.5.4") + + implementation("com.simplecityapps:recyclerview-fastscroll:2.0.1") { + exclude(group = "androidx.recyclerview") + exclude(group = "androidx.appcompat") + } + implementation("org.smali:dexlib2:2.5.2") { + exclude(group = "com.google.guava", module = "guava") + } + + implementation("androidx.room:room-runtime:2.4.2") + kapt("androidx.room:room-compiler:2.4.2") + implementation("androidx.room:room-ktx:2.4.2") + implementation("com.github.MatrixDev.Roomigrant:RoomigrantLib:0.3.4") + kapt("com.github.MatrixDev.Roomigrant:RoomigrantCompiler:0.3.4") + + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.5") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..1d4a8bc --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,47 @@ +-repackageclasses '' +-allowaccessmodification + +-keep class io.nekohasekai.sagernet.** { *;} +-keep class moe.matsuri.nb4a.** { *;} + +# Clean Kotlin +-assumenosideeffects class kotlin.jvm.internal.Intrinsics { + static void checkParameterIsNotNull(java.lang.Object, java.lang.String); + static void checkExpressionValueIsNotNull(java.lang.Object, java.lang.String); + static void checkNotNullExpressionValue(java.lang.Object, java.lang.String); + static void checkReturnedValueIsNotNull(java.lang.Object, java.lang.String, java.lang.String); + static void checkReturnedValueIsNotNull(java.lang.Object, java.lang.String); + static void checkFieldIsNotNull(java.lang.Object, java.lang.String, java.lang.String); + static void checkFieldIsNotNull(java.lang.Object, java.lang.String); + static void checkNotNull(java.lang.Object); + static void checkNotNull(java.lang.Object, java.lang.String); + static void checkNotNullParameter(java.lang.Object, java.lang.String); + static void throwUninitializedPropertyAccessException(java.lang.String); +} + +# ini4j +-keep public class org.ini4j.spi.** { (); } + +# SnakeYaml +-keep class org.yaml.snakeyaml.** { *; } + +-dontobfuscate +-keepattributes SourceFile + +-dontwarn java.beans.BeanInfo +-dontwarn java.beans.FeatureDescriptor +-dontwarn java.beans.IntrospectionException +-dontwarn java.beans.Introspector +-dontwarn java.beans.PropertyDescriptor +-dontwarn java.beans.Transient +-dontwarn java.beans.VetoableChangeListener +-dontwarn java.beans.VetoableChangeSupport +-dontwarn org.apache.harmony.xnet.provider.jsse.SSLParametersImpl +-dontwarn org.bouncycastle.jce.provider.BouncyCastleProvider +-dontwarn org.bouncycastle.jsse.BCSSLParameters +-dontwarn org.bouncycastle.jsse.BCSSLSocket +-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider +-dontwarn org.openjsse.javax.net.ssl.SSLParameters +-dontwarn org.openjsse.javax.net.ssl.SSLSocket +-dontwarn org.openjsse.net.ssl.OpenJSSE +-dontwarn java.beans.PropertyVetoException 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 0000000..5e9db91 --- /dev/null +++ b/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/1.json @@ -0,0 +1,330 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "f66fd943df1d9e86d281a2e32c9fdd47", + "entities": [ + { + "tableName": "proxy_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userOrder` INTEGER NOT NULL, `ungrouped` INTEGER NOT NULL, `name` TEXT, `type` INTEGER NOT NULL, `subscription` BLOB, `order` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userOrder", + "columnName": "userOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ungrouped", + "columnName": "ungrouped", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscription", + "columnName": "subscription", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "proxy_entities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL, `type` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL, `tx` INTEGER NOT NULL, `rx` INTEGER NOT NULL, `status` INTEGER NOT NULL, `ping` INTEGER NOT NULL, `uuid` TEXT NOT NULL, `error` TEXT, `socksBean` BLOB, `httpBean` BLOB, `ssBean` BLOB, `vmessBean` BLOB, `trojanBean` BLOB, `trojanGoBean` BLOB, `naiveBean` BLOB, `hysteriaBean` BLOB, `tuicBean` BLOB, `sshBean` BLOB, `wgBean` BLOB, `chainBean` BLOB, `nekoBean` BLOB, `configBean` BLOB)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userOrder", + "columnName": "userOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tx", + "columnName": "tx", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rx", + "columnName": "rx", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ping", + "columnName": "ping", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "socksBean", + "columnName": "socksBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "httpBean", + "columnName": "httpBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "ssBean", + "columnName": "ssBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "vmessBean", + "columnName": "vmessBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "trojanBean", + "columnName": "trojanBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "trojanGoBean", + "columnName": "trojanGoBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "naiveBean", + "columnName": "naiveBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "hysteriaBean", + "columnName": "hysteriaBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "tuicBean", + "columnName": "tuicBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "sshBean", + "columnName": "sshBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "wgBean", + "columnName": "wgBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "chainBean", + "columnName": "chainBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "nekoBean", + "columnName": "nekoBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "configBean", + "columnName": "configBean", + "affinity": "BLOB", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "groupId", + "unique": false, + "columnNames": [ + "groupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `groupId` ON `${TABLE_NAME}` (`groupId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `userOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `domains` TEXT NOT NULL, `ip` TEXT NOT NULL, `port` TEXT NOT NULL, `sourcePort` TEXT NOT NULL, `network` TEXT NOT NULL, `source` TEXT NOT NULL, `protocol` TEXT NOT NULL, `outbound` INTEGER NOT NULL, `packages` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userOrder", + "columnName": "userOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domains", + "columnName": "domains", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ip", + "columnName": "ip", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "port", + "columnName": "port", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourcePort", + "columnName": "sourcePort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "network", + "columnName": "network", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "protocol", + "columnName": "protocol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "outbound", + "columnName": "outbound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packages", + "columnName": "packages", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f66fd943df1d9e86d281a2e32c9fdd47')" + ] + } +} \ 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 0000000..4986920 --- /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/schemas/moe.matsuri.nb4a.TempDatabase/1.json b/app/schemas/moe.matsuri.nb4a.TempDatabase/1.json new file mode 100644 index 0000000..4986920 --- /dev/null +++ b/app/schemas/moe.matsuri.nb4a.TempDatabase/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/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d6373d9 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,316 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/aidl/io/nekohasekai/sagernet/aidl/ISagerNetService.aidl b/app/src/main/aidl/io/nekohasekai/sagernet/aidl/ISagerNetService.aidl new file mode 100644 index 0000000..bb7ce08 --- /dev/null +++ b/app/src/main/aidl/io/nekohasekai/sagernet/aidl/ISagerNetService.aidl @@ -0,0 +1,13 @@ +package io.nekohasekai.sagernet.aidl; + +import io.nekohasekai.sagernet.aidl.ISagerNetServiceCallback; + +interface ISagerNetService { + int getState(); + String getProfileName(); + + void registerCallback(in ISagerNetServiceCallback cb); + oneway void unregisterCallback(in ISagerNetServiceCallback cb); + + int urlTest(); +} diff --git a/app/src/main/aidl/io/nekohasekai/sagernet/aidl/ISagerNetServiceCallback.aidl b/app/src/main/aidl/io/nekohasekai/sagernet/aidl/ISagerNetServiceCallback.aidl new file mode 100644 index 0000000..8eb485d --- /dev/null +++ b/app/src/main/aidl/io/nekohasekai/sagernet/aidl/ISagerNetServiceCallback.aidl @@ -0,0 +1,14 @@ +package io.nekohasekai.sagernet.aidl; + +import io.nekohasekai.sagernet.aidl.SpeedDisplayData; +import io.nekohasekai.sagernet.aidl.TrafficData; + +oneway interface ISagerNetServiceCallback { + void stateChanged(int state, String profileName, String msg); + void missingPlugin(String profileName, String pluginName); + void routeAlert(int type, String routeName); + void updateWakeLockStatus(boolean acquired); + void cbSpeedUpdate(in SpeedDisplayData stats); + void cbTrafficUpdate(in TrafficData stats); + void cbLogUpdate(String str); +} diff --git a/app/src/main/aidl/io/nekohasekai/sagernet/aidl/SpeedDisplayData.aidl b/app/src/main/aidl/io/nekohasekai/sagernet/aidl/SpeedDisplayData.aidl new file mode 100644 index 0000000..bc5f0fb --- /dev/null +++ b/app/src/main/aidl/io/nekohasekai/sagernet/aidl/SpeedDisplayData.aidl @@ -0,0 +1,3 @@ +package io.nekohasekai.sagernet.aidl; + +parcelable SpeedDisplayData; diff --git a/app/src/main/aidl/io/nekohasekai/sagernet/aidl/TrafficData.aidl b/app/src/main/aidl/io/nekohasekai/sagernet/aidl/TrafficData.aidl new file mode 100644 index 0000000..99d43b4 --- /dev/null +++ b/app/src/main/aidl/io/nekohasekai/sagernet/aidl/TrafficData.aidl @@ -0,0 +1,3 @@ +package io.nekohasekai.sagernet.aidl; + +parcelable TrafficData; diff --git a/app/src/main/assets/LICENSE b/app/src/main/assets/LICENSE new file mode 100644 index 0000000..c585bf6 --- /dev/null +++ b/app/src/main/assets/LICENSE @@ -0,0 +1,19 @@ +Copyright (C) 2021 by nekohasekai + + +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 . \ No newline at end of file diff --git a/app/src/main/assets/analysis.txt b/app/src/main/assets/analysis.txt new file mode 100644 index 0000000..a4f7cc7 --- /dev/null +++ b/app/src/main/assets/analysis.txt @@ -0,0 +1,5 @@ +domain:appcenter.ms +domain:app-measurement.com +domain:firebase.io +domain:crashlytics.com +domain:google-analytics.com \ No newline at end of file diff --git a/app/src/main/assets/yacd.version.txt b/app/src/main/assets/yacd.version.txt new file mode 100644 index 0000000..56a6051 --- /dev/null +++ b/app/src/main/assets/yacd.version.txt @@ -0,0 +1 @@ +1 \ No newline at end of file diff --git a/app/src/main/assets/yacd.zip b/app/src/main/assets/yacd.zip new file mode 100644 index 0000000..46d2b59 Binary files /dev/null and b/app/src/main/assets/yacd.zip differ diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..7ca0c2d Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/github/shadowsocks/plugin/Utils.kt b/app/src/main/java/com/github/shadowsocks/plugin/Utils.kt new file mode 100644 index 0000000..66ec88d --- /dev/null +++ b/app/src/main/java/com/github/shadowsocks/plugin/Utils.kt @@ -0,0 +1,9 @@ +@file:JvmName("Utils") + +package com.github.shadowsocks.plugin + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class Empty : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/github/shadowsocks/plugin/fragment/AlertDialogFragment.kt b/app/src/main/java/com/github/shadowsocks/plugin/fragment/AlertDialogFragment.kt new file mode 100644 index 0000000..870b57a --- /dev/null +++ b/app/src/main/java/com/github/shadowsocks/plugin/fragment/AlertDialogFragment.kt @@ -0,0 +1,60 @@ +package com.github.shadowsocks.plugin.fragment + +import android.app.Activity +import android.content.DialogInterface +import android.os.Bundle +import android.os.Parcelable +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatDialogFragment +import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResult +import androidx.fragment.app.setFragmentResultListener +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +/** + * Based on: https://android.googlesource.com/platform/ +packages/apps/ExactCalculator/+/8c43f06/src/com/android/calculator2/AlertDialogFragment.java + */ +abstract class AlertDialogFragment : + AppCompatDialogFragment(), DialogInterface.OnClickListener { + companion object { + private const val KEY_RESULT = "result" + private const val KEY_ARG = "arg" + private const val KEY_RET = "ret" + private const val KEY_WHICH = "which" + + fun setResultListener(fragment: Fragment, requestKey: String, + listener: (Int, Ret?) -> Unit) { + fragment.setFragmentResultListener(requestKey) { _, bundle -> + listener(bundle.getInt(KEY_WHICH, Activity.RESULT_CANCELED), bundle.getParcelable(KEY_RET)) + } + } + inline fun , Ret : Parcelable?> setResultListener( + fragment: Fragment, noinline listener: (Int, Ret?) -> Unit) = + setResultListener(fragment, T::class.java.name, listener) + } + protected abstract fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) + + private val resultKey get() = requireArguments().getString(KEY_RESULT) + protected val arg by lazy { requireArguments().getParcelable(KEY_ARG)!! } + protected open fun ret(which: Int): Ret? = null + + private fun args() = arguments ?: Bundle().also { arguments = it } + fun arg(arg: Arg) = args().putParcelable(KEY_ARG, arg) + fun key(resultKey: String = javaClass.name) = args().putString(KEY_RESULT, resultKey) + + override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog = + MaterialAlertDialogBuilder(requireContext()).also { it.prepare(this) }.create() + + override fun onClick(dialog: DialogInterface?, which: Int) { + setFragmentResult(resultKey ?: return, Bundle().apply { + putInt(KEY_WHICH, which) + putParcelable(KEY_RET, ret(which) ?: return@apply) + }) + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + onClick(null, Activity.RESULT_CANCELED) + } +} diff --git a/app/src/main/java/com/wireguard/crypto/Curve25519.java b/app/src/main/java/com/wireguard/crypto/Curve25519.java new file mode 100644 index 0000000..55f2809 --- /dev/null +++ b/app/src/main/java/com/wireguard/crypto/Curve25519.java @@ -0,0 +1,497 @@ +/* + * Copyright © 2016 Southern Storm Software, Pty Ltd. + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.crypto; + +import androidx.annotation.Nullable; + +import java.util.Arrays; + +/** + * Implementation of Curve25519 ECDH. + *

+ * This implementation was imported to WireGuard from noise-java: + * https://github.com/rweather/noise-java + *

+ * This implementation is based on that from arduinolibs: + * https://github.com/rweather/arduinolibs + *

+ * Differences in this version are due to using 26-bit limbs for the + * representation instead of the 8/16/32-bit limbs in the original. + *

+ * References: http://cr.yp.to/ecdh.html, RFC 7748 + */ +@SuppressWarnings({"MagicNumber", "NonConstantFieldWithUpperCaseName", "SuspiciousNameCombination"}) +public final class Curve25519 { + // Numbers modulo 2^255 - 19 are broken up into ten 26-bit words. + private static final int NUM_LIMBS_255BIT = 10; + private static final int NUM_LIMBS_510BIT = 20; + + private final int[] A; + private final int[] AA; + private final int[] B; + private final int[] BB; + private final int[] C; + private final int[] CB; + private final int[] D; + private final int[] DA; + private final int[] E; + private final long[] t1; + private final int[] t2; + private final int[] x_1; + private final int[] x_2; + private final int[] x_3; + private final int[] z_2; + private final int[] z_3; + + /** + * Constructs the temporary state holder for Curve25519 evaluation. + */ + private Curve25519() { + // Allocate memory for all of the temporary variables we will need. + x_1 = new int[NUM_LIMBS_255BIT]; + x_2 = new int[NUM_LIMBS_255BIT]; + x_3 = new int[NUM_LIMBS_255BIT]; + z_2 = new int[NUM_LIMBS_255BIT]; + z_3 = new int[NUM_LIMBS_255BIT]; + A = new int[NUM_LIMBS_255BIT]; + B = new int[NUM_LIMBS_255BIT]; + C = new int[NUM_LIMBS_255BIT]; + D = new int[NUM_LIMBS_255BIT]; + E = new int[NUM_LIMBS_255BIT]; + AA = new int[NUM_LIMBS_255BIT]; + BB = new int[NUM_LIMBS_255BIT]; + DA = new int[NUM_LIMBS_255BIT]; + CB = new int[NUM_LIMBS_255BIT]; + t1 = new long[NUM_LIMBS_510BIT]; + t2 = new int[NUM_LIMBS_510BIT]; + } + + /** + * Conditional swap of two values. + * + * @param select Set to 1 to swap, 0 to leave as-is. + * @param x The first value. + * @param y The second value. + */ + private static void cswap(int select, final int[] x, final int[] y) { + select = -select; + for (int index = 0; index < NUM_LIMBS_255BIT; ++index) { + final int dummy = select & (x[index] ^ y[index]); + x[index] ^= dummy; + y[index] ^= dummy; + } + } + + /** + * Evaluates the Curve25519 curve. + * + * @param result Buffer to place the result of the evaluation into. + * @param offset Offset into the result buffer. + * @param privateKey The private key to use in the evaluation. + * @param publicKey The public key to use in the evaluation, or null + * if the base point of the curve should be used. + */ + public static void eval(final byte[] result, final int offset, + final byte[] privateKey, @Nullable final byte[] publicKey) { + final Curve25519 state = new Curve25519(); + try { + // Unpack the public key value. If null, use 9 as the base point. + Arrays.fill(state.x_1, 0); + if (publicKey != null) { + // Convert the input value from little-endian into 26-bit limbs. + for (int index = 0; index < 32; ++index) { + final int bit = (index * 8) % 26; + final int word = (index * 8) / 26; + final int value = publicKey[index] & 0xFF; + if (bit <= (26 - 8)) { + state.x_1[word] |= value << bit; + } else { + state.x_1[word] |= value << bit; + state.x_1[word] &= 0x03FFFFFF; + state.x_1[word + 1] |= value >> (26 - bit); + } + } + + // Just in case, we reduce the number modulo 2^255 - 19 to + // make sure that it is in range of the field before we start. + // This eliminates values between 2^255 - 19 and 2^256 - 1. + state.reduceQuick(state.x_1); + state.reduceQuick(state.x_1); + } else { + state.x_1[0] = 9; + } + + // Initialize the other temporary variables. + Arrays.fill(state.x_2, 0); // x_2 = 1 + state.x_2[0] = 1; + Arrays.fill(state.z_2, 0); // z_2 = 0 + System.arraycopy(state.x_1, 0, state.x_3, 0, state.x_1.length); // x_3 = x_1 + Arrays.fill(state.z_3, 0); // z_3 = 1 + state.z_3[0] = 1; + + // Evaluate the curve for every bit of the private key. + state.evalCurve(privateKey); + + // Compute x_2 * (z_2 ^ (p - 2)) where p = 2^255 - 19. + state.recip(state.z_3, state.z_2); + state.mul(state.x_2, state.x_2, state.z_3); + + // Convert x_2 into little-endian in the result buffer. + for (int index = 0; index < 32; ++index) { + final int bit = (index * 8) % 26; + final int word = (index * 8) / 26; + if (bit <= (26 - 8)) + result[offset + index] = (byte) (state.x_2[word] >> bit); + else + result[offset + index] = (byte) ((state.x_2[word] >> bit) | (state.x_2[word + 1] << (26 - bit))); + } + } finally { + // Clean up all temporary state before we exit. + state.destroy(); + } + } + + /** + * Subtracts two numbers modulo 2^255 - 19. + * + * @param result The result. + * @param x The first number to subtract. + * @param y The second number to subtract. + */ + private static void sub(final int[] result, final int[] x, final int[] y) { + int index; + int borrow; + + // Subtract y from x to generate the intermediate result. + borrow = 0; + for (index = 0; index < NUM_LIMBS_255BIT; ++index) { + borrow = x[index] - y[index] - ((borrow >> 26) & 0x01); + result[index] = borrow & 0x03FFFFFF; + } + + // If we had a borrow, then the result has gone negative and we + // have to add 2^255 - 19 to the result to make it positive again. + // The top bits of "borrow" will be all 1's if there is a borrow + // or it will be all 0's if there was no borrow. Easiest is to + // conditionally subtract 19 and then mask off the high bits. + borrow = result[0] - ((-((borrow >> 26) & 0x01)) & 19); + result[0] = borrow & 0x03FFFFFF; + for (index = 1; index < NUM_LIMBS_255BIT; ++index) { + borrow = result[index] - ((borrow >> 26) & 0x01); + result[index] = borrow & 0x03FFFFFF; + } + result[NUM_LIMBS_255BIT - 1] &= 0x001FFFFF; + } + + /** + * Adds two numbers modulo 2^255 - 19. + * + * @param result The result. + * @param x The first number to add. + * @param y The second number to add. + */ + private void add(final int[] result, final int[] x, final int[] y) { + int carry = x[0] + y[0]; + result[0] = carry & 0x03FFFFFF; + for (int index = 1; index < NUM_LIMBS_255BIT; ++index) { + carry = (carry >> 26) + x[index] + y[index]; + result[index] = carry & 0x03FFFFFF; + } + reduceQuick(result); + } + + /** + * Destroy all sensitive data in this object. + */ + private void destroy() { + // Destroy all temporary variables. + Arrays.fill(x_1, 0); + Arrays.fill(x_2, 0); + Arrays.fill(x_3, 0); + Arrays.fill(z_2, 0); + Arrays.fill(z_3, 0); + Arrays.fill(A, 0); + Arrays.fill(B, 0); + Arrays.fill(C, 0); + Arrays.fill(D, 0); + Arrays.fill(E, 0); + Arrays.fill(AA, 0); + Arrays.fill(BB, 0); + Arrays.fill(DA, 0); + Arrays.fill(CB, 0); + Arrays.fill(t1, 0L); + Arrays.fill(t2, 0); + } + + /** + * Evaluates the curve for every bit in a secret key. + * + * @param s The 32-byte secret key. + */ + private void evalCurve(final byte[] s) { + int sposn = 31; + int sbit = 6; + int svalue = s[sposn] | 0x40; + int swap = 0; + + // Iterate over all 255 bits of "s" from the highest to the lowest. + // We ignore the high bit of the 256-bit representation of "s". + while (true) { + // Conditional swaps on entry to this bit but only if we + // didn't swap on the previous bit. + final int select = (svalue >> sbit) & 0x01; + swap ^= select; + cswap(swap, x_2, x_3); + cswap(swap, z_2, z_3); + swap = select; + + // Evaluate the curve. + add(A, x_2, z_2); // A = x_2 + z_2 + square(AA, A); // AA = A^2 + sub(B, x_2, z_2); // B = x_2 - z_2 + square(BB, B); // BB = B^2 + sub(E, AA, BB); // E = AA - BB + add(C, x_3, z_3); // C = x_3 + z_3 + sub(D, x_3, z_3); // D = x_3 - z_3 + mul(DA, D, A); // DA = D * A + mul(CB, C, B); // CB = C * B + add(x_3, DA, CB); // x_3 = (DA + CB)^2 + square(x_3, x_3); + sub(z_3, DA, CB); // z_3 = x_1 * (DA - CB)^2 + square(z_3, z_3); + mul(z_3, z_3, x_1); + mul(x_2, AA, BB); // x_2 = AA * BB + mulA24(z_2, E); // z_2 = E * (AA + a24 * E) + add(z_2, z_2, AA); + mul(z_2, z_2, E); + + // Move onto the next lower bit of "s". + if (sbit > 0) { + --sbit; + } else if (sposn == 0) { + break; + } else if (sposn == 1) { + --sposn; + svalue = s[sposn] & 0xF8; + sbit = 7; + } else { + --sposn; + svalue = s[sposn]; + sbit = 7; + } + } + + // Final conditional swaps. + cswap(swap, x_2, x_3); + cswap(swap, z_2, z_3); + } + + /** + * Multiplies two numbers modulo 2^255 - 19. + * + * @param result The result. + * @param x The first number to multiply. + * @param y The second number to multiply. + */ + private void mul(final int[] result, final int[] x, final int[] y) { + // Multiply the two numbers to create the intermediate result. + long v = x[0]; + for (int i = 0; i < NUM_LIMBS_255BIT; ++i) { + t1[i] = v * y[i]; + } + for (int i = 1; i < NUM_LIMBS_255BIT; ++i) { + v = x[i]; + for (int j = 0; j < (NUM_LIMBS_255BIT - 1); ++j) { + t1[i + j] += v * y[j]; + } + t1[i + NUM_LIMBS_255BIT - 1] = v * y[NUM_LIMBS_255BIT - 1]; + } + + // Propagate carries and convert back into 26-bit words. + v = t1[0]; + t2[0] = ((int) v) & 0x03FFFFFF; + for (int i = 1; i < NUM_LIMBS_510BIT; ++i) { + v = (v >> 26) + t1[i]; + t2[i] = ((int) v) & 0x03FFFFFF; + } + + // Reduce the result modulo 2^255 - 19. + reduce(result, t2, NUM_LIMBS_255BIT); + } + + /** + * Multiplies a number by the a24 constant, modulo 2^255 - 19. + * + * @param result The result. + * @param x The number to multiply by a24. + */ + private void mulA24(final int[] result, final int[] x) { + final long a24 = 121665; + long carry = 0; + for (int index = 0; index < NUM_LIMBS_255BIT; ++index) { + carry += a24 * x[index]; + t2[index] = ((int) carry) & 0x03FFFFFF; + carry >>= 26; + } + t2[NUM_LIMBS_255BIT] = ((int) carry) & 0x03FFFFFF; + reduce(result, t2, 1); + } + + /** + * Raise x to the power of (2^250 - 1). + * + * @param result The result. Must not overlap with x. + * @param x The argument. + */ + private void pow250(final int[] result, final int[] x) { + // The big-endian hexadecimal expansion of (2^250 - 1) is: + // 03FFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF + // + // The naive implementation needs to do 2 multiplications per 1 bit and + // 1 multiplication per 0 bit. We can improve upon this by creating a + // pattern 0000000001 ... 0000000001. If we square and multiply the + // pattern by itself we can turn the pattern into the partial results + // 0000000011 ... 0000000011, 0000000111 ... 0000000111, etc. + // This averages out to about 1.1 multiplications per 1 bit instead of 2. + + // Build a pattern of 250 bits in length of repeated copies of 0000000001. + square(A, x); + for (int j = 0; j < 9; ++j) + square(A, A); + mul(result, A, x); + for (int i = 0; i < 23; ++i) { + for (int j = 0; j < 10; ++j) + square(A, A); + mul(result, result, A); + } + + // Multiply bit-shifted versions of the 0000000001 pattern into + // the result to "fill in" the gaps in the pattern. + square(A, result); + mul(result, result, A); + for (int j = 0; j < 8; ++j) { + square(A, A); + mul(result, result, A); + } + } + + /** + * Computes the reciprocal of a number modulo 2^255 - 19. + * + * @param result The result. Must not overlap with x. + * @param x The argument. + */ + private void recip(final int[] result, final int[] x) { + // The reciprocal is the same as x ^ (p - 2) where p = 2^255 - 19. + // The big-endian hexadecimal expansion of (p - 2) is: + // 7FFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFEB + // Start with the 250 upper bits of the expansion of (p - 2). + pow250(result, x); + + // Deal with the 5 lowest bits of (p - 2), 01011, from highest to lowest. + square(result, result); + square(result, result); + mul(result, result, x); + square(result, result); + square(result, result); + mul(result, result, x); + square(result, result); + mul(result, result, x); + } + + /** + * Reduce a number modulo 2^255 - 19. + * + * @param result The result. + * @param x The value to be reduced. This array will be + * modified during the reduction. + * @param size The number of limbs in the high order half of x. + */ + private void reduce(final int[] result, final int[] x, final int size) { + // Calculate (x mod 2^255) + ((x / 2^255) * 19) which will + // either produce the answer we want or it will produce a + // value of the form "answer + j * (2^255 - 19)". There are + // 5 left-over bits in the top-most limb of the bottom half. + int carry = 0; + int limb = x[NUM_LIMBS_255BIT - 1] >> 21; + x[NUM_LIMBS_255BIT - 1] &= 0x001FFFFF; + for (int index = 0; index < size; ++index) { + limb += x[NUM_LIMBS_255BIT + index] << 5; + carry += (limb & 0x03FFFFFF) * 19 + x[index]; + x[index] = carry & 0x03FFFFFF; + limb >>= 26; + carry >>= 26; + } + if (size < NUM_LIMBS_255BIT) { + // The high order half of the number is short; e.g. for mulA24(). + // Propagate the carry through the rest of the low order part. + for (int index = size; index < NUM_LIMBS_255BIT; ++index) { + carry += x[index]; + x[index] = carry & 0x03FFFFFF; + carry >>= 26; + } + } + + // The "j" value may still be too large due to the final carry-out. + // We must repeat the reduction. If we already have the answer, + // then this won't do any harm but we must still do the calculation + // to preserve the overall timing. The "j" value will be between + // 0 and 19, which means that the carry we care about is in the + // top 5 bits of the highest limb of the bottom half. + carry = (x[NUM_LIMBS_255BIT - 1] >> 21) * 19; + x[NUM_LIMBS_255BIT - 1] &= 0x001FFFFF; + for (int index = 0; index < NUM_LIMBS_255BIT; ++index) { + carry += x[index]; + result[index] = carry & 0x03FFFFFF; + carry >>= 26; + } + + // At this point "x" will either be the answer or it will be the + // answer plus (2^255 - 19). Perform a trial subtraction to + // complete the reduction process. + reduceQuick(result); + } + + /** + * Reduces a number modulo 2^255 - 19 where it is known that the + * number can be reduced with only 1 trial subtraction. + * + * @param x The number to reduce, and the result. + */ + private void reduceQuick(final int[] x) { + // Perform a trial subtraction of (2^255 - 19) from "x" which is + // equivalent to adding 19 and subtracting 2^255. We add 19 here; + // the subtraction of 2^255 occurs in the next step. + int carry = 19; + for (int index = 0; index < NUM_LIMBS_255BIT; ++index) { + carry += x[index]; + t2[index] = carry & 0x03FFFFFF; + carry >>= 26; + } + + // If there was a borrow, then the original "x" is the correct answer. + // If there was no borrow, then "t2" is the correct answer. Select the + // correct answer but do it in a way that instruction timing will not + // reveal which value was selected. Borrow will occur if bit 21 of + // "t2" is zero. Turn the bit into a selection mask. + final int mask = -((t2[NUM_LIMBS_255BIT - 1] >> 21) & 0x01); + final int nmask = ~mask; + t2[NUM_LIMBS_255BIT - 1] &= 0x001FFFFF; + for (int index = 0; index < NUM_LIMBS_255BIT; ++index) + x[index] = (x[index] & nmask) | (t2[index] & mask); + } + + /** + * Squares a number modulo 2^255 - 19. + * + * @param result The result. + * @param x The number to square. + */ + private void square(final int[] result, final int[] x) { + mul(result, x, x); + } +} diff --git a/app/src/main/java/com/wireguard/crypto/Ed25519.java b/app/src/main/java/com/wireguard/crypto/Ed25519.java new file mode 100644 index 0000000..a60babf --- /dev/null +++ b/app/src/main/java/com/wireguard/crypto/Ed25519.java @@ -0,0 +1,2508 @@ +/* + * Copyright © 2020 WireGuard LLC. All Rights Reserved. + * Copyright 2017 Google Inc. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.crypto; + +import java.math.BigInteger; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.util.Arrays; + +/** + * Implementation of Ed25519 signature verification. + * + *

This implementation is based on the ed25519/ref10 implementation in NaCl.

+ * + *

It implements this twisted Edwards curve: + * + *

+ * -x^2 + y^2 = 1 + (-121665 / 121666 mod 2^255-19)*x^2*y^2
+ * 
+ * + * @see Bernstein D.J., Birkner P., Joye M., Lange + * T., Peters C. (2008) Twisted Edwards Curves + * @see Hisil H., Wong K.KH., Carter G., Dawson E. + * (2008) Twisted Edwards Curves Revisited + */ +public final class Ed25519 { + + // d = -121665 / 121666 mod 2^255-19 + private static final long[] D; + // 2d + private static final long[] D2; + // 2^((p-1)/4) mod p where p = 2^255-19 + private static final long[] SQRTM1; + + /** + * Base point for the Edwards twisted curve = (x, 4/5) and its exponentiations. B_TABLE[i][j] = + * (j+1)*256^i*B for i in [0, 32) and j in [0, 8). Base point B = B_TABLE[0][0] + */ + private static final CachedXYT[][] B_TABLE; + private static final CachedXYT[] B2; + + private static final BigInteger P_BI = + BigInteger.valueOf(2).pow(255).subtract(BigInteger.valueOf(19)); + private static final BigInteger D_BI = + BigInteger.valueOf(-121665).multiply(BigInteger.valueOf(121666).modInverse(P_BI)).mod(P_BI); + private static final BigInteger D2_BI = BigInteger.valueOf(2).multiply(D_BI).mod(P_BI); + private static final BigInteger SQRTM1_BI = + BigInteger.valueOf(2).modPow(P_BI.subtract(BigInteger.ONE).divide(BigInteger.valueOf(4)), P_BI); + + private Ed25519() { + } + + private static class Point { + private BigInteger x; + private BigInteger y; + } + + private static BigInteger recoverX(BigInteger y) { + // x^2 = (y^2 - 1) / (d * y^2 + 1) mod 2^255-19 + BigInteger xx = + y.pow(2) + .subtract(BigInteger.ONE) + .multiply(D_BI.multiply(y.pow(2)).add(BigInteger.ONE).modInverse(P_BI)); + BigInteger x = xx.modPow(P_BI.add(BigInteger.valueOf(3)).divide(BigInteger.valueOf(8)), P_BI); + if (!x.pow(2).subtract(xx).mod(P_BI).equals(BigInteger.ZERO)) { + x = x.multiply(SQRTM1_BI).mod(P_BI); + } + if (x.testBit(0)) { + x = P_BI.subtract(x); + } + return x; + } + + private static Point edwards(Point a, Point b) { + Point o = new Point(); + BigInteger xxyy = D_BI.multiply(a.x.multiply(b.x).multiply(a.y).multiply(b.y)).mod(P_BI); + o.x = + (a.x.multiply(b.y).add(b.x.multiply(a.y))) + .multiply(BigInteger.ONE.add(xxyy).modInverse(P_BI)) + .mod(P_BI); + o.y = + (a.y.multiply(b.y).add(a.x.multiply(b.x))) + .multiply(BigInteger.ONE.subtract(xxyy).modInverse(P_BI)) + .mod(P_BI); + return o; + } + + private static byte[] toLittleEndian(BigInteger n) { + byte[] b = new byte[32]; + byte[] nBytes = n.toByteArray(); + System.arraycopy(nBytes, 0, b, 32 - nBytes.length, nBytes.length); + for (int i = 0; i < b.length / 2; i++) { + byte t = b[i]; + b[i] = b[b.length - i - 1]; + b[b.length - i - 1] = t; + } + return b; + } + + private static CachedXYT getCachedXYT(Point p) { + return new CachedXYT( + Field25519.expand(toLittleEndian(p.y.add(p.x).mod(P_BI))), + Field25519.expand(toLittleEndian(p.y.subtract(p.x).mod(P_BI))), + Field25519.expand(toLittleEndian(D2_BI.multiply(p.x).multiply(p.y).mod(P_BI)))); + } + + static { + Point b = new Point(); + b.y = BigInteger.valueOf(4).multiply(BigInteger.valueOf(5).modInverse(P_BI)).mod(P_BI); + b.x = recoverX(b.y); + + D = Field25519.expand(toLittleEndian(D_BI)); + D2 = Field25519.expand(toLittleEndian(D2_BI)); + SQRTM1 = Field25519.expand(toLittleEndian(SQRTM1_BI)); + + Point bi = b; + B_TABLE = new CachedXYT[32][8]; + for (int i = 0; i < 32; i++) { + Point bij = bi; + for (int j = 0; j < 8; j++) { + B_TABLE[i][j] = getCachedXYT(bij); + bij = edwards(bij, bi); + } + for (int j = 0; j < 8; j++) { + bi = edwards(bi, bi); + } + } + bi = b; + Point b2 = edwards(b, b); + B2 = new CachedXYT[8]; + for (int i = 0; i < 8; i++) { + B2[i] = getCachedXYT(bi); + bi = edwards(bi, b2); + } + } + + private static final int PUBLIC_KEY_LEN = Field25519.FIELD_LEN; + private static final int SIGNATURE_LEN = Field25519.FIELD_LEN * 2; + + /** + * Defines field 25519 function based on curve25519-donna C + * implementation (mostly identical). + * + *

Field elements are written as an array of signed, 64-bit limbs (an array of longs), least + * significant first. The value of the field element is: + * + *

+     * x[0] + 2^26·x[1] + 2^51·x[2] + 2^77·x[3] + 2^102·x[4] + 2^128·x[5] + 2^153·x[6] + 2^179·x[7] +
+     * 2^204·x[8] + 2^230·x[9],
+     * 
+ * + *

i.e. the limbs are 26, 25, 26, 25, ... bits wide. + */ + private static final class Field25519 { + /** + * During Field25519 computation, the mixed radix representation may be in different forms: + *

    + *
  • Reduced-size form: the array has size at most 10. + *
  • Non-reduced-size form: the array is not reduced modulo 2^255 - 19 and has size at most + * 19. + *
+ *

+ * TODO(quannguyen): + *

    + *
  • Clarify ill-defined terminologies. + *
  • The reduction procedure is different from DJB's paper + * (http://cr.yp.to/ecdh/curve25519-20060209.pdf). The coefficients after reducing degree and + * reducing coefficients aren't guaranteed to be in range {-2^25, ..., 2^25}. We should check to + * see what's going on. + *
  • Consider using method mult() everywhere and making product() private. + *
+ */ + + static final int FIELD_LEN = 32; + static final int LIMB_CNT = 10; + private static final long TWO_TO_25 = 1 << 25; + private static final long TWO_TO_26 = TWO_TO_25 << 1; + + private static final int[] EXPAND_START = {0, 3, 6, 9, 12, 16, 19, 22, 25, 28}; + private static final int[] EXPAND_SHIFT = {0, 2, 3, 5, 6, 0, 1, 3, 4, 6}; + private static final int[] MASK = {0x3ffffff, 0x1ffffff}; + private static final int[] SHIFT = {26, 25}; + + /** + * Sums two numbers: output = in1 + in2 + *

+ * On entry: in1, in2 are in reduced-size form. + */ + static void sum(long[] output, long[] in1, long[] in2) { + for (int i = 0; i < LIMB_CNT; i++) { + output[i] = in1[i] + in2[i]; + } + } + + /** + * Sums two numbers: output += in + *

+ * On entry: in is in reduced-size form. + */ + static void sum(long[] output, long[] in) { + sum(output, output, in); + } + + /** + * Find the difference of two numbers: output = in1 - in2 + * (note the order of the arguments!). + *

+ * On entry: in1, in2 are in reduced-size form. + */ + static void sub(long[] output, long[] in1, long[] in2) { + for (int i = 0; i < LIMB_CNT; i++) { + output[i] = in1[i] - in2[i]; + } + } + + /** + * Find the difference of two numbers: output = in - output + * (note the order of the arguments!). + *

+ * On entry: in, output are in reduced-size form. + */ + static void sub(long[] output, long[] in) { + sub(output, in, output); + } + + /** + * Multiply a number by a scalar: output = in * scalar + */ + static void scalarProduct(long[] output, long[] in, long scalar) { + for (int i = 0; i < LIMB_CNT; i++) { + output[i] = in[i] * scalar; + } + } + + /** + * Multiply two numbers: out = in2 * in + *

+ * output must be distinct to both inputs. The inputs are reduced coefficient form, + * the output is not. + *

+ * out[x] <= 14 * the largest product of the input limbs. + */ + static void product(long[] out, long[] in2, long[] in) { + out[0] = in2[0] * in[0]; + out[1] = in2[0] * in[1] + + in2[1] * in[0]; + out[2] = 2 * in2[1] * in[1] + + in2[0] * in[2] + + in2[2] * in[0]; + out[3] = in2[1] * in[2] + + in2[2] * in[1] + + in2[0] * in[3] + + in2[3] * in[0]; + out[4] = in2[2] * in[2] + + 2 * (in2[1] * in[3] + in2[3] * in[1]) + + in2[0] * in[4] + + in2[4] * in[0]; + out[5] = in2[2] * in[3] + + in2[3] * in[2] + + in2[1] * in[4] + + in2[4] * in[1] + + in2[0] * in[5] + + in2[5] * in[0]; + out[6] = 2 * (in2[3] * in[3] + in2[1] * in[5] + in2[5] * in[1]) + + in2[2] * in[4] + + in2[4] * in[2] + + in2[0] * in[6] + + in2[6] * in[0]; + out[7] = in2[3] * in[4] + + in2[4] * in[3] + + in2[2] * in[5] + + in2[5] * in[2] + + in2[1] * in[6] + + in2[6] * in[1] + + in2[0] * in[7] + + in2[7] * in[0]; + out[8] = in2[4] * in[4] + + 2 * (in2[3] * in[5] + in2[5] * in[3] + in2[1] * in[7] + in2[7] * in[1]) + + in2[2] * in[6] + + in2[6] * in[2] + + in2[0] * in[8] + + in2[8] * in[0]; + out[9] = in2[4] * in[5] + + in2[5] * in[4] + + in2[3] * in[6] + + in2[6] * in[3] + + in2[2] * in[7] + + in2[7] * in[2] + + in2[1] * in[8] + + in2[8] * in[1] + + in2[0] * in[9] + + in2[9] * in[0]; + out[10] = + 2 * (in2[5] * in[5] + in2[3] * in[7] + in2[7] * in[3] + in2[1] * in[9] + in2[9] * in[1]) + + in2[4] * in[6] + + in2[6] * in[4] + + in2[2] * in[8] + + in2[8] * in[2]; + out[11] = in2[5] * in[6] + + in2[6] * in[5] + + in2[4] * in[7] + + in2[7] * in[4] + + in2[3] * in[8] + + in2[8] * in[3] + + in2[2] * in[9] + + in2[9] * in[2]; + out[12] = in2[6] * in[6] + + 2 * (in2[5] * in[7] + in2[7] * in[5] + in2[3] * in[9] + in2[9] * in[3]) + + in2[4] * in[8] + + in2[8] * in[4]; + out[13] = in2[6] * in[7] + + in2[7] * in[6] + + in2[5] * in[8] + + in2[8] * in[5] + + in2[4] * in[9] + + in2[9] * in[4]; + out[14] = 2 * (in2[7] * in[7] + in2[5] * in[9] + in2[9] * in[5]) + + in2[6] * in[8] + + in2[8] * in[6]; + out[15] = in2[7] * in[8] + + in2[8] * in[7] + + in2[6] * in[9] + + in2[9] * in[6]; + out[16] = in2[8] * in[8] + + 2 * (in2[7] * in[9] + in2[9] * in[7]); + out[17] = in2[8] * in[9] + + in2[9] * in[8]; + out[18] = 2 * in2[9] * in[9]; + } + + /** + * Reduce a field element by calling reduceSizeByModularReduction and reduceCoefficients. + * + * @param input An input array of any length. If the array has 19 elements, it will be used as + * temporary buffer and its contents changed. + * @param output An output array of size LIMB_CNT. After the call |output[i]| < 2^26 will hold. + */ + static void reduce(long[] input, long[] output) { + long[] tmp; + if (input.length == 19) { + tmp = input; + } else { + tmp = new long[19]; + System.arraycopy(input, 0, tmp, 0, input.length); + } + reduceSizeByModularReduction(tmp); + reduceCoefficients(tmp); + System.arraycopy(tmp, 0, output, 0, LIMB_CNT); + } + + /** + * Reduce a long form to a reduced-size form by taking the input mod 2^255 - 19. + *

+ * On entry: |output[i]| < 14*2^54 + * On exit: |output[0..8]| < 280*2^54 + */ + static void reduceSizeByModularReduction(long[] output) { + // The coefficients x[10], x[11],..., x[18] are eliminated by reduction modulo 2^255 - 19. + // For example, the coefficient x[18] is multiplied by 19 and added to the coefficient x[8]. + // + // Each of these shifts and adds ends up multiplying the value by 19. + // + // For output[0..8], the absolute entry value is < 14*2^54 and we add, at most, 19*14*2^54 thus, + // on exit, |output[0..8]| < 280*2^54. + output[8] += output[18] << 4; + output[8] += output[18] << 1; + output[8] += output[18]; + output[7] += output[17] << 4; + output[7] += output[17] << 1; + output[7] += output[17]; + output[6] += output[16] << 4; + output[6] += output[16] << 1; + output[6] += output[16]; + output[5] += output[15] << 4; + output[5] += output[15] << 1; + output[5] += output[15]; + output[4] += output[14] << 4; + output[4] += output[14] << 1; + output[4] += output[14]; + output[3] += output[13] << 4; + output[3] += output[13] << 1; + output[3] += output[13]; + output[2] += output[12] << 4; + output[2] += output[12] << 1; + output[2] += output[12]; + output[1] += output[11] << 4; + output[1] += output[11] << 1; + output[1] += output[11]; + output[0] += output[10] << 4; + output[0] += output[10] << 1; + output[0] += output[10]; + } + + /** + * Reduce all coefficients of the short form input so that |x| < 2^26. + *

+ * On entry: |output[i]| < 280*2^54 + */ + static void reduceCoefficients(long[] output) { + output[10] = 0; + + for (int i = 0; i < LIMB_CNT; i += 2) { + long over = output[i] / TWO_TO_26; + // The entry condition (that |output[i]| < 280*2^54) means that over is, at most, 280*2^28 in + // the first iteration of this loop. This is added to the next limb and we can approximate the + // resulting bound of that limb by 281*2^54. + output[i] -= over << 26; + output[i + 1] += over; + + // For the first iteration, |output[i+1]| < 281*2^54, thus |over| < 281*2^29. When this is + // added to the next limb, the resulting bound can be approximated as 281*2^54. + // + // For subsequent iterations of the loop, 281*2^54 remains a conservative bound and no + // overflow occurs. + over = output[i + 1] / TWO_TO_25; + output[i + 1] -= over << 25; + output[i + 2] += over; + } + // Now |output[10]| < 281*2^29 and all other coefficients are reduced. + output[0] += output[10] << 4; + output[0] += output[10] << 1; + output[0] += output[10]; + + output[10] = 0; + // Now output[1..9] are reduced, and |output[0]| < 2^26 + 19*281*2^29 so |over| will be no more + // than 2^16. + long over = output[0] / TWO_TO_26; + output[0] -= over << 26; + output[1] += over; + // Now output[0,2..9] are reduced, and |output[1]| < 2^25 + 2^16 < 2^26. The bound on + // |output[1]| is sufficient to meet our needs. + } + + /** + * A helpful wrapper around {@ref Field25519#product}: output = in * in2. + *

+ * On entry: |in[i]| < 2^27 and |in2[i]| < 2^27. + *

+ * The output is reduced degree (indeed, one need only provide storage for 10 limbs) and + * |output[i]| < 2^26. + */ + static void mult(long[] output, long[] in, long[] in2) { + long[] t = new long[19]; + product(t, in, in2); + // |t[i]| < 2^26 + reduce(t, output); + } + + /** + * Square a number: out = in**2 + *

+ * output must be distinct from the input. The inputs are reduced coefficient form, the output is + * not. + *

+ * out[x] <= 14 * the largest product of the input limbs. + */ + private static void squareInner(long[] out, long[] in) { + out[0] = in[0] * in[0]; + out[1] = 2 * in[0] * in[1]; + out[2] = 2 * (in[1] * in[1] + in[0] * in[2]); + out[3] = 2 * (in[1] * in[2] + in[0] * in[3]); + out[4] = in[2] * in[2] + + 4 * in[1] * in[3] + + 2 * in[0] * in[4]; + out[5] = 2 * (in[2] * in[3] + in[1] * in[4] + in[0] * in[5]); + out[6] = 2 * (in[3] * in[3] + in[2] * in[4] + in[0] * in[6] + 2 * in[1] * in[5]); + out[7] = 2 * (in[3] * in[4] + in[2] * in[5] + in[1] * in[6] + in[0] * in[7]); + out[8] = in[4] * in[4] + + 2 * (in[2] * in[6] + in[0] * in[8] + 2 * (in[1] * in[7] + in[3] * in[5])); + out[9] = 2 * (in[4] * in[5] + in[3] * in[6] + in[2] * in[7] + in[1] * in[8] + in[0] * in[9]); + out[10] = 2 * (in[5] * in[5] + + in[4] * in[6] + + in[2] * in[8] + + 2 * (in[3] * in[7] + in[1] * in[9])); + out[11] = 2 * (in[5] * in[6] + in[4] * in[7] + in[3] * in[8] + in[2] * in[9]); + out[12] = in[6] * in[6] + + 2 * (in[4] * in[8] + 2 * (in[5] * in[7] + in[3] * in[9])); + out[13] = 2 * (in[6] * in[7] + in[5] * in[8] + in[4] * in[9]); + out[14] = 2 * (in[7] * in[7] + in[6] * in[8] + 2 * in[5] * in[9]); + out[15] = 2 * (in[7] * in[8] + in[6] * in[9]); + out[16] = in[8] * in[8] + 4 * in[7] * in[9]; + out[17] = 2 * in[8] * in[9]; + out[18] = 2 * in[9] * in[9]; + } + + /** + * Returns in^2. + *

+ * On entry: The |in| argument is in reduced coefficients form and |in[i]| < 2^27. + *

+ * On exit: The |output| argument is in reduced coefficients form (indeed, one need only provide + * storage for 10 limbs) and |out[i]| < 2^26. + */ + static void square(long[] output, long[] in) { + long[] t = new long[19]; + squareInner(t, in); + // |t[i]| < 14*2^54 because the largest product of two limbs will be < 2^(27+27) and SquareInner + // adds together, at most, 14 of those products. + reduce(t, output); + } + + /** + * Takes a little-endian, 32-byte number and expands it into mixed radix form. + */ + static long[] expand(byte[] input) { + long[] output = new long[LIMB_CNT]; + for (int i = 0; i < LIMB_CNT; i++) { + output[i] = ((((long) (input[EXPAND_START[i]] & 0xff)) + | ((long) (input[EXPAND_START[i] + 1] & 0xff)) << 8 + | ((long) (input[EXPAND_START[i] + 2] & 0xff)) << 16 + | ((long) (input[EXPAND_START[i] + 3] & 0xff)) << 24) >> EXPAND_SHIFT[i]) & MASK[i & 1]; + } + return output; + } + + /** + * Takes a fully reduced mixed radix form number and contract it into a little-endian, 32-byte + * array. + *

+ * On entry: |input_limbs[i]| < 2^26 + */ + @SuppressWarnings("NarrowingCompoundAssignment") + static byte[] contract(long[] inputLimbs) { + long[] input = Arrays.copyOf(inputLimbs, LIMB_CNT); + for (int j = 0; j < 2; j++) { + for (int i = 0; i < 9; i++) { + // This calculation is a time-invariant way to make input[i] non-negative by borrowing + // from the next-larger limb. + int carry = -(int) ((input[i] & (input[i] >> 31)) >> SHIFT[i & 1]); + input[i] = input[i] + (carry << SHIFT[i & 1]); + input[i + 1] -= carry; + } + + // There's no greater limb for input[9] to borrow from, but we can multiply by 19 and borrow + // from input[0], which is valid mod 2^255-19. + { + int carry = -(int) ((input[9] & (input[9] >> 31)) >> 25); + input[9] += (carry << 25); + input[0] -= (carry * 19); + } + + // After the first iteration, input[1..9] are non-negative and fit within 25 or 26 bits, + // depending on position. However, input[0] may be negative. + } + + // The first borrow-propagation pass above ended with every limb except (possibly) input[0] + // non-negative. + // + // If input[0] was negative after the first pass, then it was because of a carry from input[9]. + // On entry, input[9] < 2^26 so the carry was, at most, one, since (2**26-1) >> 25 = 1. Thus + // input[0] >= -19. + // + // In the second pass, each limb is decreased by at most one. Thus the second borrow-propagation + // pass could only have wrapped around to decrease input[0] again if the first pass left + // input[0] negative *and* input[1] through input[9] were all zero. In that case, input[1] is + // now 2^25 - 1, and this last borrow-propagation step will leave input[1] non-negative. + { + int carry = -(int) ((input[0] & (input[0] >> 31)) >> 26); + input[0] += (carry << 26); + input[1] -= carry; + } + + // All input[i] are now non-negative. However, there might be values between 2^25 and 2^26 in a + // limb which is, nominally, 25 bits wide. + for (int j = 0; j < 2; j++) { + for (int i = 0; i < 9; i++) { + int carry = (int) (input[i] >> SHIFT[i & 1]); + input[i] &= MASK[i & 1]; + input[i + 1] += carry; + } + } + + { + int carry = (int) (input[9] >> 25); + input[9] &= 0x1ffffff; + input[0] += 19 * carry; + } + + // If the first carry-chain pass, just above, ended up with a carry from input[9], and that + // caused input[0] to be out-of-bounds, then input[0] was < 2^26 + 2*19, because the carry was, + // at most, two. + // + // If the second pass carried from input[9] again then input[0] is < 2*19 and the input[9] -> + // input[0] carry didn't push input[0] out of bounds. + + // It still remains the case that input might be between 2^255-19 and 2^255. In this case, + // input[1..9] must take their maximum value and input[0] must be >= (2^255-19) & 0x3ffffff, + // which is 0x3ffffed. + int mask = gte((int) input[0], 0x3ffffed); + for (int i = 1; i < LIMB_CNT; i++) { + mask &= eq((int) input[i], MASK[i & 1]); + } + + // mask is either 0xffffffff (if input >= 2^255-19) and zero otherwise. Thus this conditionally + // subtracts 2^255-19. + input[0] -= mask & 0x3ffffed; + input[1] -= mask & 0x1ffffff; + for (int i = 2; i < LIMB_CNT; i += 2) { + input[i] -= mask & 0x3ffffff; + input[i + 1] -= mask & 0x1ffffff; + } + + for (int i = 0; i < LIMB_CNT; i++) { + input[i] <<= EXPAND_SHIFT[i]; + } + byte[] output = new byte[FIELD_LEN]; + for (int i = 0; i < LIMB_CNT; i++) { + output[EXPAND_START[i]] |= input[i] & 0xff; + output[EXPAND_START[i] + 1] |= (input[i] >> 8) & 0xff; + output[EXPAND_START[i] + 2] |= (input[i] >> 16) & 0xff; + output[EXPAND_START[i] + 3] |= (input[i] >> 24) & 0xff; + } + return output; + } + + /** + * Computes inverse of z = z(2^255 - 21) + *

+ * Shamelessly copied from agl's code which was shamelessly copied from djb's code. Only the + * comment format and the variable namings are different from those. + */ + static void inverse(long[] out, long[] z) { + long[] z2 = new long[Field25519.LIMB_CNT]; + long[] z9 = new long[Field25519.LIMB_CNT]; + long[] z11 = new long[Field25519.LIMB_CNT]; + long[] z2To5Minus1 = new long[Field25519.LIMB_CNT]; + long[] z2To10Minus1 = new long[Field25519.LIMB_CNT]; + long[] z2To20Minus1 = new long[Field25519.LIMB_CNT]; + long[] z2To50Minus1 = new long[Field25519.LIMB_CNT]; + long[] z2To100Minus1 = new long[Field25519.LIMB_CNT]; + long[] t0 = new long[Field25519.LIMB_CNT]; + long[] t1 = new long[Field25519.LIMB_CNT]; + + square(z2, z); // 2 + square(t1, z2); // 4 + square(t0, t1); // 8 + mult(z9, t0, z); // 9 + mult(z11, z9, z2); // 11 + square(t0, z11); // 22 + mult(z2To5Minus1, t0, z9); // 2^5 - 2^0 = 31 + + square(t0, z2To5Minus1); // 2^6 - 2^1 + square(t1, t0); // 2^7 - 2^2 + square(t0, t1); // 2^8 - 2^3 + square(t1, t0); // 2^9 - 2^4 + square(t0, t1); // 2^10 - 2^5 + mult(z2To10Minus1, t0, z2To5Minus1); // 2^10 - 2^0 + + square(t0, z2To10Minus1); // 2^11 - 2^1 + square(t1, t0); // 2^12 - 2^2 + for (int i = 2; i < 10; i += 2) { // 2^20 - 2^10 + square(t0, t1); + square(t1, t0); + } + mult(z2To20Minus1, t1, z2To10Minus1); // 2^20 - 2^0 + + square(t0, z2To20Minus1); // 2^21 - 2^1 + square(t1, t0); // 2^22 - 2^2 + for (int i = 2; i < 20; i += 2) { // 2^40 - 2^20 + square(t0, t1); + square(t1, t0); + } + mult(t0, t1, z2To20Minus1); // 2^40 - 2^0 + + square(t1, t0); // 2^41 - 2^1 + square(t0, t1); // 2^42 - 2^2 + for (int i = 2; i < 10; i += 2) { // 2^50 - 2^10 + square(t1, t0); + square(t0, t1); + } + mult(z2To50Minus1, t0, z2To10Minus1); // 2^50 - 2^0 + + square(t0, z2To50Minus1); // 2^51 - 2^1 + square(t1, t0); // 2^52 - 2^2 + for (int i = 2; i < 50; i += 2) { // 2^100 - 2^50 + square(t0, t1); + square(t1, t0); + } + mult(z2To100Minus1, t1, z2To50Minus1); // 2^100 - 2^0 + + square(t1, z2To100Minus1); // 2^101 - 2^1 + square(t0, t1); // 2^102 - 2^2 + for (int i = 2; i < 100; i += 2) { // 2^200 - 2^100 + square(t1, t0); + square(t0, t1); + } + mult(t1, t0, z2To100Minus1); // 2^200 - 2^0 + + square(t0, t1); // 2^201 - 2^1 + square(t1, t0); // 2^202 - 2^2 + for (int i = 2; i < 50; i += 2) { // 2^250 - 2^50 + square(t0, t1); + square(t1, t0); + } + mult(t0, t1, z2To50Minus1); // 2^250 - 2^0 + + square(t1, t0); // 2^251 - 2^1 + square(t0, t1); // 2^252 - 2^2 + square(t1, t0); // 2^253 - 2^3 + square(t0, t1); // 2^254 - 2^4 + square(t1, t0); // 2^255 - 2^5 + mult(out, t1, z11); // 2^255 - 21 + } + + + /** + * Returns 0xffffffff iff a == b and zero otherwise. + */ + private static int eq(int a, int b) { + a = ~(a ^ b); + a &= a << 16; + a &= a << 8; + a &= a << 4; + a &= a << 2; + a &= a << 1; + return a >> 31; + } + + /** + * returns 0xffffffff if a >= b and zero otherwise, where a and b are both non-negative. + */ + private static int gte(int a, int b) { + a -= b; + // a >= 0 iff a >= b. + return ~(a >> 31); + } + } + + // (x = 0, y = 1) point + private static final CachedXYT CACHED_NEUTRAL = new CachedXYT( + new long[]{1, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + new long[]{1, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + new long[]{0, 0, 0, 0, 0, 0, 0, 0, 0, 0}); + private static final PartialXYZT NEUTRAL = new PartialXYZT( + new XYZ(new long[]{0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + new long[]{1, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + new long[]{1, 0, 0, 0, 0, 0, 0, 0, 0, 0}), + new long[]{1, 0, 0, 0, 0, 0, 0, 0, 0, 0}); + + /** + * Projective point representation (X:Y:Z) satisfying x = X/Z, y = Y/Z + *

+ * Note that this is referred as ge_p2 in ref10 impl. + * Also note that x = X, y = Y and z = Z below following Java coding style. + *

+ * See + * Koyama K., Tsuruoka Y. (1993) Speeding up Elliptic Cryptosystems by Using a Signed Binary + * Window Method. + *

+ * https://hyperelliptic.org/EFD/g1p/auto-twisted-projective.html + */ + private static class XYZ { + + final long[] x; + final long[] y; + final long[] z; + + XYZ() { + this(new long[Field25519.LIMB_CNT], new long[Field25519.LIMB_CNT], new long[Field25519.LIMB_CNT]); + } + + XYZ(long[] x, long[] y, long[] z) { + this.x = x; + this.y = y; + this.z = z; + } + + XYZ(XYZ xyz) { + x = Arrays.copyOf(xyz.x, Field25519.LIMB_CNT); + y = Arrays.copyOf(xyz.y, Field25519.LIMB_CNT); + z = Arrays.copyOf(xyz.z, Field25519.LIMB_CNT); + } + + XYZ(PartialXYZT partialXYZT) { + this(); + fromPartialXYZT(this, partialXYZT); + } + + /** + * ge_p1p1_to_p2.c + */ + static XYZ fromPartialXYZT(XYZ out, PartialXYZT in) { + Field25519.mult(out.x, in.xyz.x, in.t); + Field25519.mult(out.y, in.xyz.y, in.xyz.z); + Field25519.mult(out.z, in.xyz.z, in.t); + return out; + } + + /** + * Encodes this point to bytes. + */ + byte[] toBytes() { + long[] recip = new long[Field25519.LIMB_CNT]; + long[] x = new long[Field25519.LIMB_CNT]; + long[] y = new long[Field25519.LIMB_CNT]; + Field25519.inverse(recip, z); + Field25519.mult(x, this.x, recip); + Field25519.mult(y, this.y, recip); + byte[] s = Field25519.contract(y); + s[31] = (byte) (s[31] ^ (getLsb(x) << 7)); + return s; + } + + + /** + * Best effort fix-timing array comparison. + * + * @return true if two arrays are equal. + */ + private static boolean bytesEqual(final byte[] x, final byte[] y) { + if (x == null || y == null) { + return false; + } + if (x.length != y.length) { + return false; + } + int res = 0; + for (int i = 0; i < x.length; i++) { + res |= x[i] ^ y[i]; + } + return res == 0; + } + + /** + * Checks that the point is on curve + */ + boolean isOnCurve() { + long[] x2 = new long[Field25519.LIMB_CNT]; + Field25519.square(x2, x); + long[] y2 = new long[Field25519.LIMB_CNT]; + Field25519.square(y2, y); + long[] z2 = new long[Field25519.LIMB_CNT]; + Field25519.square(z2, z); + long[] z4 = new long[Field25519.LIMB_CNT]; + Field25519.square(z4, z2); + long[] lhs = new long[Field25519.LIMB_CNT]; + // lhs = y^2 - x^2 + Field25519.sub(lhs, y2, x2); + // lhs = z^2 * (y2 - x2) + Field25519.mult(lhs, lhs, z2); + long[] rhs = new long[Field25519.LIMB_CNT]; + // rhs = x^2 * y^2 + Field25519.mult(rhs, x2, y2); + // rhs = D * x^2 * y^2 + Field25519.mult(rhs, rhs, D); + // rhs = z^4 + D * x^2 * y^2 + Field25519.sum(rhs, z4); + // Field25519.mult reduces its output, but Field25519.sum does not, so we have to manually + // reduce it here. + Field25519.reduce(rhs, rhs); + // z^2 (y^2 - x^2) == z^4 + D * x^2 * y^2 + return bytesEqual(Field25519.contract(lhs), Field25519.contract(rhs)); + } + } + + /** + * Represents extended projective point representation (X:Y:Z:T) satisfying x = X/Z, y = Y/Z, + * XY = ZT + *

+ * Note that this is referred as ge_p3 in ref10 impl. + * Also note that t = T below following Java coding style. + *

+ * See + * Hisil H., Wong K.KH., Carter G., Dawson E. (2008) Twisted Edwards Curves Revisited. + *

+ * https://hyperelliptic.org/EFD/g1p/auto-twisted-extended.html + */ + private static class XYZT { + + final XYZ xyz; + final long[] t; + + XYZT() { + this(new XYZ(), new long[Field25519.LIMB_CNT]); + } + + XYZT(XYZ xyz, long[] t) { + this.xyz = xyz; + this.t = t; + } + + XYZT(PartialXYZT partialXYZT) { + this(); + fromPartialXYZT(this, partialXYZT); + } + + /** + * ge_p1p1_to_p2.c + */ + private static XYZT fromPartialXYZT(XYZT out, PartialXYZT in) { + Field25519.mult(out.xyz.x, in.xyz.x, in.t); + Field25519.mult(out.xyz.y, in.xyz.y, in.xyz.z); + Field25519.mult(out.xyz.z, in.xyz.z, in.t); + Field25519.mult(out.t, in.xyz.x, in.xyz.y); + return out; + } + + /** + * Decodes {@code s} into an extented projective point. + * See Section 5.1.3 Decoding in https://tools.ietf.org/html/rfc8032#section-5.1.3 + */ + private static XYZT fromBytesNegateVarTime(byte[] s) throws GeneralSecurityException { + long[] x = new long[Field25519.LIMB_CNT]; + long[] y = Field25519.expand(s); + long[] z = new long[Field25519.LIMB_CNT]; + z[0] = 1; + long[] t = new long[Field25519.LIMB_CNT]; + long[] u = new long[Field25519.LIMB_CNT]; + long[] v = new long[Field25519.LIMB_CNT]; + long[] vxx = new long[Field25519.LIMB_CNT]; + long[] check = new long[Field25519.LIMB_CNT]; + Field25519.square(u, y); + Field25519.mult(v, u, D); + Field25519.sub(u, u, z); // u = y^2 - 1 + Field25519.sum(v, v, z); // v = dy^2 + 1 + + long[] v3 = new long[Field25519.LIMB_CNT]; + Field25519.square(v3, v); + Field25519.mult(v3, v3, v); // v3 = v^3 + Field25519.square(x, v3); + Field25519.mult(x, x, v); + Field25519.mult(x, x, u); // x = uv^7 + + pow2252m3(x, x); // x = (uv^7)^((q-5)/8) + Field25519.mult(x, x, v3); + Field25519.mult(x, x, u); // x = uv^3(uv^7)^((q-5)/8) + + Field25519.square(vxx, x); + Field25519.mult(vxx, vxx, v); + Field25519.sub(check, vxx, u); // vx^2-u + if (isNonZeroVarTime(check)) { + Field25519.sum(check, vxx, u); // vx^2+u + if (isNonZeroVarTime(check)) { + throw new GeneralSecurityException("Cannot convert given bytes to extended projective " + + "coordinates. No square root exists for modulo 2^255-19"); + } + Field25519.mult(x, x, SQRTM1); + } + + if (!isNonZeroVarTime(x) && (s[31] & 0xff) >> 7 != 0) { + throw new GeneralSecurityException("Cannot convert given bytes to extended projective " + + "coordinates. Computed x is zero and encoded x's least significant bit is not zero"); + } + if (getLsb(x) == ((s[31] & 0xff) >> 7)) { + neg(x, x); + } + + Field25519.mult(t, x, y); + return new XYZT(new XYZ(x, y, z), t); + } + } + + /** + * Partial projective point representation ((X:Z),(Y:T)) satisfying x=X/Z, y=Y/T + *

+ * Note that this is referred as complete form in the original ref10 impl (ge_p1p1). + * Also note that t = T below following Java coding style. + *

+ * Although this has the same types as XYZT, it is redefined to have its own type so that it is + * readable and 1:1 corresponds to ref10 impl. + *

+ * Can be converted to XYZT as follows: + * X1 = X * T = x * Z * T = x * Z1 + * Y1 = Y * Z = y * T * Z = y * Z1 + * Z1 = Z * T = Z * T + * T1 = X * Y = x * Z * y * T = x * y * Z1 = X1Y1 / Z1 + */ + private static class PartialXYZT { + + final XYZ xyz; + final long[] t; + + PartialXYZT() { + this(new XYZ(), new long[Field25519.LIMB_CNT]); + } + + PartialXYZT(XYZ xyz, long[] t) { + this.xyz = xyz; + this.t = t; + } + + PartialXYZT(PartialXYZT other) { + xyz = new XYZ(other.xyz); + t = Arrays.copyOf(other.t, Field25519.LIMB_CNT); + } + } + + /** + * Corresponds to the caching mentioned in the last paragraph of Section 3.1 of + * Hisil H., Wong K.KH., Carter G., Dawson E. (2008) Twisted Edwards Curves Revisited. + * with Z = 1. + */ + private static class CachedXYT { + + final long[] yPlusX; + final long[] yMinusX; + final long[] t2d; + + /** + * Creates a cached XYZT with Z = 1 + * + * @param yPlusX y + x + * @param yMinusX y - x + * @param t2d 2d * xy + */ + CachedXYT(long[] yPlusX, long[] yMinusX, long[] t2d) { + this.yPlusX = yPlusX; + this.yMinusX = yMinusX; + this.t2d = t2d; + } + + CachedXYT(CachedXYT other) { + yPlusX = Arrays.copyOf(other.yPlusX, Field25519.LIMB_CNT); + yMinusX = Arrays.copyOf(other.yMinusX, Field25519.LIMB_CNT); + t2d = Arrays.copyOf(other.t2d, Field25519.LIMB_CNT); + } + + // z is one implicitly, so this just copies {@code in} to {@code output}. + void multByZ(long[] output, long[] in) { + System.arraycopy(in, 0, output, 0, Field25519.LIMB_CNT); + } + + /** + * If icopy is 1, copies {@code other} into this point. Time invariant wrt to icopy value. + */ + void copyConditional(CachedXYT other, int icopy) { + copyConditional(yPlusX, other.yPlusX, icopy); + copyConditional(yMinusX, other.yMinusX, icopy); + copyConditional(t2d, other.t2d, icopy); + } + + /** + * Conditionally copies a reduced-form limb arrays {@code b} into {@code a} if {@code icopy} is 1, + * but leave {@code a} unchanged if 'iswap' is 0. Runs in data-invariant time to avoid + * side-channel attacks. + * + *

NOTE that this function requires that {@code icopy} be 1 or 0; other values give wrong + * results. Also, the two limb arrays must be in reduced-coefficient, reduced-degree form: the + * values in a[10..19] or b[10..19] aren't swapped, and all all values in a[0..9],b[0..9] must + * have magnitude less than Integer.MAX_VALUE. + */ + static void copyConditional(long[] a, long[] b, int icopy) { + int copy = -icopy; + for (int i = 0; i < Field25519.LIMB_CNT; i++) { + int x = copy & (((int) a[i]) ^ ((int) b[i])); + a[i] = ((int) a[i]) ^ x; + } + } + } + + private static class CachedXYZT extends CachedXYT { + + private final long[] z; + + CachedXYZT() { + this(new long[Field25519.LIMB_CNT], new long[Field25519.LIMB_CNT], new long[Field25519.LIMB_CNT], new long[Field25519.LIMB_CNT]); + } + + /** + * ge_p3_to_cached.c + */ + CachedXYZT(XYZT xyzt) { + this(); + Field25519.sum(yPlusX, xyzt.xyz.y, xyzt.xyz.x); + Field25519.sub(yMinusX, xyzt.xyz.y, xyzt.xyz.x); + System.arraycopy(xyzt.xyz.z, 0, z, 0, Field25519.LIMB_CNT); + Field25519.mult(t2d, xyzt.t, D2); + } + + /** + * Creates a cached XYZT + * + * @param yPlusX Y + X + * @param yMinusX Y - X + * @param z Z + * @param t2d 2d * (XY/Z) + */ + CachedXYZT(long[] yPlusX, long[] yMinusX, long[] z, long[] t2d) { + super(yPlusX, yMinusX, t2d); + this.z = z; + } + + @Override + public void multByZ(long[] output, long[] in) { + Field25519.mult(output, in, z); + } + } + + /** + * Addition defined in Section 3.1 of + * Hisil H., Wong K.KH., Carter G., Dawson E. (2008) Twisted Edwards Curves Revisited. + *

+ * Please note that this is a partial of the operation listed there leaving out the final + * conversion from PartialXYZT to XYZT. + * + * @param extended extended projective point input + * @param cached cached projective point input + */ + private static void add(PartialXYZT partialXYZT, XYZT extended, CachedXYT cached) { + long[] t = new long[Field25519.LIMB_CNT]; + + // Y1 + X1 + Field25519.sum(partialXYZT.xyz.x, extended.xyz.y, extended.xyz.x); + + // Y1 - X1 + Field25519.sub(partialXYZT.xyz.y, extended.xyz.y, extended.xyz.x); + + // A = (Y1 - X1) * (Y2 - X2) + Field25519.mult(partialXYZT.xyz.y, partialXYZT.xyz.y, cached.yMinusX); + + // B = (Y1 + X1) * (Y2 + X2) + Field25519.mult(partialXYZT.xyz.z, partialXYZT.xyz.x, cached.yPlusX); + + // C = T1 * 2d * T2 = 2d * T1 * T2 (2d is written as k in the paper) + Field25519.mult(partialXYZT.t, extended.t, cached.t2d); + + // Z1 * Z2 + cached.multByZ(partialXYZT.xyz.x, extended.xyz.z); + + // D = 2 * Z1 * Z2 + Field25519.sum(t, partialXYZT.xyz.x, partialXYZT.xyz.x); + + // X3 = B - A + Field25519.sub(partialXYZT.xyz.x, partialXYZT.xyz.z, partialXYZT.xyz.y); + + // Y3 = B + A + Field25519.sum(partialXYZT.xyz.y, partialXYZT.xyz.z, partialXYZT.xyz.y); + + // Z3 = D + C + Field25519.sum(partialXYZT.xyz.z, t, partialXYZT.t); + + // T3 = D - C + Field25519.sub(partialXYZT.t, t, partialXYZT.t); + } + + /** + * Based on the addition defined in Section 3.1 of + * Hisil H., Wong K.KH., Carter G., Dawson E. (2008) Twisted Edwards Curves Revisited. + *

+ * Please note that this is a partial of the operation listed there leaving out the final + * conversion from PartialXYZT to XYZT. + * + * @param extended extended projective point input + * @param cached cached projective point input + */ + private static void sub(PartialXYZT partialXYZT, XYZT extended, CachedXYT cached) { + long[] t = new long[Field25519.LIMB_CNT]; + + // Y1 + X1 + Field25519.sum(partialXYZT.xyz.x, extended.xyz.y, extended.xyz.x); + + // Y1 - X1 + Field25519.sub(partialXYZT.xyz.y, extended.xyz.y, extended.xyz.x); + + // A = (Y1 - X1) * (Y2 + X2) + Field25519.mult(partialXYZT.xyz.y, partialXYZT.xyz.y, cached.yPlusX); + + // B = (Y1 + X1) * (Y2 - X2) + Field25519.mult(partialXYZT.xyz.z, partialXYZT.xyz.x, cached.yMinusX); + + // C = T1 * 2d * T2 = 2d * T1 * T2 (2d is written as k in the paper) + Field25519.mult(partialXYZT.t, extended.t, cached.t2d); + + // Z1 * Z2 + cached.multByZ(partialXYZT.xyz.x, extended.xyz.z); + + // D = 2 * Z1 * Z2 + Field25519.sum(t, partialXYZT.xyz.x, partialXYZT.xyz.x); + + // X3 = B - A + Field25519.sub(partialXYZT.xyz.x, partialXYZT.xyz.z, partialXYZT.xyz.y); + + // Y3 = B + A + Field25519.sum(partialXYZT.xyz.y, partialXYZT.xyz.z, partialXYZT.xyz.y); + + // Z3 = D - C + Field25519.sub(partialXYZT.xyz.z, t, partialXYZT.t); + + // T3 = D + C + Field25519.sum(partialXYZT.t, t, partialXYZT.t); + } + + /** + * Doubles {@code p} and puts the result into this PartialXYZT. + *

+ * This is based on the addition defined in formula 7 in Section 3.3 of + * Hisil H., Wong K.KH., Carter G., Dawson E. (2008) Twisted Edwards Curves Revisited. + *

+ * Please note that this is a partial of the operation listed there leaving out the final + * conversion from PartialXYZT to XYZT and also this fixes a typo in calculation of Y3 and T3 in + * the paper, H should be replaced with A+B. + */ + private static void doubleXYZ(PartialXYZT partialXYZT, XYZ p) { + long[] t0 = new long[Field25519.LIMB_CNT]; + + // XX = X1^2 + Field25519.square(partialXYZT.xyz.x, p.x); + + // YY = Y1^2 + Field25519.square(partialXYZT.xyz.z, p.y); + + // B' = Z1^2 + Field25519.square(partialXYZT.t, p.z); + + // B = 2 * B' + Field25519.sum(partialXYZT.t, partialXYZT.t, partialXYZT.t); + + // A = X1 + Y1 + Field25519.sum(partialXYZT.xyz.y, p.x, p.y); + + // AA = A^2 + Field25519.square(t0, partialXYZT.xyz.y); + + // Y3 = YY + XX + Field25519.sum(partialXYZT.xyz.y, partialXYZT.xyz.z, partialXYZT.xyz.x); + + // Z3 = YY - XX + Field25519.sub(partialXYZT.xyz.z, partialXYZT.xyz.z, partialXYZT.xyz.x); + + // X3 = AA - Y3 + Field25519.sub(partialXYZT.xyz.x, t0, partialXYZT.xyz.y); + + // T3 = B - Z3 + Field25519.sub(partialXYZT.t, partialXYZT.t, partialXYZT.xyz.z); + } + + /** + * Doubles {@code p} and puts the result into this PartialXYZT. + */ + private static void doubleXYZT(PartialXYZT partialXYZT, XYZT p) { + doubleXYZ(partialXYZT, p.xyz); + } + + /** + * Compares two byte values in constant time. + */ + private static int eq(int a, int b) { + int r = ~(a ^ b) & 0xff; + r &= r << 4; + r &= r << 2; + r &= r << 1; + return (r >> 7) & 1; + } + + /** + * This is a constant time operation where point b*B*256^pos is stored in {@code t}. + * When b is 0, t remains the same (i.e., neutral point). + *

+ * Although B_TABLE[32][8] (B_TABLE[i][j] = (j+1)*B*256^i) has j values in [0, 7], the select + * method negates the corresponding point if b is negative (which is straight forward in elliptic + * curves by just negating y coordinate). Therefore we can get multiples of B with the half of + * memory requirements. + * + * @param t neutral element (i.e., point 0), also serves as output. + * @param pos in B[pos][j] = (j+1)*B*256^pos + * @param b value in [-8, 8] range. + */ + private static void select(CachedXYT t, int pos, byte b) { + int bnegative = (b & 0xff) >> 7; + int babs = b - (((-bnegative) & b) << 1); + + t.copyConditional(B_TABLE[pos][0], eq(babs, 1)); + t.copyConditional(B_TABLE[pos][1], eq(babs, 2)); + t.copyConditional(B_TABLE[pos][2], eq(babs, 3)); + t.copyConditional(B_TABLE[pos][3], eq(babs, 4)); + t.copyConditional(B_TABLE[pos][4], eq(babs, 5)); + t.copyConditional(B_TABLE[pos][5], eq(babs, 6)); + t.copyConditional(B_TABLE[pos][6], eq(babs, 7)); + t.copyConditional(B_TABLE[pos][7], eq(babs, 8)); + + long[] yPlusX = Arrays.copyOf(t.yMinusX, Field25519.LIMB_CNT); + long[] yMinusX = Arrays.copyOf(t.yPlusX, Field25519.LIMB_CNT); + long[] t2d = Arrays.copyOf(t.t2d, Field25519.LIMB_CNT); + neg(t2d, t2d); + CachedXYT minust = new CachedXYT(yPlusX, yMinusX, t2d); + t.copyConditional(minust, bnegative); + } + + /** + * Computes {@code a}*B + * where a = a[0]+256*a[1]+...+256^31 a[31] and + * B is the Ed25519 base point (x,4/5) with x positive. + *

+ * Preconditions: + * a[31] <= 127 + * + * @throws IllegalStateException iff there is arithmetic error. + */ + @SuppressWarnings("NarrowingCompoundAssignment") + private static XYZ scalarMultWithBase(byte[] a) { + byte[] e = new byte[2 * Field25519.FIELD_LEN]; + for (int i = 0; i < Field25519.FIELD_LEN; i++) { + e[2 * i + 0] = (byte) (((a[i] & 0xff) >> 0) & 0xf); + e[2 * i + 1] = (byte) (((a[i] & 0xff) >> 4) & 0xf); + } + // each e[i] is between 0 and 15 + // e[63] is between 0 and 7 + + // Rewrite e in a way that each e[i] is in [-8, 8]. + // This can be done since a[63] is in [0, 7], the carry-over onto the most significant byte + // a[63] can be at most 1. + int carry = 0; + for (int i = 0; i < e.length - 1; i++) { + e[i] += carry; + carry = e[i] + 8; + carry >>= 4; + e[i] -= carry << 4; + } + e[e.length - 1] += carry; + + PartialXYZT ret = new PartialXYZT(NEUTRAL); + XYZT xyzt = new XYZT(); + // Although B_TABLE's i can be at most 31 (stores only 32 4bit multiples of B) and we have 64 + // 4bit values in e array, the below for loop adds cached values by iterating e by two in odd + // indices. After the result, we can double the result point 4 times to shift the multiplication + // scalar by 4 bits. + for (int i = 1; i < e.length; i += 2) { + CachedXYT t = new CachedXYT(CACHED_NEUTRAL); + select(t, i / 2, e[i]); + add(ret, XYZT.fromPartialXYZT(xyzt, ret), t); + } + + // Doubles the result 4 times to shift the multiplication scalar 4 bits to get the actual result + // for the odd indices in e. + XYZ xyz = new XYZ(); + doubleXYZ(ret, XYZ.fromPartialXYZT(xyz, ret)); + doubleXYZ(ret, XYZ.fromPartialXYZT(xyz, ret)); + doubleXYZ(ret, XYZ.fromPartialXYZT(xyz, ret)); + doubleXYZ(ret, XYZ.fromPartialXYZT(xyz, ret)); + + // Add multiples of B for even indices of e. + for (int i = 0; i < e.length; i += 2) { + CachedXYT t = new CachedXYT(CACHED_NEUTRAL); + select(t, i / 2, e[i]); + add(ret, XYZT.fromPartialXYZT(xyzt, ret), t); + } + + // This check is to protect against flaws, i.e. if there is a computation error through a + // faulty CPU or if the implementation contains a bug. + XYZ result = new XYZ(ret); + if (!result.isOnCurve()) { + throw new IllegalStateException("arithmetic error in scalar multiplication"); + } + return result; + } + + @SuppressWarnings("NarrowingCompoundAssignment") + private static byte[] slide(byte[] a) { + byte[] r = new byte[256]; + // Writes each bit in a[0..31] into r[0..255]: + // a = a[0]+256*a[1]+...+256^31*a[31] is equal to + // r = r[0]+2*r[1]+...+2^255*r[255] + for (int i = 0; i < 256; i++) { + r[i] = (byte) (1 & ((a[i >> 3] & 0xff) >> (i & 7))); + } + + // Transforms r[i] as odd values in [-15, 15] + for (int i = 0; i < 256; i++) { + if (r[i] != 0) { + for (int b = 1; b <= 6 && i + b < 256; b++) { + if (r[i + b] != 0) { + if (r[i] + (r[i + b] << b) <= 15) { + r[i] += r[i + b] << b; + r[i + b] = 0; + } else if (r[i] - (r[i + b] << b) >= -15) { + r[i] -= r[i + b] << b; + for (int k = i + b; k < 256; k++) { + if (r[k] == 0) { + r[k] = 1; + break; + } + r[k] = 0; + } + } else { + break; + } + } + } + } + } + return r; + } + + /** + * Computes {@code a}*{@code pointA}+{@code b}*B + * where a = a[0]+256*a[1]+...+256^31*a[31]. + * and b = b[0]+256*b[1]+...+256^31*b[31]. + * B is the Ed25519 base point (x,4/5) with x positive. + *

+ * Note that execution time varies based on the input since this will only be used in verification + * of signatures. + */ + private static XYZ doubleScalarMultVarTime(byte[] a, XYZT pointA, byte[] b) { + // pointA, 3*pointA, 5*pointA, 7*pointA, 9*pointA, 11*pointA, 13*pointA, 15*pointA + CachedXYZT[] pointAArray = new CachedXYZT[8]; + pointAArray[0] = new CachedXYZT(pointA); + PartialXYZT t = new PartialXYZT(); + doubleXYZT(t, pointA); + XYZT doubleA = new XYZT(t); + for (int i = 1; i < pointAArray.length; i++) { + add(t, doubleA, pointAArray[i - 1]); + pointAArray[i] = new CachedXYZT(new XYZT(t)); + } + + byte[] aSlide = slide(a); + byte[] bSlide = slide(b); + t = new PartialXYZT(NEUTRAL); + XYZT u = new XYZT(); + int i = 255; + for (; i >= 0; i--) { + if (aSlide[i] != 0 || bSlide[i] != 0) { + break; + } + } + for (; i >= 0; i--) { + doubleXYZ(t, new XYZ(t)); + if (aSlide[i] > 0) { + add(t, XYZT.fromPartialXYZT(u, t), pointAArray[aSlide[i] / 2]); + } else if (aSlide[i] < 0) { + sub(t, XYZT.fromPartialXYZT(u, t), pointAArray[-aSlide[i] / 2]); + } + if (bSlide[i] > 0) { + add(t, XYZT.fromPartialXYZT(u, t), B2[bSlide[i] / 2]); + } else if (bSlide[i] < 0) { + sub(t, XYZT.fromPartialXYZT(u, t), B2[-bSlide[i] / 2]); + } + } + + return new XYZ(t); + } + + /** + * Returns true if {@code in} is nonzero. + *

+ * Note that execution time might depend on the input {@code in}. + */ + private static boolean isNonZeroVarTime(long[] in) { + long[] inCopy = new long[in.length + 1]; + System.arraycopy(in, 0, inCopy, 0, in.length); + Field25519.reduceCoefficients(inCopy); + byte[] bytes = Field25519.contract(inCopy); + for (byte b : bytes) { + if (b != 0) { + return true; + } + } + return false; + } + + /** + * Returns the least significant bit of {@code in}. + */ + private static int getLsb(long[] in) { + return Field25519.contract(in)[0] & 1; + } + + /** + * Negates all values in {@code in} and store it in {@code out}. + */ + private static void neg(long[] out, long[] in) { + for (int i = 0; i < in.length; i++) { + out[i] = -in[i]; + } + } + + /** + * Computes {@code in}^(2^252-3) mod 2^255-19 and puts the result in {@code out}. + */ + private static void pow2252m3(long[] out, long[] in) { + long[] t0 = new long[Field25519.LIMB_CNT]; + long[] t1 = new long[Field25519.LIMB_CNT]; + long[] t2 = new long[Field25519.LIMB_CNT]; + + // z2 = z1^2^1 + Field25519.square(t0, in); + + // z8 = z2^2^2 + Field25519.square(t1, t0); + for (int i = 1; i < 2; i++) { + Field25519.square(t1, t1); + } + + // z9 = z1*z8 + Field25519.mult(t1, in, t1); + + // z11 = z2*z9 + Field25519.mult(t0, t0, t1); + + // z22 = z11^2^1 + Field25519.square(t0, t0); + + // z_5_0 = z9*z22 + Field25519.mult(t0, t1, t0); + + // z_10_5 = z_5_0^2^5 + Field25519.square(t1, t0); + for (int i = 1; i < 5; i++) { + Field25519.square(t1, t1); + } + + // z_10_0 = z_10_5*z_5_0 + Field25519.mult(t0, t1, t0); + + // z_20_10 = z_10_0^2^10 + Field25519.square(t1, t0); + for (int i = 1; i < 10; i++) { + Field25519.square(t1, t1); + } + + // z_20_0 = z_20_10*z_10_0 + Field25519.mult(t1, t1, t0); + + // z_40_20 = z_20_0^2^20 + Field25519.square(t2, t1); + for (int i = 1; i < 20; i++) { + Field25519.square(t2, t2); + } + + // z_40_0 = z_40_20*z_20_0 + Field25519.mult(t1, t2, t1); + + // z_50_10 = z_40_0^2^10 + Field25519.square(t1, t1); + for (int i = 1; i < 10; i++) { + Field25519.square(t1, t1); + } + + // z_50_0 = z_50_10*z_10_0 + Field25519.mult(t0, t1, t0); + + // z_100_50 = z_50_0^2^50 + Field25519.square(t1, t0); + for (int i = 1; i < 50; i++) { + Field25519.square(t1, t1); + } + + // z_100_0 = z_100_50*z_50_0 + Field25519.mult(t1, t1, t0); + + // z_200_100 = z_100_0^2^100 + Field25519.square(t2, t1); + for (int i = 1; i < 100; i++) { + Field25519.square(t2, t2); + } + + // z_200_0 = z_200_100*z_100_0 + Field25519.mult(t1, t2, t1); + + // z_250_50 = z_200_0^2^50 + Field25519.square(t1, t1); + for (int i = 1; i < 50; i++) { + Field25519.square(t1, t1); + } + + // z_250_0 = z_250_50*z_50_0 + Field25519.mult(t0, t1, t0); + + // z_252_2 = z_250_0^2^2 + Field25519.square(t0, t0); + for (int i = 1; i < 2; i++) { + Field25519.square(t0, t0); + } + + // z_252_3 = z_252_2*z1 + Field25519.mult(out, t0, in); + } + + /** + * Returns 3 bytes of {@code in} starting from {@code idx} in Little-Endian format. + */ + private static long load3(byte[] in, int idx) { + long result; + result = (long) in[idx] & 0xff; + result |= (long) (in[idx + 1] & 0xff) << 8; + result |= (long) (in[idx + 2] & 0xff) << 16; + return result; + } + + /** + * Returns 4 bytes of {@code in} starting from {@code idx} in Little-Endian format. + */ + private static long load4(byte[] in, int idx) { + long result = load3(in, idx); + result |= (long) (in[idx + 3] & 0xff) << 24; + return result; + } + + /** + * Input: + * s[0]+256*s[1]+...+256^63*s[63] = s + *

+ * Output: + * s[0]+256*s[1]+...+256^31*s[31] = s mod l + * where l = 2^252 + 27742317777372353535851937790883648493. + * Overwrites s in place. + */ + private static void reduce(byte[] s) { + // Observation: + // 2^252 mod l is equivalent to -27742317777372353535851937790883648493 mod l + // Let m = -27742317777372353535851937790883648493 + // Thus a*2^252+b mod l is equivalent to a*m+b mod l + // + // First s is divided into chunks of 21 bits as follows: + // s0+2^21*s1+2^42*s3+...+2^462*s23 = s[0]+256*s[1]+...+256^63*s[63] + long s0 = 2097151 & load3(s, 0); + long s1 = 2097151 & (load4(s, 2) >> 5); + long s2 = 2097151 & (load3(s, 5) >> 2); + long s3 = 2097151 & (load4(s, 7) >> 7); + long s4 = 2097151 & (load4(s, 10) >> 4); + long s5 = 2097151 & (load3(s, 13) >> 1); + long s6 = 2097151 & (load4(s, 15) >> 6); + long s7 = 2097151 & (load3(s, 18) >> 3); + long s8 = 2097151 & load3(s, 21); + long s9 = 2097151 & (load4(s, 23) >> 5); + long s10 = 2097151 & (load3(s, 26) >> 2); + long s11 = 2097151 & (load4(s, 28) >> 7); + long s12 = 2097151 & (load4(s, 31) >> 4); + long s13 = 2097151 & (load3(s, 34) >> 1); + long s14 = 2097151 & (load4(s, 36) >> 6); + long s15 = 2097151 & (load3(s, 39) >> 3); + long s16 = 2097151 & load3(s, 42); + long s17 = 2097151 & (load4(s, 44) >> 5); + long s18 = 2097151 & (load3(s, 47) >> 2); + long s19 = 2097151 & (load4(s, 49) >> 7); + long s20 = 2097151 & (load4(s, 52) >> 4); + long s21 = 2097151 & (load3(s, 55) >> 1); + long s22 = 2097151 & (load4(s, 57) >> 6); + long s23 = (load4(s, 60) >> 3); + long carry0; + long carry1; + long carry2; + long carry3; + long carry4; + long carry5; + long carry6; + long carry7; + long carry8; + long carry9; + long carry10; + long carry11; + long carry12; + long carry13; + long carry14; + long carry15; + long carry16; + + // s23*2^462 = s23*2^210*2^252 is equivalent to s23*2^210*m in mod l + // As m is a 125 bit number, the result needs to scattered to 6 limbs (125/21 ceil is 6) + // starting from s11 (s11*2^210) + // m = [666643, 470296, 654183, -997805, 136657, -683901] in 21-bit limbs + s11 += s23 * 666643; + s12 += s23 * 470296; + s13 += s23 * 654183; + s14 -= s23 * 997805; + s15 += s23 * 136657; + s16 -= s23 * 683901; + // s23 = 0; + + s10 += s22 * 666643; + s11 += s22 * 470296; + s12 += s22 * 654183; + s13 -= s22 * 997805; + s14 += s22 * 136657; + s15 -= s22 * 683901; + // s22 = 0; + + s9 += s21 * 666643; + s10 += s21 * 470296; + s11 += s21 * 654183; + s12 -= s21 * 997805; + s13 += s21 * 136657; + s14 -= s21 * 683901; + // s21 = 0; + + s8 += s20 * 666643; + s9 += s20 * 470296; + s10 += s20 * 654183; + s11 -= s20 * 997805; + s12 += s20 * 136657; + s13 -= s20 * 683901; + // s20 = 0; + + s7 += s19 * 666643; + s8 += s19 * 470296; + s9 += s19 * 654183; + s10 -= s19 * 997805; + s11 += s19 * 136657; + s12 -= s19 * 683901; + // s19 = 0; + + s6 += s18 * 666643; + s7 += s18 * 470296; + s8 += s18 * 654183; + s9 -= s18 * 997805; + s10 += s18 * 136657; + s11 -= s18 * 683901; + // s18 = 0; + + // Reduce the bit length of limbs from s6 to s15 to 21-bits. + carry6 = (s6 + (1 << 20)) >> 21; + s7 += carry6; + s6 -= carry6 << 21; + carry8 = (s8 + (1 << 20)) >> 21; + s9 += carry8; + s8 -= carry8 << 21; + carry10 = (s10 + (1 << 20)) >> 21; + s11 += carry10; + s10 -= carry10 << 21; + carry12 = (s12 + (1 << 20)) >> 21; + s13 += carry12; + s12 -= carry12 << 21; + carry14 = (s14 + (1 << 20)) >> 21; + s15 += carry14; + s14 -= carry14 << 21; + carry16 = (s16 + (1 << 20)) >> 21; + s17 += carry16; + s16 -= carry16 << 21; + + carry7 = (s7 + (1 << 20)) >> 21; + s8 += carry7; + s7 -= carry7 << 21; + carry9 = (s9 + (1 << 20)) >> 21; + s10 += carry9; + s9 -= carry9 << 21; + carry11 = (s11 + (1 << 20)) >> 21; + s12 += carry11; + s11 -= carry11 << 21; + carry13 = (s13 + (1 << 20)) >> 21; + s14 += carry13; + s13 -= carry13 << 21; + carry15 = (s15 + (1 << 20)) >> 21; + s16 += carry15; + s15 -= carry15 << 21; + + // Resume reduction where we left off. + s5 += s17 * 666643; + s6 += s17 * 470296; + s7 += s17 * 654183; + s8 -= s17 * 997805; + s9 += s17 * 136657; + s10 -= s17 * 683901; + // s17 = 0; + + s4 += s16 * 666643; + s5 += s16 * 470296; + s6 += s16 * 654183; + s7 -= s16 * 997805; + s8 += s16 * 136657; + s9 -= s16 * 683901; + // s16 = 0; + + s3 += s15 * 666643; + s4 += s15 * 470296; + s5 += s15 * 654183; + s6 -= s15 * 997805; + s7 += s15 * 136657; + s8 -= s15 * 683901; + // s15 = 0; + + s2 += s14 * 666643; + s3 += s14 * 470296; + s4 += s14 * 654183; + s5 -= s14 * 997805; + s6 += s14 * 136657; + s7 -= s14 * 683901; + // s14 = 0; + + s1 += s13 * 666643; + s2 += s13 * 470296; + s3 += s13 * 654183; + s4 -= s13 * 997805; + s5 += s13 * 136657; + s6 -= s13 * 683901; + // s13 = 0; + + s0 += s12 * 666643; + s1 += s12 * 470296; + s2 += s12 * 654183; + s3 -= s12 * 997805; + s4 += s12 * 136657; + s5 -= s12 * 683901; + s12 = 0; + + // Reduce the range of limbs from s0 to s11 to 21-bits. + carry0 = (s0 + (1 << 20)) >> 21; + s1 += carry0; + s0 -= carry0 << 21; + carry2 = (s2 + (1 << 20)) >> 21; + s3 += carry2; + s2 -= carry2 << 21; + carry4 = (s4 + (1 << 20)) >> 21; + s5 += carry4; + s4 -= carry4 << 21; + carry6 = (s6 + (1 << 20)) >> 21; + s7 += carry6; + s6 -= carry6 << 21; + carry8 = (s8 + (1 << 20)) >> 21; + s9 += carry8; + s8 -= carry8 << 21; + carry10 = (s10 + (1 << 20)) >> 21; + s11 += carry10; + s10 -= carry10 << 21; + + carry1 = (s1 + (1 << 20)) >> 21; + s2 += carry1; + s1 -= carry1 << 21; + carry3 = (s3 + (1 << 20)) >> 21; + s4 += carry3; + s3 -= carry3 << 21; + carry5 = (s5 + (1 << 20)) >> 21; + s6 += carry5; + s5 -= carry5 << 21; + carry7 = (s7 + (1 << 20)) >> 21; + s8 += carry7; + s7 -= carry7 << 21; + carry9 = (s9 + (1 << 20)) >> 21; + s10 += carry9; + s9 -= carry9 << 21; + carry11 = (s11 + (1 << 20)) >> 21; + s12 += carry11; + s11 -= carry11 << 21; + + s0 += s12 * 666643; + s1 += s12 * 470296; + s2 += s12 * 654183; + s3 -= s12 * 997805; + s4 += s12 * 136657; + s5 -= s12 * 683901; + s12 = 0; + + // Carry chain reduction to propagate excess bits from s0 to s5 to the most significant limbs. + carry0 = s0 >> 21; + s1 += carry0; + s0 -= carry0 << 21; + carry1 = s1 >> 21; + s2 += carry1; + s1 -= carry1 << 21; + carry2 = s2 >> 21; + s3 += carry2; + s2 -= carry2 << 21; + carry3 = s3 >> 21; + s4 += carry3; + s3 -= carry3 << 21; + carry4 = s4 >> 21; + s5 += carry4; + s4 -= carry4 << 21; + carry5 = s5 >> 21; + s6 += carry5; + s5 -= carry5 << 21; + carry6 = s6 >> 21; + s7 += carry6; + s6 -= carry6 << 21; + carry7 = s7 >> 21; + s8 += carry7; + s7 -= carry7 << 21; + carry8 = s8 >> 21; + s9 += carry8; + s8 -= carry8 << 21; + carry9 = s9 >> 21; + s10 += carry9; + s9 -= carry9 << 21; + carry10 = s10 >> 21; + s11 += carry10; + s10 -= carry10 << 21; + carry11 = s11 >> 21; + s12 += carry11; + s11 -= carry11 << 21; + + // Do one last reduction as s12 might be 1. + s0 += s12 * 666643; + s1 += s12 * 470296; + s2 += s12 * 654183; + s3 -= s12 * 997805; + s4 += s12 * 136657; + s5 -= s12 * 683901; + // s12 = 0; + + carry0 = s0 >> 21; + s1 += carry0; + s0 -= carry0 << 21; + carry1 = s1 >> 21; + s2 += carry1; + s1 -= carry1 << 21; + carry2 = s2 >> 21; + s3 += carry2; + s2 -= carry2 << 21; + carry3 = s3 >> 21; + s4 += carry3; + s3 -= carry3 << 21; + carry4 = s4 >> 21; + s5 += carry4; + s4 -= carry4 << 21; + carry5 = s5 >> 21; + s6 += carry5; + s5 -= carry5 << 21; + carry6 = s6 >> 21; + s7 += carry6; + s6 -= carry6 << 21; + carry7 = s7 >> 21; + s8 += carry7; + s7 -= carry7 << 21; + carry8 = s8 >> 21; + s9 += carry8; + s8 -= carry8 << 21; + carry9 = s9 >> 21; + s10 += carry9; + s9 -= carry9 << 21; + carry10 = s10 >> 21; + s11 += carry10; + s10 -= carry10 << 21; + + // Serialize the result into the s. + s[0] = (byte) s0; + s[1] = (byte) (s0 >> 8); + s[2] = (byte) ((s0 >> 16) | (s1 << 5)); + s[3] = (byte) (s1 >> 3); + s[4] = (byte) (s1 >> 11); + s[5] = (byte) ((s1 >> 19) | (s2 << 2)); + s[6] = (byte) (s2 >> 6); + s[7] = (byte) ((s2 >> 14) | (s3 << 7)); + s[8] = (byte) (s3 >> 1); + s[9] = (byte) (s3 >> 9); + s[10] = (byte) ((s3 >> 17) | (s4 << 4)); + s[11] = (byte) (s4 >> 4); + s[12] = (byte) (s4 >> 12); + s[13] = (byte) ((s4 >> 20) | (s5 << 1)); + s[14] = (byte) (s5 >> 7); + s[15] = (byte) ((s5 >> 15) | (s6 << 6)); + s[16] = (byte) (s6 >> 2); + s[17] = (byte) (s6 >> 10); + s[18] = (byte) ((s6 >> 18) | (s7 << 3)); + s[19] = (byte) (s7 >> 5); + s[20] = (byte) (s7 >> 13); + s[21] = (byte) s8; + s[22] = (byte) (s8 >> 8); + s[23] = (byte) ((s8 >> 16) | (s9 << 5)); + s[24] = (byte) (s9 >> 3); + s[25] = (byte) (s9 >> 11); + s[26] = (byte) ((s9 >> 19) | (s10 << 2)); + s[27] = (byte) (s10 >> 6); + s[28] = (byte) ((s10 >> 14) | (s11 << 7)); + s[29] = (byte) (s11 >> 1); + s[30] = (byte) (s11 >> 9); + s[31] = (byte) (s11 >> 17); + } + + /** + * Input: + * a[0]+256*a[1]+...+256^31*a[31] = a + * b[0]+256*b[1]+...+256^31*b[31] = b + * c[0]+256*c[1]+...+256^31*c[31] = c + *

+ * Output: + * s[0]+256*s[1]+...+256^31*s[31] = (ab+c) mod l + * where l = 2^252 + 27742317777372353535851937790883648493. + */ + private static void mulAdd(byte[] s, byte[] a, byte[] b, byte[] c) { + // This is very similar to Ed25519.reduce, the difference in here is that it computes ab+c + // See Ed25519.reduce for related comments. + long a0 = 2097151 & load3(a, 0); + long a1 = 2097151 & (load4(a, 2) >> 5); + long a2 = 2097151 & (load3(a, 5) >> 2); + long a3 = 2097151 & (load4(a, 7) >> 7); + long a4 = 2097151 & (load4(a, 10) >> 4); + long a5 = 2097151 & (load3(a, 13) >> 1); + long a6 = 2097151 & (load4(a, 15) >> 6); + long a7 = 2097151 & (load3(a, 18) >> 3); + long a8 = 2097151 & load3(a, 21); + long a9 = 2097151 & (load4(a, 23) >> 5); + long a10 = 2097151 & (load3(a, 26) >> 2); + long a11 = (load4(a, 28) >> 7); + long b0 = 2097151 & load3(b, 0); + long b1 = 2097151 & (load4(b, 2) >> 5); + long b2 = 2097151 & (load3(b, 5) >> 2); + long b3 = 2097151 & (load4(b, 7) >> 7); + long b4 = 2097151 & (load4(b, 10) >> 4); + long b5 = 2097151 & (load3(b, 13) >> 1); + long b6 = 2097151 & (load4(b, 15) >> 6); + long b7 = 2097151 & (load3(b, 18) >> 3); + long b8 = 2097151 & load3(b, 21); + long b9 = 2097151 & (load4(b, 23) >> 5); + long b10 = 2097151 & (load3(b, 26) >> 2); + long b11 = (load4(b, 28) >> 7); + long c0 = 2097151 & load3(c, 0); + long c1 = 2097151 & (load4(c, 2) >> 5); + long c2 = 2097151 & (load3(c, 5) >> 2); + long c3 = 2097151 & (load4(c, 7) >> 7); + long c4 = 2097151 & (load4(c, 10) >> 4); + long c5 = 2097151 & (load3(c, 13) >> 1); + long c6 = 2097151 & (load4(c, 15) >> 6); + long c7 = 2097151 & (load3(c, 18) >> 3); + long c8 = 2097151 & load3(c, 21); + long c9 = 2097151 & (load4(c, 23) >> 5); + long c10 = 2097151 & (load3(c, 26) >> 2); + long c11 = (load4(c, 28) >> 7); + long s0; + long s1; + long s2; + long s3; + long s4; + long s5; + long s6; + long s7; + long s8; + long s9; + long s10; + long s11; + long s12; + long s13; + long s14; + long s15; + long s16; + long s17; + long s18; + long s19; + long s20; + long s21; + long s22; + long s23; + long carry0; + long carry1; + long carry2; + long carry3; + long carry4; + long carry5; + long carry6; + long carry7; + long carry8; + long carry9; + long carry10; + long carry11; + long carry12; + long carry13; + long carry14; + long carry15; + long carry16; + long carry17; + long carry18; + long carry19; + long carry20; + long carry21; + long carry22; + + s0 = c0 + a0 * b0; + s1 = c1 + a0 * b1 + a1 * b0; + s2 = c2 + a0 * b2 + a1 * b1 + a2 * b0; + s3 = c3 + a0 * b3 + a1 * b2 + a2 * b1 + a3 * b0; + s4 = c4 + a0 * b4 + a1 * b3 + a2 * b2 + a3 * b1 + a4 * b0; + s5 = c5 + a0 * b5 + a1 * b4 + a2 * b3 + a3 * b2 + a4 * b1 + a5 * b0; + s6 = c6 + a0 * b6 + a1 * b5 + a2 * b4 + a3 * b3 + a4 * b2 + a5 * b1 + a6 * b0; + s7 = c7 + a0 * b7 + a1 * b6 + a2 * b5 + a3 * b4 + a4 * b3 + a5 * b2 + a6 * b1 + a7 * b0; + s8 = c8 + a0 * b8 + a1 * b7 + a2 * b6 + a3 * b5 + a4 * b4 + a5 * b3 + a6 * b2 + a7 * b1 + + a8 * b0; + s9 = c9 + a0 * b9 + a1 * b8 + a2 * b7 + a3 * b6 + a4 * b5 + a5 * b4 + a6 * b3 + a7 * b2 + + a8 * b1 + a9 * b0; + s10 = c10 + a0 * b10 + a1 * b9 + a2 * b8 + a3 * b7 + a4 * b6 + a5 * b5 + a6 * b4 + a7 * b3 + + a8 * b2 + a9 * b1 + a10 * b0; + s11 = c11 + a0 * b11 + a1 * b10 + a2 * b9 + a3 * b8 + a4 * b7 + a5 * b6 + a6 * b5 + a7 * b4 + + a8 * b3 + a9 * b2 + a10 * b1 + a11 * b0; + s12 = a1 * b11 + a2 * b10 + a3 * b9 + a4 * b8 + a5 * b7 + a6 * b6 + a7 * b5 + a8 * b4 + a9 * b3 + + a10 * b2 + a11 * b1; + s13 = a2 * b11 + a3 * b10 + a4 * b9 + a5 * b8 + a6 * b7 + a7 * b6 + a8 * b5 + a9 * b4 + a10 * b3 + + a11 * b2; + s14 = a3 * b11 + a4 * b10 + a5 * b9 + a6 * b8 + a7 * b7 + a8 * b6 + a9 * b5 + a10 * b4 + + a11 * b3; + s15 = a4 * b11 + a5 * b10 + a6 * b9 + a7 * b8 + a8 * b7 + a9 * b6 + a10 * b5 + a11 * b4; + s16 = a5 * b11 + a6 * b10 + a7 * b9 + a8 * b8 + a9 * b7 + a10 * b6 + a11 * b5; + s17 = a6 * b11 + a7 * b10 + a8 * b9 + a9 * b8 + a10 * b7 + a11 * b6; + s18 = a7 * b11 + a8 * b10 + a9 * b9 + a10 * b8 + a11 * b7; + s19 = a8 * b11 + a9 * b10 + a10 * b9 + a11 * b8; + s20 = a9 * b11 + a10 * b10 + a11 * b9; + s21 = a10 * b11 + a11 * b10; + s22 = a11 * b11; + s23 = 0; + + carry0 = (s0 + (1 << 20)) >> 21; + s1 += carry0; + s0 -= carry0 << 21; + carry2 = (s2 + (1 << 20)) >> 21; + s3 += carry2; + s2 -= carry2 << 21; + carry4 = (s4 + (1 << 20)) >> 21; + s5 += carry4; + s4 -= carry4 << 21; + carry6 = (s6 + (1 << 20)) >> 21; + s7 += carry6; + s6 -= carry6 << 21; + carry8 = (s8 + (1 << 20)) >> 21; + s9 += carry8; + s8 -= carry8 << 21; + carry10 = (s10 + (1 << 20)) >> 21; + s11 += carry10; + s10 -= carry10 << 21; + carry12 = (s12 + (1 << 20)) >> 21; + s13 += carry12; + s12 -= carry12 << 21; + carry14 = (s14 + (1 << 20)) >> 21; + s15 += carry14; + s14 -= carry14 << 21; + carry16 = (s16 + (1 << 20)) >> 21; + s17 += carry16; + s16 -= carry16 << 21; + carry18 = (s18 + (1 << 20)) >> 21; + s19 += carry18; + s18 -= carry18 << 21; + carry20 = (s20 + (1 << 20)) >> 21; + s21 += carry20; + s20 -= carry20 << 21; + carry22 = (s22 + (1 << 20)) >> 21; + s23 += carry22; + s22 -= carry22 << 21; + + carry1 = (s1 + (1 << 20)) >> 21; + s2 += carry1; + s1 -= carry1 << 21; + carry3 = (s3 + (1 << 20)) >> 21; + s4 += carry3; + s3 -= carry3 << 21; + carry5 = (s5 + (1 << 20)) >> 21; + s6 += carry5; + s5 -= carry5 << 21; + carry7 = (s7 + (1 << 20)) >> 21; + s8 += carry7; + s7 -= carry7 << 21; + carry9 = (s9 + (1 << 20)) >> 21; + s10 += carry9; + s9 -= carry9 << 21; + carry11 = (s11 + (1 << 20)) >> 21; + s12 += carry11; + s11 -= carry11 << 21; + carry13 = (s13 + (1 << 20)) >> 21; + s14 += carry13; + s13 -= carry13 << 21; + carry15 = (s15 + (1 << 20)) >> 21; + s16 += carry15; + s15 -= carry15 << 21; + carry17 = (s17 + (1 << 20)) >> 21; + s18 += carry17; + s17 -= carry17 << 21; + carry19 = (s19 + (1 << 20)) >> 21; + s20 += carry19; + s19 -= carry19 << 21; + carry21 = (s21 + (1 << 20)) >> 21; + s22 += carry21; + s21 -= carry21 << 21; + + s11 += s23 * 666643; + s12 += s23 * 470296; + s13 += s23 * 654183; + s14 -= s23 * 997805; + s15 += s23 * 136657; + s16 -= s23 * 683901; + // s23 = 0; + + s10 += s22 * 666643; + s11 += s22 * 470296; + s12 += s22 * 654183; + s13 -= s22 * 997805; + s14 += s22 * 136657; + s15 -= s22 * 683901; + // s22 = 0; + + s9 += s21 * 666643; + s10 += s21 * 470296; + s11 += s21 * 654183; + s12 -= s21 * 997805; + s13 += s21 * 136657; + s14 -= s21 * 683901; + // s21 = 0; + + s8 += s20 * 666643; + s9 += s20 * 470296; + s10 += s20 * 654183; + s11 -= s20 * 997805; + s12 += s20 * 136657; + s13 -= s20 * 683901; + // s20 = 0; + + s7 += s19 * 666643; + s8 += s19 * 470296; + s9 += s19 * 654183; + s10 -= s19 * 997805; + s11 += s19 * 136657; + s12 -= s19 * 683901; + // s19 = 0; + + s6 += s18 * 666643; + s7 += s18 * 470296; + s8 += s18 * 654183; + s9 -= s18 * 997805; + s10 += s18 * 136657; + s11 -= s18 * 683901; + // s18 = 0; + + carry6 = (s6 + (1 << 20)) >> 21; + s7 += carry6; + s6 -= carry6 << 21; + carry8 = (s8 + (1 << 20)) >> 21; + s9 += carry8; + s8 -= carry8 << 21; + carry10 = (s10 + (1 << 20)) >> 21; + s11 += carry10; + s10 -= carry10 << 21; + carry12 = (s12 + (1 << 20)) >> 21; + s13 += carry12; + s12 -= carry12 << 21; + carry14 = (s14 + (1 << 20)) >> 21; + s15 += carry14; + s14 -= carry14 << 21; + carry16 = (s16 + (1 << 20)) >> 21; + s17 += carry16; + s16 -= carry16 << 21; + + carry7 = (s7 + (1 << 20)) >> 21; + s8 += carry7; + s7 -= carry7 << 21; + carry9 = (s9 + (1 << 20)) >> 21; + s10 += carry9; + s9 -= carry9 << 21; + carry11 = (s11 + (1 << 20)) >> 21; + s12 += carry11; + s11 -= carry11 << 21; + carry13 = (s13 + (1 << 20)) >> 21; + s14 += carry13; + s13 -= carry13 << 21; + carry15 = (s15 + (1 << 20)) >> 21; + s16 += carry15; + s15 -= carry15 << 21; + + s5 += s17 * 666643; + s6 += s17 * 470296; + s7 += s17 * 654183; + s8 -= s17 * 997805; + s9 += s17 * 136657; + s10 -= s17 * 683901; + // s17 = 0; + + s4 += s16 * 666643; + s5 += s16 * 470296; + s6 += s16 * 654183; + s7 -= s16 * 997805; + s8 += s16 * 136657; + s9 -= s16 * 683901; + // s16 = 0; + + s3 += s15 * 666643; + s4 += s15 * 470296; + s5 += s15 * 654183; + s6 -= s15 * 997805; + s7 += s15 * 136657; + s8 -= s15 * 683901; + // s15 = 0; + + s2 += s14 * 666643; + s3 += s14 * 470296; + s4 += s14 * 654183; + s5 -= s14 * 997805; + s6 += s14 * 136657; + s7 -= s14 * 683901; + // s14 = 0; + + s1 += s13 * 666643; + s2 += s13 * 470296; + s3 += s13 * 654183; + s4 -= s13 * 997805; + s5 += s13 * 136657; + s6 -= s13 * 683901; + // s13 = 0; + + s0 += s12 * 666643; + s1 += s12 * 470296; + s2 += s12 * 654183; + s3 -= s12 * 997805; + s4 += s12 * 136657; + s5 -= s12 * 683901; + s12 = 0; + + carry0 = (s0 + (1 << 20)) >> 21; + s1 += carry0; + s0 -= carry0 << 21; + carry2 = (s2 + (1 << 20)) >> 21; + s3 += carry2; + s2 -= carry2 << 21; + carry4 = (s4 + (1 << 20)) >> 21; + s5 += carry4; + s4 -= carry4 << 21; + carry6 = (s6 + (1 << 20)) >> 21; + s7 += carry6; + s6 -= carry6 << 21; + carry8 = (s8 + (1 << 20)) >> 21; + s9 += carry8; + s8 -= carry8 << 21; + carry10 = (s10 + (1 << 20)) >> 21; + s11 += carry10; + s10 -= carry10 << 21; + + carry1 = (s1 + (1 << 20)) >> 21; + s2 += carry1; + s1 -= carry1 << 21; + carry3 = (s3 + (1 << 20)) >> 21; + s4 += carry3; + s3 -= carry3 << 21; + carry5 = (s5 + (1 << 20)) >> 21; + s6 += carry5; + s5 -= carry5 << 21; + carry7 = (s7 + (1 << 20)) >> 21; + s8 += carry7; + s7 -= carry7 << 21; + carry9 = (s9 + (1 << 20)) >> 21; + s10 += carry9; + s9 -= carry9 << 21; + carry11 = (s11 + (1 << 20)) >> 21; + s12 += carry11; + s11 -= carry11 << 21; + + s0 += s12 * 666643; + s1 += s12 * 470296; + s2 += s12 * 654183; + s3 -= s12 * 997805; + s4 += s12 * 136657; + s5 -= s12 * 683901; + s12 = 0; + + carry0 = s0 >> 21; + s1 += carry0; + s0 -= carry0 << 21; + carry1 = s1 >> 21; + s2 += carry1; + s1 -= carry1 << 21; + carry2 = s2 >> 21; + s3 += carry2; + s2 -= carry2 << 21; + carry3 = s3 >> 21; + s4 += carry3; + s3 -= carry3 << 21; + carry4 = s4 >> 21; + s5 += carry4; + s4 -= carry4 << 21; + carry5 = s5 >> 21; + s6 += carry5; + s5 -= carry5 << 21; + carry6 = s6 >> 21; + s7 += carry6; + s6 -= carry6 << 21; + carry7 = s7 >> 21; + s8 += carry7; + s7 -= carry7 << 21; + carry8 = s8 >> 21; + s9 += carry8; + s8 -= carry8 << 21; + carry9 = s9 >> 21; + s10 += carry9; + s9 -= carry9 << 21; + carry10 = s10 >> 21; + s11 += carry10; + s10 -= carry10 << 21; + carry11 = s11 >> 21; + s12 += carry11; + s11 -= carry11 << 21; + + s0 += s12 * 666643; + s1 += s12 * 470296; + s2 += s12 * 654183; + s3 -= s12 * 997805; + s4 += s12 * 136657; + s5 -= s12 * 683901; + // s12 = 0; + + carry0 = s0 >> 21; + s1 += carry0; + s0 -= carry0 << 21; + carry1 = s1 >> 21; + s2 += carry1; + s1 -= carry1 << 21; + carry2 = s2 >> 21; + s3 += carry2; + s2 -= carry2 << 21; + carry3 = s3 >> 21; + s4 += carry3; + s3 -= carry3 << 21; + carry4 = s4 >> 21; + s5 += carry4; + s4 -= carry4 << 21; + carry5 = s5 >> 21; + s6 += carry5; + s5 -= carry5 << 21; + carry6 = s6 >> 21; + s7 += carry6; + s6 -= carry6 << 21; + carry7 = s7 >> 21; + s8 += carry7; + s7 -= carry7 << 21; + carry8 = s8 >> 21; + s9 += carry8; + s8 -= carry8 << 21; + carry9 = s9 >> 21; + s10 += carry9; + s9 -= carry9 << 21; + carry10 = s10 >> 21; + s11 += carry10; + s10 -= carry10 << 21; + + s[0] = (byte) s0; + s[1] = (byte) (s0 >> 8); + s[2] = (byte) ((s0 >> 16) | (s1 << 5)); + s[3] = (byte) (s1 >> 3); + s[4] = (byte) (s1 >> 11); + s[5] = (byte) ((s1 >> 19) | (s2 << 2)); + s[6] = (byte) (s2 >> 6); + s[7] = (byte) ((s2 >> 14) | (s3 << 7)); + s[8] = (byte) (s3 >> 1); + s[9] = (byte) (s3 >> 9); + s[10] = (byte) ((s3 >> 17) | (s4 << 4)); + s[11] = (byte) (s4 >> 4); + s[12] = (byte) (s4 >> 12); + s[13] = (byte) ((s4 >> 20) | (s5 << 1)); + s[14] = (byte) (s5 >> 7); + s[15] = (byte) ((s5 >> 15) | (s6 << 6)); + s[16] = (byte) (s6 >> 2); + s[17] = (byte) (s6 >> 10); + s[18] = (byte) ((s6 >> 18) | (s7 << 3)); + s[19] = (byte) (s7 >> 5); + s[20] = (byte) (s7 >> 13); + s[21] = (byte) s8; + s[22] = (byte) (s8 >> 8); + s[23] = (byte) ((s8 >> 16) | (s9 << 5)); + s[24] = (byte) (s9 >> 3); + s[25] = (byte) (s9 >> 11); + s[26] = (byte) ((s9 >> 19) | (s10 << 2)); + s[27] = (byte) (s10 >> 6); + s[28] = (byte) ((s10 >> 14) | (s11 << 7)); + s[29] = (byte) (s11 >> 1); + s[30] = (byte) (s11 >> 9); + s[31] = (byte) (s11 >> 17); + } + + // The order of the generator as unsigned bytes in little endian order. + // (2^252 + 0x14def9dea2f79cd65812631a5cf5d3ed, cf. RFC 7748) + private static final byte[] GROUP_ORDER = { + (byte) 0xed, (byte) 0xd3, (byte) 0xf5, (byte) 0x5c, + (byte) 0x1a, (byte) 0x63, (byte) 0x12, (byte) 0x58, + (byte) 0xd6, (byte) 0x9c, (byte) 0xf7, (byte) 0xa2, + (byte) 0xde, (byte) 0xf9, (byte) 0xde, (byte) 0x14, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, + (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x10}; + + // Checks whether s represents an integer smaller than the order of the group. + // This is needed to ensure that EdDSA signatures are non-malleable, as failing to check + // the range of S allows to modify signatures (cf. RFC 8032, Section 5.2.7 and Section 8.4.) + // @param s an integer in little-endian order. + private static boolean isSmallerThanGroupOrder(byte[] s) { + for (int j = Field25519.FIELD_LEN - 1; j >= 0; j--) { + // compare unsigned bytes + int a = s[j] & 0xff; + int b = GROUP_ORDER[j] & 0xff; + if (a != b) { + return a < b; + } + } + return false; + } + + /** + * Returns true if the EdDSA {@code signature} with {@code message}, can be verified with + * {@code publicKey}. + */ + public static boolean verify(final byte[] message, final byte[] signature, + final byte[] publicKey) { + try { + if (signature.length != SIGNATURE_LEN) { + return false; + } + if (publicKey.length != PUBLIC_KEY_LEN) { + return false; + } + byte[] s = Arrays.copyOfRange(signature, Field25519.FIELD_LEN, SIGNATURE_LEN); + if (!isSmallerThanGroupOrder(s)) { + return false; + } + MessageDigest digest = MessageDigest.getInstance("SHA-512"); + digest.update(signature, 0, Field25519.FIELD_LEN); + digest.update(publicKey); + digest.update(message); + byte[] h = digest.digest(); + reduce(h); + + XYZT negPublicKey = XYZT.fromBytesNegateVarTime(publicKey); + XYZ xyz = doubleScalarMultVarTime(h, negPublicKey, s); + byte[] expectedR = xyz.toBytes(); + for (int i = 0; i < Field25519.FIELD_LEN; i++) { + if (expectedR[i] != signature[i]) { + return false; + } + } + return true; + } catch (final GeneralSecurityException ignored) { + return false; + } + } +} diff --git a/app/src/main/java/com/wireguard/crypto/Key.java b/app/src/main/java/com/wireguard/crypto/Key.java new file mode 100644 index 0000000..9e25e60 --- /dev/null +++ b/app/src/main/java/com/wireguard/crypto/Key.java @@ -0,0 +1,288 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.crypto; + +import com.wireguard.crypto.KeyFormatException.Type; + +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Arrays; + +/** + * Represents a WireGuard public or private key. This class uses specialized constant-time base64 + * and hexadecimal codec implementations that resist side-channel attacks. + *

+ * Instances of this class are immutable. + */ +@SuppressWarnings("MagicNumber") +public final class Key { + private final byte[] key; + + /** + * Constructs an object encapsulating the supplied key. + * + * @param key an array of bytes containing a binary key. Callers of this constructor are + * responsible for ensuring that the array is of the correct length. + */ + private Key(final byte[] key) { + // Defensively copy to ensure immutability. + this.key = Arrays.copyOf(key, key.length); + } + + /** + * Decodes a single 4-character base64 chunk to an integer in constant time. + * + * @param src an array of at least 4 characters in base64 format + * @param srcOffset the offset of the beginning of the chunk in {@code src} + * @return the decoded 3-byte integer, or some arbitrary integer value if the input was not + * valid base64 + */ + private static int decodeBase64(final char[] src, final int srcOffset) { + int val = 0; + for (int i = 0; i < 4; ++i) { + final char c = src[i + srcOffset]; + val |= (-1 + + ((((('A' - 1) - c) & (c - ('Z' + 1))) >>> 8) & (c - 64)) + + ((((('a' - 1) - c) & (c - ('z' + 1))) >>> 8) & (c - 70)) + + ((((('0' - 1) - c) & (c - ('9' + 1))) >>> 8) & (c + 5)) + + ((((('+' - 1) - c) & (c - ('+' + 1))) >>> 8) & 63) + + ((((('/' - 1) - c) & (c - ('/' + 1))) >>> 8) & 64) + ) << (18 - 6 * i); + } + return val; + } + + /** + * Encodes a single 4-character base64 chunk from 3 consecutive bytes in constant time. + * + * @param src an array of at least 3 bytes + * @param srcOffset the offset of the beginning of the chunk in {@code src} + * @param dest an array of at least 4 characters + * @param destOffset the offset of the beginning of the chunk in {@code dest} + */ + private static void encodeBase64(final byte[] src, final int srcOffset, + final char[] dest, final int destOffset) { + final byte[] input = { + (byte) ((src[srcOffset] >>> 2) & 63), + (byte) ((src[srcOffset] << 4 | ((src[1 + srcOffset] & 0xff) >>> 4)) & 63), + (byte) ((src[1 + srcOffset] << 2 | ((src[2 + srcOffset] & 0xff) >>> 6)) & 63), + (byte) ((src[2 + srcOffset]) & 63), + }; + for (int i = 0; i < 4; ++i) { + dest[i + destOffset] = (char) (input[i] + 'A' + + (((25 - input[i]) >>> 8) & 6) + - (((51 - input[i]) >>> 8) & 75) + - (((61 - input[i]) >>> 8) & 15) + + (((62 - input[i]) >>> 8) & 3)); + } + } + + /** + * Decodes a WireGuard public or private key from its base64 string representation. This + * function throws a {@link KeyFormatException} if the source string is not well-formed. + * + * @param str the base64 string representation of a WireGuard key + * @return the decoded key encapsulated in an immutable container + */ + public static Key fromBase64(final String str) throws KeyFormatException { + final char[] input = str.toCharArray(); + if (input.length != Format.BASE64.length || input[Format.BASE64.length - 1] != '=') + throw new KeyFormatException(Format.BASE64, Type.LENGTH); + final byte[] key = new byte[Format.BINARY.length]; + int i; + int ret = 0; + for (i = 0; i < key.length / 3; ++i) { + final int val = decodeBase64(input, i * 4); + ret |= val >>> 31; + key[i * 3] = (byte) ((val >>> 16) & 0xff); + key[i * 3 + 1] = (byte) ((val >>> 8) & 0xff); + key[i * 3 + 2] = (byte) (val & 0xff); + } + final char[] endSegment = { + input[i * 4], + input[i * 4 + 1], + input[i * 4 + 2], + 'A', + }; + final int val = decodeBase64(endSegment, 0); + ret |= (val >>> 31) | (val & 0xff); + key[i * 3] = (byte) ((val >>> 16) & 0xff); + key[i * 3 + 1] = (byte) ((val >>> 8) & 0xff); + + if (ret != 0) + throw new KeyFormatException(Format.BASE64, Type.CONTENTS); + return new Key(key); + } + + /** + * Wraps a WireGuard public or private key in an immutable container. This function throws a + * {@link KeyFormatException} if the source data is not the correct length. + * + * @param bytes an array of bytes containing a WireGuard key in binary format + * @return the key encapsulated in an immutable container + */ + public static Key fromBytes(final byte[] bytes) throws KeyFormatException { + if (bytes.length != Format.BINARY.length) + throw new KeyFormatException(Format.BINARY, Type.LENGTH); + return new Key(bytes); + } + + /** + * Decodes a WireGuard public or private key from its hexadecimal string representation. This + * function throws a {@link KeyFormatException} if the source string is not well-formed. + * + * @param str the hexadecimal string representation of a WireGuard key + * @return the decoded key encapsulated in an immutable container + */ + public static Key fromHex(final String str) throws KeyFormatException { + final char[] input = str.toCharArray(); + if (input.length != Format.HEX.length) + throw new KeyFormatException(Format.HEX, Type.LENGTH); + final byte[] key = new byte[Format.BINARY.length]; + int ret = 0; + for (int i = 0; i < key.length; ++i) { + int c; + int cNum; + int cNum0; + int cAlpha; + int cAlpha0; + int cVal; + final int cAcc; + + c = input[i * 2]; + cNum = c ^ 48; + cNum0 = ((cNum - 10) >>> 8) & 0xff; + cAlpha = (c & ~32) - 55; + cAlpha0 = (((cAlpha - 10) ^ (cAlpha - 16)) >>> 8) & 0xff; + ret |= ((cNum0 | cAlpha0) - 1) >>> 8; + cVal = (cNum0 & cNum) | (cAlpha0 & cAlpha); + cAcc = cVal * 16; + + c = input[i * 2 + 1]; + cNum = c ^ 48; + cNum0 = ((cNum - 10) >>> 8) & 0xff; + cAlpha = (c & ~32) - 55; + cAlpha0 = (((cAlpha - 10) ^ (cAlpha - 16)) >>> 8) & 0xff; + ret |= ((cNum0 | cAlpha0) - 1) >>> 8; + cVal = (cNum0 & cNum) | (cAlpha0 & cAlpha); + key[i] = (byte) (cAcc | cVal); + } + if (ret != 0) + throw new KeyFormatException(Format.HEX, Type.CONTENTS); + return new Key(key); + } + + /** + * Generates a private key using the system's {@link SecureRandom} number generator. + * + * @return a well-formed random private key + */ + static Key generatePrivateKey() { + final SecureRandom secureRandom = new SecureRandom(); + final byte[] privateKey = new byte[Format.BINARY.getLength()]; + secureRandom.nextBytes(privateKey); + privateKey[0] &= 248; + privateKey[31] &= 127; + privateKey[31] |= 64; + return new Key(privateKey); + } + + /** + * Generates a public key from an existing private key. + * + * @param privateKey a private key + * @return a well-formed public key that corresponds to the supplied private key + */ + static Key generatePublicKey(final Key privateKey) { + final byte[] publicKey = new byte[Format.BINARY.getLength()]; + Curve25519.eval(publicKey, 0, privateKey.getBytes(), null); + return new Key(publicKey); + } + + @Override + public boolean equals(final Object obj) { + if (obj == this) + return true; + if (obj == null || obj.getClass() != getClass()) + return false; + final Key other = (Key) obj; + return MessageDigest.isEqual(key, other.key); + } + + /** + * Returns the key as an array of bytes. + * + * @return an array of bytes containing the raw binary key + */ + public byte[] getBytes() { + // Defensively copy to ensure immutability. + return Arrays.copyOf(key, key.length); + } + + @Override + public int hashCode() { + int ret = 0; + for (int i = 0; i < key.length / 4; ++i) + ret ^= (key[i * 4 + 0] >> 0) + (key[i * 4 + 1] >> 8) + (key[i * 4 + 2] >> 16) + (key[i * 4 + 3] >> 24); + return ret; + } + + /** + * Encodes the key to base64. + * + * @return a string containing the encoded key + */ + public String toBase64() { + final char[] output = new char[Format.BASE64.length]; + int i; + for (i = 0; i < key.length / 3; ++i) + encodeBase64(key, i * 3, output, i * 4); + final byte[] endSegment = { + key[i * 3], + key[i * 3 + 1], + 0, + }; + encodeBase64(endSegment, 0, output, i * 4); + output[Format.BASE64.length - 1] = '='; + return new String(output); + } + + /** + * Encodes the key to hexadecimal ASCII characters. + * + * @return a string containing the encoded key + */ + public String toHex() { + final char[] output = new char[Format.HEX.length]; + for (int i = 0; i < key.length; ++i) { + output[i * 2] = (char) (87 + (key[i] >> 4 & 0xf) + + ((((key[i] >> 4 & 0xf) - 10) >> 8) & ~38)); + output[i * 2 + 1] = (char) (87 + (key[i] & 0xf) + + ((((key[i] & 0xf) - 10) >> 8) & ~38)); + } + return new String(output); + } + + /** + * The supported formats for encoding a WireGuard key. + */ + public enum Format { + BASE64(44), + BINARY(32), + HEX(64); + + private final int length; + + Format(final int length) { + this.length = length; + } + + public int getLength() { + return length; + } + } + +} diff --git a/app/src/main/java/com/wireguard/crypto/KeyFormatException.java b/app/src/main/java/com/wireguard/crypto/KeyFormatException.java new file mode 100644 index 0000000..5818b4d --- /dev/null +++ b/app/src/main/java/com/wireguard/crypto/KeyFormatException.java @@ -0,0 +1,34 @@ +/* + * Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.crypto; + +/** + * An exception thrown when attempting to parse an invalid key (too short, too long, or byte + * data inappropriate for the format). The format being parsed can be accessed with the + * {@link #getFormat} method. + */ +public final class KeyFormatException extends Exception { + private final Key.Format format; + private final Type type; + + KeyFormatException(final Key.Format format, final Type type) { + this.format = format; + this.type = type; + } + + public Key.Format getFormat() { + return format; + } + + public Type getType() { + return type; + } + + public enum Type { + CONTENTS, + LENGTH + } +} diff --git a/app/src/main/java/com/wireguard/crypto/KeyPair.java b/app/src/main/java/com/wireguard/crypto/KeyPair.java new file mode 100644 index 0000000..f8238e9 --- /dev/null +++ b/app/src/main/java/com/wireguard/crypto/KeyPair.java @@ -0,0 +1,51 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.crypto; + +/** + * Represents a Curve25519 key pair as used by WireGuard. + *

+ * Instances of this class are immutable. + */ +public class KeyPair { + private final Key privateKey; + private final Key publicKey; + + /** + * Creates a key pair using a newly-generated private key. + */ + public KeyPair() { + this(Key.generatePrivateKey()); + } + + /** + * Creates a key pair using an existing private key. + * + * @param privateKey a private key, used to derive the public key + */ + public KeyPair(final Key privateKey) { + this.privateKey = privateKey; + publicKey = Key.generatePublicKey(privateKey); + } + + /** + * Returns the private key from the key pair. + * + * @return the private key + */ + public Key getPrivateKey() { + return privateKey; + } + + /** + * Returns the public key from the key pair. + * + * @return the public key + */ + public Key getPublicKey() { + return publicKey; + } +} 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 0000000..bc2e4b2 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt @@ -0,0 +1,182 @@ +package io.nekohasekai.sagernet + +const val CONNECTION_TEST_URL = "http://cp.cloudflare.com/" + +object Key { + + const val DB_PUBLIC = "configuration.db" + const val DB_PROFILE = "sager_net.db" + + const val APP_EXPERT = "isExpert" + const val APP_THEME = "appTheme" + const val NIGHT_THEME = "nightTheme" + const val SERVICE_MODE = "serviceMode" + const val MODE_VPN = "vpn" + const val MODE_PROXY = "proxy" + + const val REMOTE_DNS = "remoteDns" + const val DIRECT_DNS = "directDns" + const val DIRECT_DNS_USE_SYSTEM = "directDnsUseSystem" + const val ENABLE_DNS_ROUTING = "enableDnsRouting" + const val ENABLE_FAKEDNS = "enableFakeDns" + const val DNS_NETWORK = "dnsNetwork" + + const val IPV6_MODE = "ipv6Mode" + + const val PROXY_APPS = "proxyApps" + const val BYPASS_MODE = "bypassMode" + const val INDIVIDUAL = "individual" + const val METERED_NETWORK = "meteredNetwork" + + const val DOMAIN_STRATEGY = "domainStrategy" + const val TRAFFIC_SNIFFING = "trafficSniffing" + const val RESOLVE_DESTINATION = "resolveDestination" + + const val BYPASS_LAN = "bypassLan" + const val BYPASS_LAN_IN_CORE_ONLY = "bypassLanInCoreOnly" + + const val MIXED_PORT = "mixedPort" + const val ALLOW_ACCESS = "allowAccess" + const val SPEED_INTERVAL = "speedInterval" + const val SHOW_DIRECT_SPEED = "showDirectSpeed" + const val LOCAL_DNS_PORT = "portLocalDns" + + const val APPEND_HTTP_PROXY = "appendHttpProxy" + + const val REQUIRE_TRANSPROXY = "requireTransproxy" + const val TRANSPROXY_MODE = "transproxyMode" + const val TRANSPROXY_PORT = "transproxyPort" + const val CONNECTION_TEST_URL = "connectionTestURL" + + const val TCP_KEEP_ALIVE_INTERVAL = "tcpKeepAliveInterval" + const val RULES_PROVIDER = "rulesProvider" + const val ENABLE_LOG = "enableLog" + const val LOG_BUF_SIZE = "logBufSize" + const val MTU = "mtu" + const val ALWAYS_SHOW_ADDRESS = "alwaysShowAddress" + + // Protocol Settings + const val MUX_PROTOCOLS = "mux" + const val MUX_CONCURRENCY = "muxConcurrency" + + const val ACQUIRE_WAKE_LOCK = "acquireWakeLock" + const val SHOW_BOTTOM_BAR = "showBottomBar" + + const val TUN_IMPLEMENTATION = "tunImplementation" + const val PROFILE_TRAFFIC_STATISTICS = "profileTrafficStatistics" + + const val PROFILE_DIRTY = "profileDirty" + const val PROFILE_ID = "profileId" + const val PROFILE_NAME = "profileName" + const val PROFILE_GROUP = "profileGroup" + const val PROFILE_CURRENT = "profileCurrent" + + const val SERVER_ADDRESS = "serverAddress" + const val SERVER_PORT = "serverPort" + const val SERVER_USERNAME = "serverUsername" + const val SERVER_PASSWORD = "serverPassword" + const val SERVER_METHOD = "serverMethod" + const val SERVER_PLUGIN = "serverPlugin" + const val SERVER_PLUGIN_CONFIGURE = "serverPluginConfigure" + const val SERVER_PASSWORD1 = "serverPassword1" + + const val SERVER_PROTOCOL = "serverProtocol" + const val SERVER_OBFS = "serverObfs" + + const val SERVER_SECURITY = "serverSecurity" + const val SERVER_NETWORK = "serverNetwork" + const val SERVER_HOST = "serverHost" + const val SERVER_PATH = "serverPath" + const val SERVER_SNI = "serverSNI" + const val SERVER_ENCRYPTION = "serverEncryption" + const val SERVER_ALPN = "serverALPN" + const val SERVER_CERTIFICATES = "serverCertificates" + const val SERVER_CONFIG = "serverConfig" + + const val SERVER_SECURITY_CATEGORY = "serverSecurityCategory" + const val SERVER_WS_CATEGORY = "serverWsCategory" + const val SERVER_SS_CATEGORY = "serverSsCategory" + const val SERVER_HEADERS = "serverHeaders" + const val SERVER_ALLOW_INSECURE = "serverAllowInsecure" + + const val SERVER_AUTH_TYPE = "serverAuthType" + const val SERVER_UPLOAD_SPEED = "serverUploadSpeed" + const val SERVER_DOWNLOAD_SPEED = "serverDownloadSpeed" + const val SERVER_STREAM_RECEIVE_WINDOW = "serverStreamReceiveWindow" + const val SERVER_CONNECTION_RECEIVE_WINDOW = "serverConnectionReceiveWindow" + const val SERVER_MTU = "serverMTU" + const val SERVER_DISABLE_MTU_DISCOVERY = "serverDisableMtuDiscovery" + const val SERVER_HOP_INTERVAL = "hopInterval" + + const val SERVER_PRIVATE_KEY = "serverPrivateKey" + const val SERVER_LOCAL_ADDRESS = "serverLocalAddress" + const val SERVER_INSECURE_CONCURRENCY = "serverInsecureConcurrency" + + const val SERVER_UDP_RELAY_MODE = "serverUDPRelayMode" + const val SERVER_CONGESTION_CONTROLLER = "serverCongestionController" + const val SERVER_DISABLE_SNI = "serverDisableSNI" + const val SERVER_REDUCE_RTT = "serverReduceRTT" + const val SERVER_FAST_CONNECT = "serverFastConnect" + + const val ROUTE_NAME = "routeName" + const val ROUTE_DOMAIN = "routeDomain" + const val ROUTE_IP = "routeIP" + const val ROUTE_PORT = "routePort" + const val ROUTE_SOURCE_PORT = "routeSourcePort" + const val ROUTE_NETWORK = "routeNetwork" + const val ROUTE_SOURCE = "routeSource" + const val ROUTE_PROTOCOL = "routeProtocol" + const val ROUTE_OUTBOUND = "routeOutbound" + const val ROUTE_OUTBOUND_RULE = "routeOutboundRule" + const val ROUTE_PACKAGES = "routePackages" + + const val GROUP_NAME = "groupName" + const val GROUP_TYPE = "groupType" + const val GROUP_ORDER = "groupOrder" + + const val GROUP_SUBSCRIPTION = "groupSubscription" + const val SUBSCRIPTION_LINK = "subscriptionLink" + const val SUBSCRIPTION_FORCE_RESOLVE = "subscriptionForceResolve" + const val SUBSCRIPTION_DEDUPLICATION = "subscriptionDeduplication" + const val SUBSCRIPTION_UPDATE = "subscriptionUpdate" + const val SUBSCRIPTION_UPDATE_WHEN_CONNECTED_ONLY = "subscriptionUpdateWhenConnectedOnly" + const val SUBSCRIPTION_USER_AGENT = "subscriptionUserAgent" + const val SUBSCRIPTION_AUTO_UPDATE = "subscriptionAutoUpdate" + const val SUBSCRIPTION_AUTO_UPDATE_DELAY = "subscriptionAutoUpdateDelay" + + // + + const val NEKO_PLUGIN_MANAGED = "nekoPlugins" + const val APP_TLS_VERSION = "appTLSVersion" + const val ENABLE_CLASH_API = "enableClashAPI" +} + +object TunImplementation { + const val GVISOR = 0 + const val SYSTEM = 1 +} + +object IPv6Mode { + const val DISABLE = 0 + const val ENABLE = 1 + const val PREFER = 2 + const val ONLY = 3 +} + +object GroupType { + const val BASIC = 0 + const val SUBSCRIPTION = 1 +} + +object GroupOrder { + const val ORIGIN = 0 + const val BY_NAME = 1 + const val BY_DELAY = 2 +} + +object Action { + const val SERVICE = "io.nekohasekai.sagernet.SERVICE" + const val CLOSE = "io.nekohasekai.sagernet.CLOSE" + const val RELOAD = "io.nekohasekai.sagernet.RELOAD" + const val SWITCH_WAKE_LOCK = "io.nekohasekai.sagernet.SWITCH_WAKELOCK" +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/QuickToggleShortcut.kt b/app/src/main/java/io/nekohasekai/sagernet/QuickToggleShortcut.kt new file mode 100644 index 0000000..2cd9d7d --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/QuickToggleShortcut.kt @@ -0,0 +1,88 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 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 + +import android.app.Activity +import android.content.Intent +import android.content.pm.ShortcutManager +import android.os.Build +import android.os.Bundle +import androidx.core.content.getSystemService +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat +import io.nekohasekai.sagernet.aidl.ISagerNetService +import io.nekohasekai.sagernet.bg.BaseService +import io.nekohasekai.sagernet.bg.SagerConnection +import io.nekohasekai.sagernet.database.DataStore + +@Suppress("DEPRECATION") +class QuickToggleShortcut : Activity(), SagerConnection.Callback { + private val connection = SagerConnection() + private var profileId = -1L + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (intent.action == Intent.ACTION_CREATE_SHORTCUT) { + setResult(RESULT_OK, ShortcutManagerCompat.createShortcutResultIntent(this, + ShortcutInfoCompat.Builder(this, "toggle") + .setIntent(Intent(this, + QuickToggleShortcut::class.java).setAction(Intent.ACTION_MAIN)) + .setIcon(IconCompat.createWithResource(this, + R.drawable.ic_qu_shadowsocks_launcher)) + .setShortLabel(getString(R.string.quick_toggle)) + .build())) + finish() + } else { + profileId = intent.getLongExtra("profile", -1L) + connection.connect(this, this) + if (Build.VERSION.SDK_INT >= 25) { + getSystemService()!!.reportShortcutUsed(if (profileId >= 0) "shortcut-profile-$profileId" else "toggle") + } + } + } + + override fun onServiceConnected(service: ISagerNetService) { + val state = BaseService.State.values()[service.state] + when { + state.canStop -> { + if (profileId == DataStore.selectedProxy || profileId == -1L) { + SagerNet.stopService() + } else { + DataStore.selectedProxy = profileId + SagerNet.reloadService() + } + } + state == BaseService.State.Stopped -> { + if (profileId >= 0L) DataStore.selectedProxy = profileId + SagerNet.startService() + } + } + finish() + } + + override fun stateChanged(state: BaseService.State, profileName: String?, msg: String?) {} + + override fun onDestroy() { + connection.disconnect(this) + super.onDestroy() + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt b/app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt new file mode 100644 index 0000000..62a1d12 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt @@ -0,0 +1,283 @@ +package io.nekohasekai.sagernet + +import android.annotation.SuppressLint +import android.app.* +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.net.ConnectivityManager +import android.net.Network +import android.os.Build +import android.os.PowerManager +import android.os.StrictMode +import android.os.UserManager +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import go.Seq +import io.nekohasekai.sagernet.bg.SagerConnection +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.ktx.Logs +import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher +import io.nekohasekai.sagernet.ui.MainActivity +import io.nekohasekai.sagernet.utils.* +import kotlinx.coroutines.DEBUG_PROPERTY_NAME +import kotlinx.coroutines.DEBUG_PROPERTY_VALUE_ON +import libcore.BoxPlatformInterface +import libcore.Libcore +import libcore.NB4AInterface +import moe.matsuri.nb4a.utils.JavaUtil +import moe.matsuri.nb4a.utils.cleanWebview +import java.net.InetSocketAddress +import androidx.work.Configuration as WorkConfiguration + +class SagerNet : Application(), + BoxPlatformInterface, + WorkConfiguration.Provider, NB4AInterface { + + override fun attachBaseContext(base: Context) { + super.attachBaseContext(base) + + application = this + } + + val externalAssets by lazy { getExternalFilesDir(null) ?: filesDir } + val process = JavaUtil.getProcessName() + val isMainProcess = process == BuildConfig.APPLICATION_ID + val isBgProcess = process.endsWith(":bg") + + override fun onCreate() { + super.onCreate() + + System.setProperty(DEBUG_PROPERTY_NAME, DEBUG_PROPERTY_VALUE_ON) + Thread.setDefaultUncaughtExceptionHandler(CrashHandler) + + if (isMainProcess || isBgProcess) { + // fix multi process issue in Android 9+ + JavaUtil.handleWebviewDir(this) + + runOnDefaultDispatcher { + PackageCache.register() + cleanWebview() + } + } + + Seq.setContext(this) + updateNotificationChannels() + + // nb4a: init core + externalAssets.mkdirs() + Libcore.initCore( + process, + cacheDir.absolutePath + "/", + filesDir.absolutePath + "/", + externalAssets.absolutePath + "/", + DataStore.logBufSize, + DataStore.enableLog, + this + ) + + // libbox: platform interface + Libcore.setBoxPlatformInterface(this) + + if (isMainProcess) { + Theme.apply(this) + Theme.applyNightTheme() + runOnDefaultDispatcher { + DefaultNetworkListener.start(this) { + underlyingNetwork = it + } + } + } + + if (BuildConfig.DEBUG) StrictMode.setVmPolicy( + StrictMode.VmPolicy.Builder() + .detectLeakedSqlLiteObjects() + .detectLeakedClosableObjects() + .detectLeakedRegistrationObjects() + .penaltyLog() + .build() + ) + } + + fun getPackageInfo(packageName: String) = packageManager.getPackageInfo( + packageName, if (Build.VERSION.SDK_INT >= 28) PackageManager.GET_SIGNING_CERTIFICATES + else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES + )!! + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + updateNotificationChannels() + } + + override fun getWorkManagerConfiguration(): WorkConfiguration { + return WorkConfiguration.Builder() + .setDefaultProcessName("${BuildConfig.APPLICATION_ID}:bg") + .build() + } + + override fun onTrimMemory(level: Int) { + super.onTrimMemory(level) + + Libcore.forceGc() + } + + @SuppressLint("InlinedApi") + companion object { + + lateinit var application: SagerNet + + val isTv by lazy { + uiMode.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION + } + + // /data/user_de available when not unlocked + val deviceStorage by lazy { + if (Build.VERSION.SDK_INT < 24) application else DeviceStorageApp(application) + } + + val configureIntent: (Context) -> PendingIntent by lazy { + { + PendingIntent.getActivity( + it, + 0, + Intent( + application, MainActivity::class.java + ).setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 + ) + } + } + val activity by lazy { application.getSystemService()!! } + 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 uiMode by lazy { application.getSystemService()!! } + val power by lazy { application.getSystemService()!! } + + val packageInfo: PackageInfo by lazy { application.getPackageInfo(application.packageName) } + + fun getClipboardText(): String { + return clipboard.primaryClip?.takeIf { it.itemCount > 0 } + ?.getItemAt(0)?.text?.toString() ?: "" + } + + fun trySetPrimaryClip(clip: String) = try { + clipboard.setPrimaryClip(ClipData.newPlainText(null, clip)) + true + } catch (e: RuntimeException) { + Logs.w(e) + false + } + + fun updateNotificationChannels() { + if (Build.VERSION.SDK_INT >= 26) @RequiresApi(26) { + notification.createNotificationChannels( + listOf( + NotificationChannel( + "service-vpn", + application.getText(R.string.service_vpn), + if (Build.VERSION.SDK_INT >= 28) NotificationManager.IMPORTANCE_MIN + else NotificationManager.IMPORTANCE_LOW + ), // #1355 + NotificationChannel( + "service-proxy", + application.getText(R.string.service_proxy), + NotificationManager.IMPORTANCE_LOW + ), NotificationChannel( + "service-subscription", + application.getText(R.string.service_subscription), + NotificationManager.IMPORTANCE_DEFAULT + ) + ) + ) + } + } + + fun startService() = ContextCompat.startForegroundService( + application, Intent(application, SagerConnection.serviceClass) + ) + + fun reloadService() = + application.sendBroadcast(Intent(Action.RELOAD).setPackage(application.packageName)) + + fun stopService() = + application.sendBroadcast(Intent(Action.CLOSE).setPackage(application.packageName)) + + var underlyingNetwork: Network? = null + + } + + + // libbox interface + + override fun autoDetectInterfaceControl(fd: Int) { + DataStore.vpnService?.protect(fd) + } + + override fun openTun(singTunOptionsJson: String, tunPlatformOptionsJson: String): Long { + if (DataStore.vpnService == null) { + throw Exception("no VpnService") + } + return DataStore.vpnService!!.startVpn(singTunOptionsJson, tunPlatformOptionsJson).toLong() + } + + override fun useProcFS(): Boolean { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.Q + } + + @RequiresApi(Build.VERSION_CODES.Q) + override fun findConnectionOwner( + ipProto: Int, srcIp: String, srcPort: Int, destIp: String, destPort: Int + ): Int { + return connectivity.getConnectionOwnerUid( + ipProto, InetSocketAddress(srcIp, srcPort), InetSocketAddress(destIp, destPort) + ) + } + + override fun packageNameByUid(uid: Int): String { + PackageCache.awaitLoadSync() + + if (uid <= 1000L) { + return "android" + } + + val packageNames = PackageCache.uidMap[uid] + if (!packageNames.isNullOrEmpty()) for (packageName in packageNames) { + return packageName + } + + error("unknown uid $uid") + } + + override fun uidByPackageName(packageName: String): Int { + PackageCache.awaitLoadSync() + return PackageCache[packageName] ?: 0 + } + + // nb4a interface + + override fun write(p: ByteArray): Long { + // NB4AGuiLogWriter + if (isBgProcess) { + runOnDefaultDispatcher { + DataStore.baseService?.data?.binder?.broadcast { + it.cbLogUpdate(String(p)) + } + } + } else { + DataStore.postLogListener?.let { it(String(p)) } + } + return p.size.toLong() + } + + override fun useOfficialAssets(): Boolean { + return DataStore.rulesProvider == 0 + } + +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/aidl/SpeedDisplayData.kt b/app/src/main/java/io/nekohasekai/sagernet/aidl/SpeedDisplayData.kt new file mode 100644 index 0000000..5cafbf3 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/aidl/SpeedDisplayData.kt @@ -0,0 +1,18 @@ +package io.nekohasekai.sagernet.aidl + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class SpeedDisplayData( + // Bytes per second + var txRateProxy: Long = 0L, + var rxRateProxy: Long = 0L, + var txRateDirect: Long = 0L, + var rxRateDirect: Long = 0L, + + // Bytes for the current session + // Outbound "bypass" usage is not counted + var txTotal: Long = 0L, + var rxTotal: Long = 0L, +) : Parcelable diff --git a/app/src/main/java/io/nekohasekai/sagernet/aidl/TrafficData.kt b/app/src/main/java/io/nekohasekai/sagernet/aidl/TrafficData.kt new file mode 100644 index 0000000..02c7943 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/aidl/TrafficData.kt @@ -0,0 +1,11 @@ +package io.nekohasekai.sagernet.aidl + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class TrafficData( + var id: Long = 0L, + var tx: Long = 0L, + var rx: Long = 0L, +) : Parcelable diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/AbstractInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/AbstractInstance.kt new file mode 100644 index 0000000..196e7e9 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/AbstractInstance.kt @@ -0,0 +1,9 @@ +package io.nekohasekai.sagernet.bg + +import java.io.Closeable + +interface AbstractInstance : Closeable { + + fun launch() + +} \ 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 0000000..cb63e25 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt @@ -0,0 +1,345 @@ +package io.nekohasekai.sagernet.bg + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.* +import android.widget.Toast +import io.nekohasekai.sagernet.Action +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.SagerNet +import io.nekohasekai.sagernet.aidl.ISagerNetService +import io.nekohasekai.sagernet.aidl.ISagerNetServiceCallback +import io.nekohasekai.sagernet.bg.proto.ProxyInstance +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.SagerDatabase +import io.nekohasekai.sagernet.ktx.* +import io.nekohasekai.sagernet.plugin.PluginManager +import io.nekohasekai.sagernet.utils.DefaultNetworkListener +import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import libcore.Libcore +import moe.matsuri.nb4a.Protocols +import java.net.UnknownHostException + +class BaseService { + + enum class State( + val canStop: Boolean = false, + val started: Boolean = false, + val connected: Boolean = false, + ) { + /** + * Idle state is only used by UI and will never be returned by BaseService. + */ + Idle, Connecting(true, true, false), Connected(true, true, true), Stopping, Stopped, + } + + interface ExpectedException + + class Data internal constructor(private val service: Interface) { + var state = State.Stopped + var proxy: ProxyInstance? = null + var notification: ServiceNotification? = null + + val receiver = broadcastReceiver { _, intent -> + when (intent.action) { + Intent.ACTION_SHUTDOWN -> service.persistStats() + Action.RELOAD -> service.forceLoad() + Action.SWITCH_WAKE_LOCK -> runOnDefaultDispatcher { service.switchWakeLock() } + else -> service.stopRunner() + } + } + var closeReceiverRegistered = false + + val binder = Binder(this) + var connectingJob: Job? = null + + fun changeState(s: State, msg: String? = null) { + if (state == s && msg == null) return + binder.stateChanged(s, msg) + state = s + } + } + + class Binder(private var data: Data? = null) : ISagerNetService.Stub(), CoroutineScope, + AutoCloseable { + private val callbacks = object : RemoteCallbackList() { + override fun onCallbackDied(callback: ISagerNetServiceCallback?, cookie: Any?) { + super.onCallbackDied(callback, cookie) + } + } + + override val coroutineContext = Dispatchers.Main.immediate + Job() + + override fun getState(): Int = (data?.state ?: State.Idle).ordinal + override fun getProfileName(): String = data?.proxy?.profile?.displayName() ?: "Idle" + + override fun registerCallback(cb: ISagerNetServiceCallback) { + callbacks.register(cb) + cb.updateWakeLockStatus(data?.proxy?.service?.wakeLock != null) + } + + val boardcastMutex = Mutex() + + suspend fun broadcast(work: (ISagerNetServiceCallback) -> Unit) { + boardcastMutex.withLock { + val count = callbacks.beginBroadcast() + try { + repeat(count) { + try { + work(callbacks.getBroadcastItem(it)) + } catch (_: RemoteException) { + } catch (_: Exception) { + } + } + } finally { + callbacks.finishBroadcast() + } + } + } + + override fun unregisterCallback(cb: ISagerNetServiceCallback) { + callbacks.unregister(cb) + } + + override fun urlTest(): Int { + if (data?.proxy?.box == null) { + error("core not started") + } + try { + return Libcore.urlTest( + data!!.proxy!!.box, DataStore.connectionTestURL, 3000 + ) + } catch (e: Exception) { + error(Protocols.genFriendlyMsg(e.readableMessage)) + } + } + + fun stateChanged(s: State, msg: String?) = launch { + val profileName = profileName + broadcast { it.stateChanged(s.ordinal, profileName, msg) } + } + + fun missingPlugin(pluginName: String) = launch { + val profileName = profileName + broadcast { it.missingPlugin(profileName, pluginName) } + } + + override fun close() { + callbacks.kill() + cancel() + data = null + } + } + + interface Interface { + val data: Data + val tag: String + fun createNotification(profileName: String): ServiceNotification + + fun onBind(intent: Intent): IBinder? = + if (intent.action == Action.SERVICE) data.binder else null + + fun forceLoad() { + if (DataStore.selectedProxy == 0L) { + stopRunner(false, (this as Context).getString(R.string.profile_empty)) + } + val s = data.state + when { + s == State.Stopped -> startRunner() + s.canStop -> stopRunner(true) + else -> Logs.w("Illegal state $s when invoking use") + } + } + + suspend fun startProcesses() { + data.proxy!!.launch() + } + + fun startRunner() { + this as Context + if (Build.VERSION.SDK_INT >= 26) startForegroundService(Intent(this, javaClass)) + else startService(Intent(this, javaClass)) + } + + fun killProcesses() { + data.proxy?.close() + wakeLock?.apply { + release() + wakeLock = null + } + runOnDefaultDispatcher { + DefaultNetworkListener.stop(this) + } + } + + fun stopRunner(restart: Boolean = false, msg: String? = null) { + DataStore.baseService = null + DataStore.vpnService = null + + if (data.state == State.Stopping) return + data.notification?.destroy() + data.notification = null + this as Service + + data.changeState(State.Stopping) + + runOnMainDispatcher { + data.connectingJob?.cancelAndJoin() // ensure stop connecting first + // we use a coroutineScope here to allow clean-up in parallel + coroutineScope { + killProcesses() + val data = data + if (data.closeReceiverRegistered) { + unregisterReceiver(data.receiver) + data.closeReceiverRegistered = false + } + data.proxy = null + } + + // change the state + data.changeState(State.Stopped, msg) + // stop the service if nothing has bound to it + if (restart) startRunner() else { + stopSelf() + } + } + } + + open fun persistStats() { + // TODO NEW save app stats? + } + + // networks + var upstreamInterfaceName: String? + + suspend fun preInit() { + DefaultNetworkListener.start(this) { + SagerNet.connectivity.getLinkProperties(it)?.also { link -> + val oldName = upstreamInterfaceName + if (oldName != link.interfaceName) { + upstreamInterfaceName = link.interfaceName + } + if (oldName != null && upstreamInterfaceName != null && oldName != upstreamInterfaceName) { + Logs.d("Network changed: $oldName -> $upstreamInterfaceName") + Libcore.resetAllConnections(true) + } + } + } + } + + var wakeLock: PowerManager.WakeLock? + fun acquireWakeLock() + suspend fun switchWakeLock() { + wakeLock?.apply { + release() + wakeLock = null + data.binder.broadcast { + it.updateWakeLockStatus(false) + } + } ?: apply { + acquireWakeLock() + data.binder.broadcast { + it.updateWakeLockStatus(true) + } + } + } + + suspend fun lateInit() { + wakeLock?.apply { + release() + wakeLock = null + } + + if (DataStore.acquireWakeLock) { + acquireWakeLock() + data.binder.broadcast { + it.updateWakeLockStatus(true) + } + } else { + data.binder.broadcast { + it.updateWakeLockStatus(false) + } + } + } + + fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + DataStore.baseService = this + + val data = data + if (data.state != State.Stopped) return Service.START_NOT_STICKY + val profile = SagerDatabase.proxyDao.getById(DataStore.selectedProxy) + this as Context + if (profile == null) { // gracefully shutdown: https://stackoverflow.com/q/47337857/2245107 + data.notification = createNotification("") + stopRunner(false, getString(R.string.profile_empty)) + return Service.START_NOT_STICKY + } + + val proxy = ProxyInstance(profile, this) + data.proxy = proxy + if (!data.closeReceiverRegistered) { + registerReceiver(data.receiver, IntentFilter().apply { + addAction(Action.RELOAD) + addAction(Intent.ACTION_SHUTDOWN) + addAction(Action.CLOSE) + addAction(Action.SWITCH_WAKE_LOCK) + }, "$packageName.SERVICE", null) + data.closeReceiverRegistered = true + } + + data.changeState(State.Connecting) + runOnMainDispatcher { + try { + data.notification = createNotification(profile.displayName()) + + Executable.killAll() // clean up old processes + preInit() + proxy.init() + DataStore.currentProfile = profile.id + + proxy.processes = GuardedProcessPool { + Logs.w(it) + stopRunner(false, it.readableMessage) + } + + startProcesses() + data.changeState(State.Connected) + + for ((type, routeName) in proxy.config.alerts) { + data.binder.broadcast { + it.routeAlert(type, routeName) + } + } + + lateInit() + } catch (_: CancellationException) { // if the job was cancelled, it is canceller's responsibility to call stopRunner + } catch (_: UnknownHostException) { + stopRunner(false, getString(R.string.invalid_server)) + } catch (e: PluginManager.PluginNotFoundException) { + Toast.makeText(this@Interface, e.readableMessage, Toast.LENGTH_SHORT).show() + Logs.d(e.readableMessage) + data.binder.missingPlugin(e.plugin) + stopRunner(false, null) + } catch (exc: Throwable) { + if (exc.javaClass.name.endsWith("proxyerror")) { + // error from golang + Logs.w(exc.readableMessage) + } else { + Logs.w(exc) + } + stopRunner( + false, "${getString(R.string.service_failed)}: ${exc.readableMessage}" + ) + } finally { + data.connectingJob = null + } + } + return Service.START_NOT_STICKY + } + } + +} \ 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 0000000..8e69f0b --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/Executable.kt @@ -0,0 +1,41 @@ +package io.nekohasekai.sagernet.bg + +import android.system.ErrnoException +import android.system.Os +import android.system.OsConstants +import android.text.TextUtils +import io.nekohasekai.sagernet.ktx.Logs +import java.io.File +import java.io.IOException + +object Executable { + private val EXECUTABLES = setOf( + "libtrojan.so", + "libtrojan-go.so", + "libnaive.so", + "libhysteria.so", + "libwg.so" + ) + + fun killAll(alsoKillBg: Boolean = false) { + for (process in File("/proc").listFiles { _, name -> TextUtils.isDigitsOnly(name) } + ?: return) { + val exe = File(try { + File(process, "cmdline").inputStream().bufferedReader().use { + it.readText() + } + } catch (_: IOException) { + continue + }.split(Character.MIN_VALUE, limit = 2).first()) + if (EXECUTABLES.contains(exe.name) || (alsoKillBg && exe.name.endsWith(":bg"))) try { + Os.kill(process.name.toInt(), OsConstants.SIGKILL) + Logs.w("SIGKILL ${exe.name} (${process.name}) succeed") + } catch (e: ErrnoException) { + if (e.errno != OsConstants.ESRCH) { + Logs.w("SIGKILL ${exe.absolutePath} (${process.name}) failed") + Logs.w(e) + } + } + } + } +} 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 0000000..db70000 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt @@ -0,0 +1,121 @@ +package io.nekohasekai.sagernet.bg + +import android.os.Build +import android.os.SystemClock +import android.system.ErrnoException +import android.system.Os +import android.system.OsConstants +import androidx.annotation.MainThread +import io.nekohasekai.sagernet.SagerNet +import io.nekohasekai.sagernet.ktx.Logs +import io.nekohasekai.sagernet.utils.Commandline +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import libcore.Libcore +import java.io.File +import java.io.IOException +import java.io.InputStream +import kotlin.concurrent.thread + +class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : CoroutineScope { + companion object { + private val pid by lazy { + Class.forName("java.lang.ProcessManager\$ProcessImpl").getDeclaredField("pid") + .apply { isAccessible = true } + } + } + + private inner class Guard( + private val cmd: List, + private val env: Map = mapOf() + ) { + private lateinit var process: Process + + private fun streamLogger(input: InputStream, logger: (String) -> Unit) = try { + input.bufferedReader().forEachLine(logger) + } catch (_: IOException) { + } // ignore + + fun start() { + process = ProcessBuilder(cmd).directory(SagerNet.application.noBackupFilesDir).apply { + environment().putAll(env) + }.start() + } + + @DelicateCoroutinesApi + suspend fun looper(onRestartCallback: (suspend () -> Unit)?) { + var running = true + val cmdName = File(cmd.first()).nameWithoutExtension + val exitChannel = Channel() + try { + while (true) { + thread(name = "stderr-$cmdName") { + streamLogger(process.errorStream) { Libcore.nekoLogPrintln("[$cmdName] $it") } + } + thread(name = "stdout-$cmdName") { + streamLogger(process.inputStream) { Libcore.nekoLogPrintln("[$cmdName] $it") } + // this thread also acts as a daemon thread for waitFor + runBlocking { exitChannel.send(process.waitFor()) } + } + val startTime = SystemClock.elapsedRealtime() + val exitCode = exitChannel.receive() + running = false + when { + SystemClock.elapsedRealtime() - startTime < 1000 -> throw IOException( + "$cmdName exits too fast (exit code: $exitCode)" + ) + exitCode == 128 + OsConstants.SIGKILL -> Logs.w("$cmdName was killed") + else -> Logs.w(IOException("$cmdName unexpectedly exits with code $exitCode")) + } + Logs.i("restart process: ${Commandline.toString(cmd)} (last exit code: $exitCode)") + start() + running = true + onRestartCallback?.invoke() + } + } catch (e: IOException) { + Logs.w("error occurred. stop guard: ${Commandline.toString(cmd)}") + GlobalScope.launch(Dispatchers.Main) { onFatal(e) } + } finally { + if (running) withContext(NonCancellable) { // clean-up cannot be cancelled + if (Build.VERSION.SDK_INT < 24) { + try { + Os.kill(pid.get(process) as Int, OsConstants.SIGTERM) + } catch (e: ErrnoException) { + if (e.errno != OsConstants.ESRCH) Logs.w(e) + } catch (e: ReflectiveOperationException) { + Logs.w(e) + } + if (withTimeoutOrNull(500) { exitChannel.receive() } != null) return@withContext + } + process.destroy() // kill the process + if (Build.VERSION.SDK_INT >= 26) { + if (withTimeoutOrNull(1000) { exitChannel.receive() } != null) return@withContext + process.destroyForcibly() // Force to kill the process if it's still alive + } + exitChannel.receive() + } // otherwise process already exited, nothing to be done + } + } + } + + override val coroutineContext = Dispatchers.Main.immediate + Job() + + @MainThread + fun start( + cmd: List, + env: MutableMap = mutableMapOf(), + onRestartCallback: (suspend () -> Unit)? = null + ) { + Logs.i("start process: ${Commandline.toString(cmd)}") + Guard(cmd, env).apply { + start() // if start fails, IOException will be thrown directly + launch { looper(onRestartCallback) } + } + } + + @MainThread + fun close(scope: CoroutineScope) { + cancel() + coroutineContext[Job]!!.also { job -> scope.launch { job.cancelAndJoin() } } + } +} 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 0000000..448ac26 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/ProxyService.kt @@ -0,0 +1,27 @@ +package io.nekohasekai.sagernet.bg + +import android.annotation.SuppressLint +import android.app.Service +import android.content.Intent +import android.os.PowerManager +import io.nekohasekai.sagernet.SagerNet + +class ProxyService : Service(), BaseService.Interface { + override val data = BaseService.Data(this) + override val tag: String get() = "SagerNetProxyService" + override fun createNotification(profileName: String): ServiceNotification = + ServiceNotification(this, profileName, "service-proxy", true) + + override var wakeLock: PowerManager.WakeLock? = null + override var upstreamInterfaceName: String? = null + + @SuppressLint("WakelockTimeout") + override fun acquireWakeLock() { + wakeLock = SagerNet.power.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "sagernet:proxy") + .apply { acquire() } + } + + override fun onBind(intent: Intent) = super.onBind(intent) + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = + super.onStartCommand(intent, flags, startId) +} 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 0000000..bfc0d24 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/SagerConnection.kt @@ -0,0 +1,167 @@ +package io.nekohasekai.sagernet.bg + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import android.os.RemoteException +import io.nekohasekai.sagernet.Action +import io.nekohasekai.sagernet.Key +import io.nekohasekai.sagernet.aidl.* +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.ktx.runOnMainDispatcher + +class SagerConnection(private var listenForDeath: Boolean = false) : ServiceConnection, + IBinder.DeathRecipient { + + companion object { + val serviceClass + get() = when (DataStore.serviceMode) { + Key.MODE_PROXY -> ProxyService::class + Key.MODE_VPN -> VpnService::class // Key.MODE_TRANS -> TransproxyService::class + else -> throw UnknownError() + }.java + } + + interface Callback { + // smaller ISagerNetServiceCallback + + fun cbSpeedUpdate(stats: SpeedDisplayData) {} + fun cbTrafficUpdate(data: TrafficData) {} + + fun stateChanged(state: BaseService.State, profileName: String?, msg: String?) + + fun missingPlugin(profileName: String, pluginName: String) {} + fun routeAlert(type: Int, routeName: String) {} + + fun onServiceConnected(service: ISagerNetService) + + /** + * Different from Android framework, this method will be called even when you call `detachService`. + */ + fun onServiceDisconnected() {} + fun onBinderDied() {} + } + + private var connectionActive = false + private var callbackRegistered = false + private var callback: Callback? = null + private val serviceCallback = object : ISagerNetServiceCallback.Stub() { + + override fun stateChanged(state: Int, profileName: String?, msg: String?) { + val s = BaseService.State.values()[state] + DataStore.serviceState = s + val callback = callback ?: return + runOnMainDispatcher { + callback.stateChanged(s, profileName, msg) + } + } + + override fun cbSpeedUpdate(stats: SpeedDisplayData) { + val callback = callback ?: return + runOnMainDispatcher { + callback.cbSpeedUpdate(stats) + } + } + + override fun cbTrafficUpdate(stats: TrafficData) { + val callback = callback ?: return + runOnMainDispatcher { + callback.cbTrafficUpdate(stats) + } + } + + override fun missingPlugin(profileName: String, pluginName: String) { + val callback = callback ?: return + runOnMainDispatcher { + callback.missingPlugin(profileName, pluginName) + } + } + + override fun routeAlert(type: Int, routeName: String) { + val callback = callback ?: return + runOnMainDispatcher { + callback.routeAlert(type, routeName) + } + } + + override fun updateWakeLockStatus(acquired: Boolean) { + } + + override fun cbLogUpdate(str: String?) { + DataStore.postLogListener?.let { + if (str != null) { + it(str) + } + } + } + + } + + private var binder: IBinder? = null + + var service: ISagerNetService? = null + + override fun onServiceConnected(name: ComponentName?, binder: IBinder) { + this.binder = binder + val service = ISagerNetService.Stub.asInterface(binder)!! + this.service = service + try { + if (listenForDeath) binder.linkToDeath(this, 0) + check(!callbackRegistered) + service.registerCallback(serviceCallback) + callbackRegistered = true + } catch (e: RemoteException) { + e.printStackTrace() + } + callback!!.onServiceConnected(service) + } + + override fun onServiceDisconnected(name: ComponentName?) { + unregisterCallback() + callback?.onServiceDisconnected() + service = null + binder = null + } + + override fun binderDied() { + service = null + callbackRegistered = false + callback?.also { runOnMainDispatcher { it.onBinderDied() } } + } + + private fun unregisterCallback() { + val service = service + if (service != null && callbackRegistered) try { + service.unregisterCallback(serviceCallback) + } catch (_: RemoteException) { + } + callbackRegistered = false + } + + fun connect(context: Context, callback: Callback) { + if (connectionActive) return + connectionActive = true + check(this.callback == null) + this.callback = callback + val intent = Intent(context, serviceClass).setAction(Action.SERVICE) + context.bindService(intent, this, Context.BIND_AUTO_CREATE) + } + + fun disconnect(context: Context) { + unregisterCallback() + if (connectionActive) try { + context.unbindService(this) + } catch (_: IllegalArgumentException) { + } // ignore + connectionActive = false + if (listenForDeath) try { + binder?.unlinkToDeath(this, 0) + } catch (_: NoSuchElementException) { + } + binder = null + service = null + callback = null + } +} 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 0000000..eddb573 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt @@ -0,0 +1,202 @@ +package io.nekohasekai.sagernet.bg + +import android.app.PendingIntent +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import android.text.format.Formatter +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import io.nekohasekai.sagernet.Action +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.SagerNet +import io.nekohasekai.sagernet.aidl.ISagerNetServiceCallback +import io.nekohasekai.sagernet.aidl.SpeedDisplayData +import io.nekohasekai.sagernet.aidl.TrafficData +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.ktx.app +import io.nekohasekai.sagernet.ktx.getColorAttr +import io.nekohasekai.sagernet.ui.SwitchActivity +import io.nekohasekai.sagernet.utils.Theme + +/** + * User can customize visibility of notification since Android 8. + * The default visibility: + * + * Android 8.x: always visible due to system limitations + * VPN: always invisible because of VPN notification/icon + * Other: always visible + * + * See also: https://github.com/aosp-mirror/platform_frameworks_base/commit/070d142993403cc2c42eca808ff3fafcee220ac4 + */ +class ServiceNotification( + private val service: BaseService.Interface, profileName: String, + channel: String, visible: Boolean = false, +) : BroadcastReceiver() { + companion object { + const val notificationId = 1 + val flags = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 + } + + val trafficStatistics = DataStore.profileTrafficStatistics + val showDirectSpeed = DataStore.showDirectSpeed + + private val callback: ISagerNetServiceCallback by lazy { + object : ISagerNetServiceCallback.Stub() { + override fun cbSpeedUpdate(stats: SpeedDisplayData) { + if (!trafficStatistics) return + builder.apply { + if (showDirectSpeed) { + val speedDetail = (service as Context).getString( + R.string.speed_detail, service.getString( + R.string.speed, Formatter.formatFileSize(service, stats.txRateProxy) + ), service.getString( + R.string.speed, Formatter.formatFileSize(service, stats.rxRateProxy) + ), service.getString( + R.string.speed, + Formatter.formatFileSize(service, stats.txRateDirect) + ), service.getString( + R.string.speed, + Formatter.formatFileSize(service, stats.rxRateDirect) + ) + ) + setStyle(NotificationCompat.BigTextStyle().bigText(speedDetail)) + setContentText(speedDetail) + } else { + val speedSimple = (service as Context).getString( + R.string.traffic, service.getString( + R.string.speed, Formatter.formatFileSize(service, stats.txRateProxy) + ), service.getString( + R.string.speed, Formatter.formatFileSize(service, stats.rxRateProxy) + ) + ) + setContentText(speedSimple) + } + setSubText( + service.getString( + R.string.traffic, + Formatter.formatFileSize(service, stats.txTotal), + Formatter.formatFileSize(service, stats.rxTotal) + ) + ) + } + update() + } + + override fun cbTrafficUpdate(stats: TrafficData?) { + } + + override fun stateChanged(state: Int, profileName: String?, msg: String?) { + } + + override fun missingPlugin(profileName: String?, pluginName: String?) { + } + + override fun routeAlert(type: Int, routeName: String?) { + } + + override fun updateWakeLockStatus(acquired: Boolean) { + updateActions(acquired) + builder.priority = + if (acquired) NotificationCompat.PRIORITY_HIGH else NotificationCompat.PRIORITY_LOW + update() + } + + override fun cbLogUpdate(str: String?) { + } + } + } + private var callbackRegistered = false + + private val builder = NotificationCompat.Builder(service as Context, channel) + .setWhen(0) + .setTicker(service.getString(R.string.forward_success)) + .setContentTitle(profileName) + .setOnlyAlertOnce(true) + .setContentIntent(SagerNet.configureIntent(service)) + .setSmallIcon(R.drawable.ic_service_active) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setPriority(if (visible) NotificationCompat.PRIORITY_LOW else NotificationCompat.PRIORITY_MIN) + + init { + service as Context + updateActions(false) + + Theme.apply(app) + Theme.apply(service) + builder.color = service.getColorAttr(R.attr.colorPrimary) + + updateCallback(SagerNet.power.isInteractive) + service.registerReceiver(this, IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_ON) + addAction(Intent.ACTION_SCREEN_OFF) + }) + show() + } + + fun updateActions(wakeLockAcquired: Boolean) { + service as Context + + builder.clearActions() + val closeAction = NotificationCompat.Action.Builder( + 0, service.getText(R.string.stop), PendingIntent.getBroadcast( + service, 0, Intent(Action.CLOSE).setPackage(service.packageName), flags + ) + ).apply { + setShowsUserInterface(false) + }.build() + builder.addAction(closeAction) + + val switchAction = NotificationCompat.Action.Builder( + 0, service.getString(R.string.action_switch), PendingIntent.getActivity( + service, 0, Intent(service, SwitchActivity::class.java), flags + ) + ).apply { + setShowsUserInterface(false) + }.build() + builder.addAction(switchAction) + + val wakeLockAction = NotificationCompat.Action.Builder( + 0, + service.getText(if (!wakeLockAcquired) R.string.acquire_wake_lock else R.string.release_wake_lock), + PendingIntent.getBroadcast( + service, + 0, + Intent(Action.SWITCH_WAKE_LOCK).setPackage(service.packageName), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 + ) + ).apply { + setShowsUserInterface(false) + }.build() + builder.addAction(wakeLockAction) + } + + override fun onReceive(context: Context, intent: Intent) { + if (service.data.state == BaseService.State.Connected) updateCallback(intent.action == Intent.ACTION_SCREEN_ON) + } + + private fun updateCallback(screenOn: Boolean) { + if (!trafficStatistics) return + if (screenOn) { + service.data.binder.registerCallback(callback) + callbackRegistered = true + } else if (callbackRegistered) { // unregister callback to save battery + service.data.binder.unregisterCallback(callback) + callbackRegistered = false + } + } + + private fun show() = (service as Service).startForeground(notificationId, builder.build()) + private fun update() = + NotificationManagerCompat.from(service as Service).notify(notificationId, builder.build()) + + fun destroy() { + (service as Service).stopForeground(true) + service.unregisterReceiver(this) + updateCallback(false) + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/SubscriptionUpdater.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/SubscriptionUpdater.kt new file mode 100644 index 0000000..5a367ee --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/SubscriptionUpdater.kt @@ -0,0 +1,97 @@ +package io.nekohasekai.sagernet.bg + +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkerParameters +import androidx.work.multiprocess.RemoteWorkManager +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.SagerDatabase +import io.nekohasekai.sagernet.group.GroupUpdater +import io.nekohasekai.sagernet.ktx.Logs +import io.nekohasekai.sagernet.ktx.app +import java.util.concurrent.TimeUnit + +object SubscriptionUpdater { + + private const val WORK_NAME = "SubscriptionUpdater" + + suspend fun reconfigureUpdater() { + RemoteWorkManager.getInstance(app).cancelUniqueWork(WORK_NAME) + + val subscriptions = SagerDatabase.groupDao.subscriptions() + .filter { it.subscription!!.autoUpdate } + if (subscriptions.isEmpty()) return + + // PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS + var minDelay = + subscriptions.minByOrNull { it.subscription!!.autoUpdateDelay }!!.subscription!!.autoUpdateDelay.toLong() + val now = System.currentTimeMillis() / 1000L + var minInitDelay = + subscriptions.minOf { now - it.subscription!!.lastUpdated - (minDelay * 60) } + if (minDelay < 15) minDelay = 15 + if (minInitDelay > 60) minInitDelay = 60 + + // main process + RemoteWorkManager.getInstance(app).enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.REPLACE, + PeriodicWorkRequest.Builder(UpdateTask::class.java, minDelay, TimeUnit.MINUTES) + .apply { + if (minInitDelay > 0) setInitialDelay(minInitDelay, TimeUnit.SECONDS) + } + .build() + ) + } + + class UpdateTask( + appContext: Context, params: WorkerParameters + ) : CoroutineWorker(appContext, params) { + + val nm = NotificationManagerCompat.from(applicationContext) + + val notification = NotificationCompat.Builder(applicationContext, "service-subscription") + .setWhen(0) + .setTicker(applicationContext.getString(R.string.forward_success)) + .setContentTitle(applicationContext.getString(R.string.subscription_update)) + .setSmallIcon(R.drawable.ic_service_active) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + + override suspend fun doWork(): Result { + var subscriptions = + SagerDatabase.groupDao.subscriptions().filter { it.subscription!!.autoUpdate } + if (!DataStore.serviceState.connected) { + Logs.d("work: not connected") + subscriptions = subscriptions.filter { !it.subscription!!.updateWhenConnectedOnly } + } + + if (subscriptions.isNotEmpty()) for (profile in subscriptions) { + val subscription = profile.subscription!! + + if (((System.currentTimeMillis() / 1000).toInt() - subscription.lastUpdated) < subscription.autoUpdateDelay * 60) { + Logs.d("work: not updating " + profile.displayName()) + continue + } + Logs.d("work: updating " + profile.displayName()) + + notification.setContentText( + applicationContext.getString( + R.string.subscription_update_message, profile.displayName() + ) + ) + nm.notify(2, notification.build()) + + GroupUpdater.executeUpdate(profile, false) + } + + nm.cancel(2) + + return Result.success() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/TileService.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/TileService.kt new file mode 100644 index 0000000..5c56d9e --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/TileService.kt @@ -0,0 +1,104 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 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.bg + +import android.graphics.drawable.Icon +import android.service.quicksettings.Tile +import androidx.annotation.RequiresApi +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.SagerNet +import io.nekohasekai.sagernet.aidl.ISagerNetService +import android.service.quicksettings.TileService as BaseTileService + +@RequiresApi(24) +class TileService : BaseTileService(), SagerConnection.Callback { + private val iconIdle by lazy { Icon.createWithResource(this, R.drawable.ic_service_idle) } + private val iconBusy by lazy { Icon.createWithResource(this, R.drawable.ic_service_busy) } + private val iconConnected by lazy { + Icon.createWithResource(this, R.drawable.ic_service_active) + } + private var tapPending = false + + private val connection = SagerConnection() + override fun stateChanged(state: BaseService.State, profileName: String?, msg: String?) = + updateTile(state) { profileName } + + override fun onServiceConnected(service: ISagerNetService) { + updateTile(BaseService.State.values()[service.state]) { service.profileName } + if (tapPending) { + tapPending = false + onClick() + } + } + + override fun onStartListening() { + super.onStartListening() + connection.connect(this, this) + } + + override fun onStopListening() { + connection.disconnect(this) + super.onStopListening() + } + + override fun onClick() { + toggle() + } + + private fun updateTile(serviceState: BaseService.State, profileName: () -> String?) { + qsTile?.apply { + label = null + when (serviceState) { + BaseService.State.Idle -> error("serviceState") + BaseService.State.Connecting -> { + icon = iconBusy + state = Tile.STATE_ACTIVE + } + BaseService.State.Connected -> { + icon = iconConnected + label = profileName() + state = Tile.STATE_ACTIVE + } + BaseService.State.Stopping -> { + icon = iconBusy + state = Tile.STATE_UNAVAILABLE + } + BaseService.State.Stopped -> { + icon = iconIdle + state = Tile.STATE_INACTIVE + } + } + label = label ?: getString(R.string.app_name) + updateTile() + } + } + + private fun toggle() { + val service = connection.service + if (service == null) tapPending = + true else BaseService.State.values()[service.state].let { state -> + when { + state.canStop -> SagerNet.stopService() + state == BaseService.State.Stopped -> SagerNet.startService() + } + } + } +} 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 0000000..2d4c0d3 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/VpnService.kt @@ -0,0 +1,269 @@ +package io.nekohasekai.sagernet.bg + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Service +import android.content.Intent +import android.content.pm.PackageManager +import android.net.ProxyInfo +import android.os.Build +import android.os.ParcelFileDescriptor +import android.os.PowerManager +import io.nekohasekai.sagernet.* +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.fmt.LOCALHOST +import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean +import io.nekohasekai.sagernet.ktx.* +import io.nekohasekai.sagernet.ui.VpnRequestActivity +import io.nekohasekai.sagernet.utils.Subnet +import libcore.* +import moe.matsuri.nb4a.net.LocalResolverImpl +import moe.matsuri.nb4a.proxy.neko.needBypassRootUid +import android.net.VpnService as BaseVpnService + +class VpnService : BaseVpnService(), + BaseService.Interface { + + companion object { + + const val PRIVATE_VLAN4_CLIENT = "172.19.0.1" + const val PRIVATE_VLAN4_ROUTER = "172.19.0.2" + const val FAKEDNS_VLAN4_CLIENT = "198.18.0.0" + const val PRIVATE_VLAN6_CLIENT = "fdfe:dcba:9876::1" + const val PRIVATE_VLAN6_ROUTER = "fdfe:dcba:9876::2" + + } + + var conn: ParcelFileDescriptor? = null + + private var metered = false + + override var upstreamInterfaceName: String? = null + + override suspend fun startProcesses() { + DataStore.vpnService = this + super.startProcesses() // launch proxy instance + } + + override var wakeLock: PowerManager.WakeLock? = null + + @SuppressLint("WakelockTimeout") + override fun acquireWakeLock() { + wakeLock = SagerNet.power.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "sagernet:vpn") + .apply { acquire() } + } + + @Suppress("EXPERIMENTAL_API_USAGE") + override fun killProcesses() { + conn?.close() + conn = null + super.killProcesses() + } + + override fun onBind(intent: Intent) = when (intent.action) { + SERVICE_INTERFACE -> super.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) + } + + fun startVpn(tunOptionsJson: String, tunPlatformOptionsJson: String): Int { +// Logs.d(tunOptionsJson) +// Logs.d(tunPlatformOptionsJson) +// val tunOptions = JSONObject(tunOptionsJson) + + // address & route & MTU ...... use NB4A GUI config + val profile = data.proxy!!.profile + val builder = Builder().setConfigureIntent(SagerNet.configureIntent(this)) + .setSession(profile.displayName()) + .setMtu(DataStore.mtu) + val ipv6Mode = DataStore.ipv6Mode + + // address + builder.addAddress(PRIVATE_VLAN4_CLIENT, 30) + if (ipv6Mode != IPv6Mode.DISABLE) { + builder.addAddress(PRIVATE_VLAN6_CLIENT, 126) + } + builder.addDnsServer(PRIVATE_VLAN4_ROUTER) + + // route + if (DataStore.bypassLan && !DataStore.bypassLanInCoreOnly) { + resources.getStringArray(R.array.bypass_private_route).forEach { + val subnet = Subnet.fromString(it)!! + builder.addRoute(subnet.address.hostAddress!!, subnet.prefixSize) + } + builder.addRoute(PRIVATE_VLAN4_ROUTER, 32) + builder.addRoute(FAKEDNS_VLAN4_CLIENT, 15) + // https://issuetracker.google.com/issues/149636790 + if (ipv6Mode != IPv6Mode.DISABLE) { + builder.addRoute("2000::", 3) + } + } else { + builder.addRoute("0.0.0.0", 0) + if (ipv6Mode != IPv6Mode.DISABLE) { + builder.addRoute("::", 0) + } + } + + // ? + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { + // TODO listen for change? + if (SagerNet.underlyingNetwork != null) { + builder.setUnderlyingNetworks(arrayOf(SagerNet.underlyingNetwork)) + } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) builder.setMetered(metered) + + // app route + val packageName = packageName + var proxyApps = DataStore.proxyApps + var bypass = DataStore.bypass + var workaroundSYSTEM = false /* DataStore.tunImplementation == TunImplementation.SYSTEM */ + var needBypassRootUid = workaroundSYSTEM || data.proxy!!.config.trafficMap.values.any { + it.nekoBean?.needBypassRootUid() == true || it.hysteriaBean?.protocol == HysteriaBean.PROTOCOL_FAKETCP + } + + if (proxyApps || needBypassRootUid) { + val individual = mutableSetOf() + val allApps by lazy { + packageManager.getInstalledPackages(PackageManager.GET_PERMISSIONS).filter { + when (it.packageName) { + packageName -> false + "android" -> true + else -> it.requestedPermissions?.contains(Manifest.permission.INTERNET) == true + } + }.map { + it.packageName + } + } + if (proxyApps) { + individual.addAll(DataStore.individual.split('\n').filter { it.isNotBlank() }) + if (bypass && needBypassRootUid) { + val individualNew = allApps.toMutableList() + individualNew.removeAll(individual) + individual.clear() + individual.addAll(individualNew) + bypass = false + } + } else { + individual.addAll(allApps) + bypass = false + } + + val added = mutableListOf() + + individual.apply { + // Allow Matsuri itself using VPN. + remove(packageName) + if (!bypass) add(packageName) + }.forEach { + try { + if (bypass) { + builder.addDisallowedApplication(it) + } else { + builder.addAllowedApplication(it) + } + added.add(it) + } catch (ex: PackageManager.NameNotFoundException) { + Logs.w(ex) + } + } + + if (bypass) { + Logs.d("Add bypass: ${added.joinToString(", ")}") + } else { + Logs.d("Add allow: ${added.joinToString(", ")}") + } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && DataStore.appendHttpProxy) { + builder.setHttpProxy(ProxyInfo.buildDirectProxy(LOCALHOST, DataStore.mixedPort)) + } + + metered = DataStore.meteredNetwork + if (Build.VERSION.SDK_INT >= 29) builder.setMetered(metered) + conn = builder.establish() ?: throw NullConnectionException() + + // post setup + Libcore.setLocalResolver(LocalResolverImpl) + + return conn!!.fd + } + +// +// val appStats = mutableListOf() +// +// override fun updateStats(stats: AppStats) { +// appStats.add(stats) +// } +// +// fun persistAppStats() { +// if (!DataStore.appTrafficStatistics) return +// val tun = getTun() ?: return +// appStats.clear() +// tun.readAppTraffics(this) +// val toUpdate = mutableListOf() +// val all = SagerDatabase.statsDao.all().associateBy { it.packageName } +// for (stats in appStats) { +// if (stats.nekoConnectionsJSON.isNotBlank()) continue +// val packageName = if (stats.uid >= 10000) { +// PackageCache.uidMap[stats.uid]?.iterator()?.next() ?: "android" +// } else { +// "android" +// } +// if (!all.containsKey(packageName)) { +// SagerDatabase.statsDao.create( +// StatsEntity( +// packageName = packageName, +// tcpConnections = stats.tcpConnTotal, +// udpConnections = stats.udpConnTotal, +// uplink = stats.uplinkTotal, +// downlink = stats.downlinkTotal +// ) +// ) +// } else { +// val entity = all[packageName]!! +// entity.tcpConnections += stats.tcpConnTotal +// entity.udpConnections += stats.udpConnTotal +// entity.uplink += stats.uplinkTotal +// entity.downlink += stats.downlinkTotal +// toUpdate.add(entity) +// } +// if (toUpdate.isNotEmpty()) { +// SagerDatabase.statsDao.update(toUpdate) +// } +// +// } +// } + + override fun onRevoke() = stopRunner() + + override fun onDestroy() { + Libcore.setLocalResolver(null) + DataStore.vpnService = null + super.onDestroy() + data.binder.close() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt new file mode 100644 index 0000000..0826578 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt @@ -0,0 +1,277 @@ +package io.nekohasekai.sagernet.bg.proto + +import android.os.Build +import android.os.SystemClock +import io.nekohasekai.sagernet.SagerNet +import io.nekohasekai.sagernet.bg.AbstractInstance +import io.nekohasekai.sagernet.bg.GuardedProcessPool +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.ProxyEntity +import io.nekohasekai.sagernet.fmt.ConfigBuildResult +import io.nekohasekai.sagernet.fmt.buildConfig +import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean +import io.nekohasekai.sagernet.fmt.hysteria.buildHysteriaConfig +import io.nekohasekai.sagernet.fmt.naive.NaiveBean +import io.nekohasekai.sagernet.fmt.naive.buildNaiveConfig +import io.nekohasekai.sagernet.fmt.trojan_go.TrojanGoBean +import io.nekohasekai.sagernet.fmt.trojan_go.buildTrojanGoConfig +import io.nekohasekai.sagernet.fmt.tuic.TuicBean +import io.nekohasekai.sagernet.fmt.tuic.buildTuicConfig +import io.nekohasekai.sagernet.ktx.* +import io.nekohasekai.sagernet.plugin.PluginManager +import kotlinx.coroutines.* +import libcore.BoxInstance +import libcore.Libcore +import moe.matsuri.nb4a.proxy.neko.NekoBean +import moe.matsuri.nb4a.proxy.neko.NekoJSInterface +import moe.matsuri.nb4a.plugin.NekoPluginManager +import moe.matsuri.nb4a.proxy.neko.updateAllConfig +import org.json.JSONObject +import java.io.File + +abstract class BoxInstance( + val profile: ProxyEntity +) : AbstractInstance { + + lateinit var config: ConfigBuildResult + lateinit var box: BoxInstance + + val pluginPath = hashMapOf() + val pluginConfigs = hashMapOf>() + val externalInstances = hashMapOf() + open lateinit var processes: GuardedProcessPool + private var cacheFiles = ArrayList() + fun isInitialized(): Boolean { + return ::config.isInitialized && ::box.isInitialized + } + + protected fun initPlugin(name: String): PluginManager.InitResult { + return pluginPath.getOrPut(name) { PluginManager.init(name)!! } + } + + protected open fun buildConfig() { + config = buildConfig(profile) + } + + protected open suspend fun loadConfig() { + NekoJSInterface.Default.destroyAllJsi() + box = Libcore.newSingBoxInstance(config.config) + } + + open suspend fun init() { + buildConfig() + for ((chain) in config.externalIndex) { + chain.entries.forEachIndexed { index, (port, profile) -> + when (val bean = profile.requireBean()) { + is TrojanGoBean -> { + initPlugin("trojan-go-plugin") + pluginConfigs[port] = profile.type to bean.buildTrojanGoConfig(port) + } + is NaiveBean -> { + initPlugin("naive-plugin") + pluginConfigs[port] = profile.type to bean.buildNaiveConfig(port) + } + is HysteriaBean -> { + initPlugin("hysteria-plugin") + pluginConfigs[port] = profile.type to bean.buildHysteriaConfig(port) { + File( + app.cacheDir, "hysteria_" + SystemClock.elapsedRealtime() + ".ca" + ).apply { + parentFile?.mkdirs() + cacheFiles.add(this) + } + } + } + is TuicBean -> { + initPlugin("tuic-plugin") + pluginConfigs[port] = profile.type to bean.buildTuicConfig(port) { + File( + app.noBackupFilesDir, + "tuic_" + SystemClock.elapsedRealtime() + ".ca" + ).apply { + parentFile?.mkdirs() + cacheFiles.add(this) + } + } + } + is NekoBean -> { + // check if plugin binary can be loaded + initPlugin(bean.plgId) + + // build config and check if succeed + bean.updateAllConfig(port) + if (bean.allConfig == null) { + throw NekoPluginManager.PluginInternalException(bean.protocolId) + } + } + } + } + } + loadConfig() + } + + override fun launch() { + // TODO move, this is not box + val context = + if (Build.VERSION.SDK_INT < 24 || SagerNet.user.isUserUnlocked) SagerNet.application else SagerNet.deviceStorage + val cache = File(context.cacheDir, "tmpcfg") + cache.mkdirs() + + for ((chain) in config.externalIndex) { + chain.entries.forEachIndexed { index, (port, profile) -> + val bean = profile.requireBean() + val needChain = index != chain.size - 1 + val (profileType, config) = pluginConfigs[port] ?: (0 to "") + + when { + externalInstances.containsKey(port) -> { + externalInstances[port]!!.launch() + } + bean is TrojanGoBean -> { + val configFile = File( + cache, "trojan_go_" + SystemClock.elapsedRealtime() + ".json" + ) + configFile.parentFile?.mkdirs() + configFile.writeText(config) + cacheFiles.add(configFile) + + val commands = mutableListOf( + initPlugin("trojan-go-plugin").path, "-config", configFile.absolutePath + ) + + processes.start(commands) + } + bean is NaiveBean -> { + val configFile = File( + cache, "naive_" + SystemClock.elapsedRealtime() + ".json" + ) + + configFile.parentFile?.mkdirs() + configFile.writeText(config) + cacheFiles.add(configFile) + + val envMap = mutableMapOf() + + if (bean.certificates.isNotBlank()) { + val certFile = File( + cache, "naive_" + SystemClock.elapsedRealtime() + ".crt" + ) + + certFile.parentFile?.mkdirs() + certFile.writeText(bean.certificates) + cacheFiles.add(certFile) + + envMap["SSL_CERT_FILE"] = certFile.absolutePath + } + + val commands = mutableListOf( + initPlugin("naive-plugin").path, configFile.absolutePath + ) + + processes.start(commands, envMap) + } + bean is HysteriaBean -> { + val configFile = File( + cache, "hysteria_" + SystemClock.elapsedRealtime() + ".json" + ) + + configFile.parentFile?.mkdirs() + configFile.writeText(config) + cacheFiles.add(configFile) + + val commands = mutableListOf( + initPlugin("hysteria-plugin").path, + "--no-check", + "--config", + configFile.absolutePath, + "--log-level", + if (DataStore.enableLog) "trace" else "warn", + "client" + ) + + if (bean.protocol == HysteriaBean.PROTOCOL_FAKETCP) { + commands.addAll(0, listOf("su", "-c")) + } + + processes.start(commands) + } + bean is NekoBean -> { + // config built from JS + val nekoRunConfigs = bean.allConfig.optJSONArray("nekoRunConfigs") + val configs = mutableMapOf() + + nekoRunConfigs?.forEach { _, any -> + any as JSONObject + + val name = any.getString("name") + val configFile = File(cache, name) + configFile.parentFile?.mkdirs() + val content = any.getString("content") + configFile.writeText(content) + + cacheFiles.add(configFile) + configs[name] = configFile.absolutePath + + Logs.d(name + "\n\n" + content) + } + + val nekoCommands = bean.allConfig.getJSONArray("nekoCommands") + val commands = mutableListOf() + + nekoCommands.forEach { _, any -> + if (any is String) { + if (configs.containsKey(any)) { + commands.add(configs[any]!!) + } else if (any == "%exe%") { + commands.add(initPlugin(bean.plgId).path) + } else { + commands.add(any) + } + } + } + + processes.start(commands) + } + bean is TuicBean -> { + val configFile = File( + context.noBackupFilesDir, + "tuic_" + SystemClock.elapsedRealtime() + ".json" + ) + + configFile.parentFile?.mkdirs() + configFile.writeText(config) + cacheFiles.add(configFile) + + val commands = mutableListOf( + initPlugin("tuic-plugin").path, + "-c", + configFile.absolutePath, + ) + + processes.start(commands) + } + } + } + } + + box.start() + } + + @Suppress("EXPERIMENTAL_API_USAGE") + override fun close() { + for (instance in externalInstances.values) { + runCatching { + instance.close() + } + } + + cacheFiles.removeAll { it.delete(); true } + + if (::processes.isInitialized) processes.close(GlobalScope + Dispatchers.IO) + + if (::box.isInitialized) { + box.close() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/ProxyInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/ProxyInstance.kt new file mode 100644 index 0000000..9c930fd --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/ProxyInstance.kt @@ -0,0 +1,44 @@ +package io.nekohasekai.sagernet.bg.proto + +import io.nekohasekai.sagernet.bg.BaseService +import io.nekohasekai.sagernet.database.ProxyEntity +import io.nekohasekai.sagernet.ktx.Logs +import io.nekohasekai.sagernet.ktx.runOnIoDispatcher +import kotlinx.coroutines.runBlocking + +class ProxyInstance(profile: ProxyEntity, val service: BaseService.Interface) : + BoxInstance(profile) { + + // for TrafficLooper + private var looper: TrafficLooper? = null + + override fun buildConfig() { + super.buildConfig() + Logs.d(config.config) + } + + override suspend fun init() { + super.init() + pluginConfigs.forEach { (_, plugin) -> + val (_, content) = plugin + Logs.d(content) + } + } + + override fun launch() { + box.setAsMain() + super.launch() + runOnIoDispatcher { + looper = TrafficLooper(service.data, this) + looper?.start() + } + } + + override fun close() { + super.close() + runBlocking { + looper?.stop() + looper = null + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt new file mode 100644 index 0000000..e4b85df --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt @@ -0,0 +1,48 @@ +package io.nekohasekai.sagernet.bg.proto + +import io.nekohasekai.sagernet.BuildConfig +import io.nekohasekai.sagernet.bg.GuardedProcessPool +import io.nekohasekai.sagernet.database.ProxyEntity +import io.nekohasekai.sagernet.fmt.buildConfig +import io.nekohasekai.sagernet.ktx.Logs +import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher +import io.nekohasekai.sagernet.ktx.tryResume +import io.nekohasekai.sagernet.ktx.tryResumeWithException +import libcore.Libcore +import kotlin.coroutines.suspendCoroutine + +class TestInstance(profile: ProxyEntity, val link: String, val timeout: Int) : + BoxInstance(profile) { + + suspend fun doTest(): Int { + return suspendCoroutine { c -> + processes = GuardedProcessPool { + Logs.w(it) + c.tryResumeWithException(it) + } + runOnDefaultDispatcher { + use { + try { + init() + launch() + c.tryResume(Libcore.urlTest(box, link, timeout)) + } catch (e: Exception) { + c.tryResumeWithException(e) + } + } + } + } + } + + override fun buildConfig() { + config = buildConfig(profile, true) + } + + override suspend fun loadConfig() { + // don't call destroyAllJsi here + if (BuildConfig.DEBUG) Logs.d(config.config) + box = Libcore.newSingBoxInstance(config.config) + box.forTest = true + } + +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLooper.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLooper.kt new file mode 100644 index 0000000..e9623da --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficLooper.kt @@ -0,0 +1,130 @@ +package io.nekohasekai.sagernet.bg.proto + +import io.nekohasekai.sagernet.aidl.SpeedDisplayData +import io.nekohasekai.sagernet.aidl.TrafficData +import io.nekohasekai.sagernet.bg.BaseService +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.ProfileManager +import io.nekohasekai.sagernet.ktx.Logs +import kotlinx.coroutines.* +import kotlin.time.DurationUnit +import kotlin.time.toDuration + +class TrafficLooper + ( + val data: BaseService.Data, private val sc: CoroutineScope +) { + + private var job: Job? = null + private val items = mutableMapOf() + + suspend fun stop() { + job?.cancel() + // finally + val traffic = mutableMapOf() + data.proxy?.config?.trafficMap?.forEach { (tag, ent) -> + val item = items[tag] ?: return@forEach + ent.rx = item.rx + ent.tx = item.tx + ProfileManager.updateProfile(ent) // update DB + traffic[ent.id] = TrafficData( + id = ent.id, + rx = ent.rx, + tx = ent.tx, + ) + } + data.binder.broadcast { b -> + for (t in traffic) { + b.cbTrafficUpdate(t.value) + } + } + Logs.d("finally traffic post done") + } + + fun start() { + job = sc.launch { loop() } + } + + private suspend fun loop() { + val delayMs = DataStore.speedInterval + val showDirectSpeed = DataStore.showDirectSpeed + if (delayMs == 0) return + + var trafficUpdater: TrafficUpdater? = null + var proxy: ProxyInstance? + var itemMain: TrafficUpdater.TrafficLooperData? = null + var itemMainBase: TrafficUpdater.TrafficLooperData? = null + var itemBypass: TrafficUpdater.TrafficLooperData? = null + + while (sc.isActive) { + delay(delayMs.toDuration(DurationUnit.MILLISECONDS)) + proxy = data.proxy ?: continue + + if (trafficUpdater == null) { + if (!proxy.isInitialized()) continue + items.clear() + itemBypass = TrafficUpdater.TrafficLooperData(tag = "bypass") + items["bypass"] = itemBypass +// proxy.config.trafficMap.forEach { (tag, ent) -> + proxy.config.outboundTags.forEach { tag -> + // TODO g-xx query traffic return 0? + val ent = proxy.config.trafficMap[tag] ?: return@forEach + val item = TrafficUpdater.TrafficLooperData( + tag = tag, + rx = ent.rx, + tx = ent.tx, + ) + if (tag == proxy.config.outboundTagMain) { + itemMain = item + itemMainBase = TrafficUpdater.TrafficLooperData( + tag = tag, + rx = ent.rx, + tx = ent.tx, + ) + } + items[tag] = item + Logs.d("traffic count $tag to ${ent.id}") + } + trafficUpdater = TrafficUpdater( + box = proxy.box, items = items + ) + proxy.box.setV2rayStats(items.keys.joinToString("\n")) + } + + trafficUpdater.updateAll() + if (!sc.isActive) return + + // speed + val speed = SpeedDisplayData( + itemMain!!.txRate, + itemMain!!.rxRate, + if (showDirectSpeed) itemBypass!!.txRate else 0L, + if (showDirectSpeed) itemBypass!!.rxRate else 0L, + itemMain!!.tx - itemMainBase!!.tx, + itemMain!!.rx - itemMainBase!!.rx + ) + + // traffic + val traffic = mutableMapOf() + proxy.config.trafficMap.forEach { (tag, ent) -> + val item = items[tag] ?: return@forEach + ent.rx = item.rx + ent.tx = item.tx +// ProfileManager.updateProfile(ent) // update DB + traffic[ent.id] = TrafficData( + id = ent.id, + rx = ent.rx, + tx = ent.tx, + ) // display + } + + // broadcast + data.binder.broadcast { b -> + b.cbSpeedUpdate(speed) + for (t in traffic) { + b.cbTrafficUpdate(t.value) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficUpdater.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficUpdater.kt new file mode 100644 index 0000000..b91810f --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TrafficUpdater.kt @@ -0,0 +1,69 @@ +package io.nekohasekai.sagernet.bg.proto + +import io.nekohasekai.sagernet.ktx.onIoDispatcher + +class TrafficUpdater( + private val box: libcore.BoxInstance, + val items: Map, // contain "bypass" +) { + + class TrafficLooperData( + var tag: String, + var tx: Long = 0, + var rx: Long = 0, + var txRate: Long = 0, + var rxRate: Long = 0, + var lastUpdate: Long = 0, + ) + + private suspend fun updateOne(item: TrafficLooperData): TrafficLooperData { + // last update + val now = System.currentTimeMillis() + val interval = now - item.lastUpdate + item.lastUpdate = now + if (interval <= 0) return item.apply { + rxRate = 0 + txRate = 0 + } + + // query + var tx = 0L + var rx = 0L + onIoDispatcher { + tx = box.queryStats(item.tag, "uplink") + rx = box.queryStats(item.tag, "downlink") + } + + // add diff + item.rx += rx + item.tx += tx + item.rxRate = rx * 1000 / interval + item.txRate = tx * 1000 / interval + + // return diff + return TrafficLooperData( + tag = item.tag, + rx = rx, + tx = tx, + rxRate = item.rxRate, + txRate = item.txRate, + ) + } + + suspend fun updateAll() { + val updated = mutableMapOf() // diffs + items.forEach { (tag, item) -> + var diff = updated[tag] + // query a tag only once + if (diff == null) { + diff = updateOne(item) + updated[tag] = diff + } else { + item.rx += diff.rx + item.tx += diff.tx + item.rxRate += diff.rxRate + item.txRate += diff.txRate + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/UrlTest.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/UrlTest.kt new file mode 100644 index 0000000..3cec7cf --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/UrlTest.kt @@ -0,0 +1,15 @@ +package io.nekohasekai.sagernet.bg.proto + +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.ProxyEntity + +class UrlTest { + + val link = DataStore.connectionTestURL + val timeout = 3000 + + suspend fun doTest(profile: ProxyEntity): Int { + return TestInstance(profile, link, timeout).doTest() + } + +} \ 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 0000000..1ea66f6 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt @@ -0,0 +1,250 @@ +package io.nekohasekai.sagernet.database + +import android.os.Binder +import androidx.preference.PreferenceDataStore +import io.nekohasekai.sagernet.* +import io.nekohasekai.sagernet.bg.BaseService +import io.nekohasekai.sagernet.bg.VpnService +import io.nekohasekai.sagernet.database.preference.OnPreferenceDataStoreChangeListener +import io.nekohasekai.sagernet.database.preference.PublicDatabase +import io.nekohasekai.sagernet.database.preference.RoomPreferenceDataStore +import io.nekohasekai.sagernet.ktx.* +import moe.matsuri.nb4a.TempDatabase + +object DataStore : OnPreferenceDataStoreChangeListener { + + // share service state in main process + @Volatile + var serviceState = BaseService.State.Idle + + val configurationStore = RoomPreferenceDataStore(PublicDatabase.kvPairDao) + val profileCacheStore = RoomPreferenceDataStore(TempDatabase.profileCacheDao) + + // last used, but may not be running + var currentProfile by configurationStore.long(Key.PROFILE_CURRENT) + + var selectedProxy by configurationStore.long(Key.PROFILE_ID) + var selectedGroup by configurationStore.long(Key.PROFILE_GROUP) { currentGroupId() } // "ungrouped" group id = 1 + + // only in bg process + var vpnService: VpnService? = null + var baseService: BaseService.Interface? = null + + // only in GUI process + var postLogListener: ((String) -> Unit)? = null + + fun currentGroupId(): Long { + val currentSelected = configurationStore.getLong(Key.PROFILE_GROUP, -1) + if (currentSelected > 0L) return currentSelected + val groups = SagerDatabase.groupDao.allGroups() + if (groups.isNotEmpty()) { + val groupId = groups[0].id + selectedGroup = groupId + return groupId + } + val groupId = SagerDatabase.groupDao.createGroup(ProxyGroup(ungrouped = true)) + selectedGroup = groupId + return groupId + } + + fun currentGroup(): ProxyGroup { + var group: ProxyGroup? = null + val currentSelected = configurationStore.getLong(Key.PROFILE_GROUP, -1) + if (currentSelected > 0L) { + group = SagerDatabase.groupDao.getById(currentSelected) + } + if (group != null) return group + val groups = SagerDatabase.groupDao.allGroups() + if (groups.isEmpty()) { + group = ProxyGroup(ungrouped = true).apply { + id = SagerDatabase.groupDao.createGroup(this) + } + } else { + group = groups[0] + } + selectedGroup = group.id + return group + } + + fun selectedGroupForImport(): Long { + val current = currentGroup() + if (current.type == GroupType.BASIC) return current.id + val groups = SagerDatabase.groupDao.allGroups() + return groups.find { it.type == GroupType.BASIC }!!.id + } + + var nekoPlugins by configurationStore.string(Key.NEKO_PLUGIN_MANAGED) + var appTLSVersion by configurationStore.string(Key.APP_TLS_VERSION) + var enableClashAPI by configurationStore.boolean(Key.ENABLE_CLASH_API) + var showBottomBar by configurationStore.boolean(Key.SHOW_BOTTOM_BAR) + + // + + var isExpert by configurationStore.boolean(Key.APP_EXPERT) + var appTheme by configurationStore.int(Key.APP_THEME) + var nightTheme by configurationStore.stringToInt(Key.NIGHT_THEME) + var serviceMode by configurationStore.string(Key.SERVICE_MODE) { Key.MODE_VPN } + +// var domainStrategy by configurationStore.string(Key.DOMAIN_STRATEGY) { "AsIs" } + var trafficSniffing by configurationStore.boolean(Key.TRAFFIC_SNIFFING) { true } + var resolveDestination by configurationStore.boolean(Key.RESOLVE_DESTINATION) + +// var tcpKeepAliveInterval by configurationStore.stringToInt(Key.TCP_KEEP_ALIVE_INTERVAL) { 15 } + var mtu by configurationStore.stringToInt(Key.MTU) { 9000 } + + var bypassLan by configurationStore.boolean(Key.BYPASS_LAN) + var bypassLanInCoreOnly by configurationStore.boolean(Key.BYPASS_LAN_IN_CORE_ONLY) + + var allowAccess by configurationStore.boolean(Key.ALLOW_ACCESS) + var speedInterval by configurationStore.stringToInt(Key.SPEED_INTERVAL) + + var remoteDns by configurationStore.string(Key.REMOTE_DNS) { "https://8.8.8.8/dns-query" } + var directDns by configurationStore.string(Key.DIRECT_DNS) { "https://223.5.5.5/dns-query" } + var directDnsUseSystem by configurationStore.boolean(Key.DIRECT_DNS_USE_SYSTEM) + var enableDnsRouting by configurationStore.boolean(Key.ENABLE_DNS_ROUTING) { true } + var enableFakeDns by configurationStore.boolean(Key.ENABLE_FAKEDNS) + var dnsNetwork by configurationStore.stringSet(Key.DNS_NETWORK) + + var rulesProvider by configurationStore.stringToInt(Key.RULES_PROVIDER) + var enableLog by configurationStore.boolean(Key.ENABLE_LOG) + var logBufSize by configurationStore.int(Key.LOG_BUF_SIZE) { 0 } + var acquireWakeLock by configurationStore.boolean(Key.ACQUIRE_WAKE_LOCK) + + // hopefully hashCode = mHandle doesn't change, currently this is true from KitKat to Nougat + private val userIndex by lazy { Binder.getCallingUserHandle().hashCode() } + var mixedPort: Int + get() = getLocalPort(Key.MIXED_PORT, 2080) + set(value) = saveLocalPort(Key.MIXED_PORT, value) + var localDNSPort: Int + get() = getLocalPort(Key.LOCAL_DNS_PORT, 6450) + set(value) { + saveLocalPort(Key.LOCAL_DNS_PORT, value) + } + var transproxyPort: Int + get() = getLocalPort(Key.TRANSPROXY_PORT, 9200) + set(value) = saveLocalPort(Key.TRANSPROXY_PORT, value) + + fun initGlobal() { + if (configurationStore.getString(Key.MIXED_PORT) == null) { + mixedPort = mixedPort + } + if (configurationStore.getString(Key.LOCAL_DNS_PORT) == null) { + localDNSPort = localDNSPort + } + if (configurationStore.getString(Key.TRANSPROXY_PORT) == null) { + transproxyPort = transproxyPort + } + } + + + private fun getLocalPort(key: String, default: Int): Int { + return parsePort(configurationStore.getString(key), default + userIndex) + } + + private fun saveLocalPort(key: String, value: Int) { + configurationStore.putString(key, "$value") + } + + var ipv6Mode by configurationStore.stringToInt(Key.IPV6_MODE) { IPv6Mode.DISABLE } + + var meteredNetwork by configurationStore.boolean(Key.METERED_NETWORK) + var proxyApps by configurationStore.boolean(Key.PROXY_APPS) + var bypass by configurationStore.boolean(Key.BYPASS_MODE) { true } + var individual by configurationStore.string(Key.INDIVIDUAL) + var showDirectSpeed by configurationStore.boolean(Key.SHOW_DIRECT_SPEED) { true } + + var appendHttpProxy by configurationStore.boolean(Key.APPEND_HTTP_PROXY) + var requireTransproxy by configurationStore.boolean(Key.REQUIRE_TRANSPROXY) + var transproxyMode by configurationStore.stringToInt(Key.TRANSPROXY_MODE) + var connectionTestURL by configurationStore.string(Key.CONNECTION_TEST_URL) { CONNECTION_TEST_URL } + var alwaysShowAddress by configurationStore.boolean(Key.ALWAYS_SHOW_ADDRESS) + + var tunImplementation by configurationStore.stringToInt(Key.TUN_IMPLEMENTATION) { TunImplementation.SYSTEM } + var profileTrafficStatistics by configurationStore.boolean(Key.PROFILE_TRAFFIC_STATISTICS) { true } + + var yacdURL by configurationStore.string("yacdURL") { "http://127.0.0.1:9090/ui" } + + // protocol + + var muxProtocols by configurationStore.stringSet(Key.MUX_PROTOCOLS) + var muxConcurrency by configurationStore.stringToInt(Key.MUX_CONCURRENCY) { 8 } + + // old cache, DO NOT ADD + + var dirty by profileCacheStore.boolean(Key.PROFILE_DIRTY) + var editingId by profileCacheStore.long(Key.PROFILE_ID) + var editingGroup by profileCacheStore.long(Key.PROFILE_GROUP) + var profileName by profileCacheStore.string(Key.PROFILE_NAME) + var serverAddress by profileCacheStore.string(Key.SERVER_ADDRESS) + var serverPort by profileCacheStore.stringToInt(Key.SERVER_PORT) + var serverUsername by profileCacheStore.string(Key.SERVER_USERNAME) + var serverPassword by profileCacheStore.string(Key.SERVER_PASSWORD) + var serverPassword1 by profileCacheStore.string(Key.SERVER_PASSWORD1) + var serverMethod by profileCacheStore.string(Key.SERVER_METHOD) + + var sharedStorage by profileCacheStore.string("sharedStorage") + + var serverProtocol by profileCacheStore.string(Key.SERVER_PROTOCOL) + var serverObfs by profileCacheStore.string(Key.SERVER_OBFS) + + var serverNetwork by profileCacheStore.string(Key.SERVER_NETWORK) + var serverHost by profileCacheStore.string(Key.SERVER_HOST) + var serverPath by profileCacheStore.string(Key.SERVER_PATH) + var serverSNI by profileCacheStore.string(Key.SERVER_SNI) + var serverEncryption by profileCacheStore.string(Key.SERVER_ENCRYPTION) + var serverALPN by profileCacheStore.string(Key.SERVER_ALPN) + var serverCertificates by profileCacheStore.string(Key.SERVER_CERTIFICATES) + var serverHeaders by profileCacheStore.string(Key.SERVER_HEADERS) + var serverAllowInsecure by profileCacheStore.boolean(Key.SERVER_ALLOW_INSECURE) + + var serverAuthType by profileCacheStore.stringToInt(Key.SERVER_AUTH_TYPE) + var serverUploadSpeed by profileCacheStore.stringToInt(Key.SERVER_UPLOAD_SPEED) + var serverDownloadSpeed by profileCacheStore.stringToInt(Key.SERVER_DOWNLOAD_SPEED) + var serverStreamReceiveWindow by profileCacheStore.stringToIntIfExists(Key.SERVER_STREAM_RECEIVE_WINDOW) + var serverConnectionReceiveWindow by profileCacheStore.stringToIntIfExists(Key.SERVER_CONNECTION_RECEIVE_WINDOW) + var serverMTU by profileCacheStore.stringToInt(Key.SERVER_MTU) { 1420 } + var serverDisableMtuDiscovery by profileCacheStore.boolean(Key.SERVER_DISABLE_MTU_DISCOVERY) + var serverHopInterval by profileCacheStore.stringToInt(Key.SERVER_HOP_INTERVAL) { 10 } + + var serverProtocolVersion by profileCacheStore.stringToInt(Key.SERVER_PROTOCOL) + var serverPrivateKey by profileCacheStore.string(Key.SERVER_PRIVATE_KEY) + var serverInsecureConcurrency by profileCacheStore.stringToInt(Key.SERVER_INSECURE_CONCURRENCY) + + var serverUDPRelayMode by profileCacheStore.string(Key.SERVER_UDP_RELAY_MODE) + var serverCongestionController by profileCacheStore.string(Key.SERVER_CONGESTION_CONTROLLER) + var serverDisableSNI by profileCacheStore.boolean(Key.SERVER_DISABLE_SNI) + var serverReduceRTT by profileCacheStore.boolean(Key.SERVER_REDUCE_RTT) + var serverFastConnect by profileCacheStore.boolean(Key.SERVER_FAST_CONNECT) + + var routeName by profileCacheStore.string(Key.ROUTE_NAME) + var routeDomain by profileCacheStore.string(Key.ROUTE_DOMAIN) + var routeIP by profileCacheStore.string(Key.ROUTE_IP) + var routePort by profileCacheStore.string(Key.ROUTE_PORT) + var routeSourcePort by profileCacheStore.string(Key.ROUTE_SOURCE_PORT) + var routeNetwork by profileCacheStore.string(Key.ROUTE_NETWORK) + var routeSource by profileCacheStore.string(Key.ROUTE_SOURCE) + var routeProtocol by profileCacheStore.string(Key.ROUTE_PROTOCOL) + var routeOutbound by profileCacheStore.stringToInt(Key.ROUTE_OUTBOUND) + var routeOutboundRule by profileCacheStore.long(Key.ROUTE_OUTBOUND_RULE) + var routePackages by profileCacheStore.string(Key.ROUTE_PACKAGES) + + + var serverConfig by profileCacheStore.string(Key.SERVER_CONFIG) + + var groupName by profileCacheStore.string(Key.GROUP_NAME) + var groupType by profileCacheStore.stringToInt(Key.GROUP_TYPE) + var groupOrder by profileCacheStore.stringToInt(Key.GROUP_ORDER) + + var subscriptionLink by profileCacheStore.string(Key.SUBSCRIPTION_LINK) + var subscriptionForceResolve by profileCacheStore.boolean(Key.SUBSCRIPTION_FORCE_RESOLVE) + var subscriptionDeduplication by profileCacheStore.boolean(Key.SUBSCRIPTION_DEDUPLICATION) + var subscriptionUpdateWhenConnectedOnly by profileCacheStore.boolean(Key.SUBSCRIPTION_UPDATE_WHEN_CONNECTED_ONLY) + var subscriptionUserAgent by profileCacheStore.string(Key.SUBSCRIPTION_USER_AGENT) + var subscriptionAutoUpdate by profileCacheStore.boolean(Key.SUBSCRIPTION_AUTO_UPDATE) + var subscriptionAutoUpdateDelay by profileCacheStore.stringToInt(Key.SUBSCRIPTION_AUTO_UPDATE_DELAY) { 360 } + + var rulesFirstCreate by profileCacheStore.boolean("rulesFirstCreate") + + override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) { + } +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/GroupManager.kt b/app/src/main/java/io/nekohasekai/sagernet/database/GroupManager.kt new file mode 100644 index 0000000..b2688f4 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/database/GroupManager.kt @@ -0,0 +1,114 @@ +package io.nekohasekai.sagernet.database + +import io.nekohasekai.sagernet.GroupType +import io.nekohasekai.sagernet.bg.SubscriptionUpdater +import io.nekohasekai.sagernet.ktx.applyDefaultValues + +object GroupManager { + + interface Listener { + suspend fun groupAdd(group: ProxyGroup) + suspend fun groupUpdated(group: ProxyGroup) + + suspend fun groupRemoved(groupId: Long) + suspend fun groupUpdated(groupId: Long) + } + + interface Interface { + suspend fun confirm(message: String): Boolean + suspend fun alert(message: String) + suspend fun onUpdateSuccess( + group: ProxyGroup, + changed: Int, + added: List, + updated: Map, + deleted: List, + duplicate: List, + byUser: Boolean + ) + + suspend fun onUpdateFailure(group: ProxyGroup, message: String) + } + + private val listeners = ArrayList() + var userInterface: Interface? = null + + suspend fun iterator(what: suspend Listener.() -> Unit) { + synchronized(listeners) { + listeners.toList() + }.forEach { listener -> + what(listener) + } + } + + fun addListener(listener: Listener) { + synchronized(listeners) { + listeners.add(listener) + } + } + + fun removeListener(listener: Listener) { + synchronized(listeners) { + listeners.remove(listener) + } + } + + suspend fun clearGroup(groupId: Long) { + DataStore.selectedProxy = 0L + SagerDatabase.proxyDao.deleteAll(groupId) + iterator { groupUpdated(groupId) } + } + + fun rearrange(groupId: Long) { + val entities = SagerDatabase.proxyDao.getByGroup(groupId) + for (index in entities.indices) { + entities[index].userOrder = (index + 1).toLong() + } + SagerDatabase.proxyDao.updateProxy(entities) + } + + suspend fun postUpdate(group: ProxyGroup) { + iterator { groupUpdated(group) } + } + + suspend fun postUpdate(groupId: Long) { + postUpdate(SagerDatabase.groupDao.getById(groupId) ?: return) + } + + suspend fun postReload(groupId: Long) { + iterator { groupUpdated(groupId) } + } + + suspend fun createGroup(group: ProxyGroup): ProxyGroup { + group.userOrder = SagerDatabase.groupDao.nextOrder() ?: 1 + group.id = SagerDatabase.groupDao.createGroup(group.applyDefaultValues()) + iterator { groupAdd(group) } + if (group.type == GroupType.SUBSCRIPTION) { + SubscriptionUpdater.reconfigureUpdater() + } + return group + } + + suspend fun updateGroup(group: ProxyGroup) { + SagerDatabase.groupDao.updateGroup(group) + iterator { groupUpdated(group) } + if (group.type == GroupType.SUBSCRIPTION) { + SubscriptionUpdater.reconfigureUpdater() + } + } + + suspend fun deleteGroup(groupId: Long) { + SagerDatabase.groupDao.deleteById(groupId) + SagerDatabase.proxyDao.deleteByGroup(groupId) + iterator { groupRemoved(groupId) } + SubscriptionUpdater.reconfigureUpdater() + } + + suspend fun deleteGroup(group: List) { + SagerDatabase.groupDao.deleteGroup(group) + SagerDatabase.proxyDao.deleteByGroup(group.map { it.id }.toLongArray()) + for (proxyGroup in group) iterator { groupRemoved(proxyGroup.id) } + SubscriptionUpdater.reconfigureUpdater() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/ParcelizeBridge.java b/app/src/main/java/io/nekohasekai/sagernet/database/ParcelizeBridge.java new file mode 100644 index 0000000..9bdb6d8 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/database/ParcelizeBridge.java @@ -0,0 +1,13 @@ +package io.nekohasekai.sagernet.database; + +import android.os.Parcel; + +/** + * see: https://youtrack.jetbrains.com/issue/KT-19853 + */ +public class ParcelizeBridge { + + public static RuleEntity createRule(Parcel parcel) { + return (RuleEntity) RuleEntity.CREATOR.createFromParcel(parcel); + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt b/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt new file mode 100644 index 0000000..31816c2 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt @@ -0,0 +1,258 @@ +package io.nekohasekai.sagernet.database + +import android.database.sqlite.SQLiteCantOpenDatabaseException +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.aidl.SpeedDisplayData +import io.nekohasekai.sagernet.aidl.TrafficData +import io.nekohasekai.sagernet.fmt.AbstractBean +import io.nekohasekai.sagernet.ktx.Logs +import io.nekohasekai.sagernet.ktx.app +import io.nekohasekai.sagernet.ktx.applyDefaultValues +import java.io.IOException +import java.sql.SQLException +import java.util.* + + +object ProfileManager { + + interface Listener { + suspend fun onAdd(profile: ProxyEntity) + suspend fun onUpdated(data: TrafficData) + suspend fun onUpdated(profile: ProxyEntity) + suspend fun onRemoved(groupId: Long, profileId: Long) + } + + interface RuleListener { + suspend fun onAdd(rule: RuleEntity) + suspend fun onUpdated(rule: RuleEntity) + suspend fun onRemoved(ruleId: Long) + suspend fun onCleared() + } + + private val listeners = ArrayList() + private val ruleListeners = ArrayList() + + suspend fun iterator(what: suspend Listener.() -> Unit) { + synchronized(listeners) { + listeners.toList() + }.forEach { listener -> + what(listener) + } + } + + suspend fun ruleIterator(what: suspend RuleListener.() -> Unit) { + val ruleListeners = synchronized(ruleListeners) { + ruleListeners.toList() + } + for (listener in ruleListeners) { + what(listener) + } + } + + fun addListener(listener: Listener) { + synchronized(listeners) { + listeners.add(listener) + } + } + + fun removeListener(listener: Listener) { + synchronized(listeners) { + listeners.remove(listener) + } + } + + fun addListener(listener: RuleListener) { + synchronized(ruleListeners) { + ruleListeners.add(listener) + } + } + + fun removeListener(listener: RuleListener) { + synchronized(ruleListeners) { + ruleListeners.remove(listener) + } + } + + suspend fun createProfile(groupId: Long, bean: AbstractBean): ProxyEntity { + bean.applyDefaultValues() + + val profile = ProxyEntity(groupId = groupId).apply { + id = 0 + putBean(bean) + userOrder = SagerDatabase.proxyDao.nextOrder(groupId) ?: 1 + } + profile.id = SagerDatabase.proxyDao.addProxy(profile) + iterator { onAdd(profile) } + return profile + } + + suspend fun updateProfile(profile: ProxyEntity) { + SagerDatabase.proxyDao.updateProxy(profile) + iterator { onUpdated(profile) } + } + + suspend fun updateProfile(profiles: List) { + SagerDatabase.proxyDao.updateProxy(profiles) + profiles.forEach { + iterator { onUpdated(it) } + } + } + + suspend fun deleteProfile2(groupId: Long, profileId: Long) { + if (SagerDatabase.proxyDao.deleteById(profileId) == 0) return + if (DataStore.selectedProxy == profileId) { + DataStore.selectedProxy = 0L + } + } + + suspend fun deleteProfile(groupId: Long, profileId: Long) { + if (SagerDatabase.proxyDao.deleteById(profileId) == 0) return + if (DataStore.selectedProxy == profileId) { + DataStore.selectedProxy = 0L + } + iterator { onRemoved(groupId, profileId) } + if (SagerDatabase.proxyDao.countByGroup(groupId) > 1) { + GroupManager.rearrange(groupId) + } + } + + fun getProfile(profileId: Long): ProxyEntity? { + if (profileId == 0L) return null + return try { + SagerDatabase.proxyDao.getById(profileId) + } catch (ex: SQLiteCantOpenDatabaseException) { + throw IOException(ex) + } catch (ex: SQLException) { + Logs.w(ex) + null + } + } + + fun getProfiles(profileIds: List): List { + if (profileIds.isEmpty()) return listOf() + return try { + SagerDatabase.proxyDao.getEntities(profileIds) + } catch (ex: SQLiteCantOpenDatabaseException) { + throw IOException(ex) + } catch (ex: SQLException) { + Logs.w(ex) + listOf() + } + } + + // postUpdate: post to listeners, don't change the DB + + suspend fun postUpdate(profileId: Long) { + postUpdate(getProfile(profileId) ?: return) + } + + suspend fun postUpdate(profile: ProxyEntity) { + iterator { onUpdated(profile) } + } + + suspend fun postUpdate(data: TrafficData) { + iterator { onUpdated(data) } + } + + suspend fun createRule(rule: RuleEntity, post: Boolean = true): RuleEntity { + rule.userOrder = SagerDatabase.rulesDao.nextOrder() ?: 1 + rule.id = SagerDatabase.rulesDao.createRule(rule) + if (post) { + ruleIterator { onAdd(rule) } + } + return rule + } + + suspend fun updateRule(rule: RuleEntity) { + SagerDatabase.rulesDao.updateRule(rule) + ruleIterator { onUpdated(rule) } + } + + suspend fun deleteRule(ruleId: Long) { + SagerDatabase.rulesDao.deleteById(ruleId) + ruleIterator { onRemoved(ruleId) } + } + + suspend fun deleteRules(rules: List) { + SagerDatabase.rulesDao.deleteRules(rules) + ruleIterator { + rules.forEach { + onRemoved(it.id) + } + } + } + + suspend fun getRules(): List { + var rules = SagerDatabase.rulesDao.allRules() + if (rules.isEmpty() && !DataStore.rulesFirstCreate) { + DataStore.rulesFirstCreate = true + createRule( + RuleEntity( + name = app.getString(R.string.route_opt_block_quic), + port = "443", + network = "udp", + outbound = -2 + ) + ) + createRule( + RuleEntity( + name = app.getString(R.string.route_opt_block_ads), + domains = "geosite:category-ads-all", + outbound = -2 + ) + ) + createRule( + RuleEntity( + name = app.getString(R.string.route_opt_block_analysis), + domains = app.assets.open("analysis.txt").use { + it.bufferedReader() + .readLines() + .filter { it.isNotBlank() } + .joinToString("\n") + }, + outbound = -2, + ) + ) + var country = Locale.getDefault().country.lowercase() + var displayCountry = Locale.getDefault().displayCountry + if (country in arrayOf( + "ir" + ) + ) { + createRule( + RuleEntity( + name = app.getString(R.string.route_bypass_domain, displayCountry), + domains = "domain:$country", + outbound = -1 + ), false + ) + } else { + country = Locale.CHINA.country.lowercase() + displayCountry = Locale.CHINA.displayCountry + createRule( + RuleEntity( + name = app.getString(R.string.route_play_store, displayCountry), + domains = "domain:googleapis.cn", + ), false + ) + createRule( + RuleEntity( + name = app.getString(R.string.route_bypass_domain, displayCountry), + domains = "geosite:$country", + outbound = -1 + ), false + ) + } + createRule( + RuleEntity( + name = app.getString(R.string.route_bypass_ip, displayCountry), + ip = "geoip:$country", + outbound = -1 + ), false + ) + rules = SagerDatabase.rulesDao.allRules() + } + return rules + } + +} 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 0000000..467b5f6 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt @@ -0,0 +1,489 @@ +package io.nekohasekai.sagernet.database + +import android.content.Context +import android.content.Intent +import androidx.room.* +import com.esotericsoftware.kryo.io.ByteBufferInput +import com.esotericsoftware.kryo.io.ByteBufferOutput +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.fmt.* +import io.nekohasekai.sagernet.fmt.http.HttpBean +import io.nekohasekai.sagernet.fmt.http.toUri +import io.nekohasekai.sagernet.fmt.hysteria.* +import io.nekohasekai.sagernet.fmt.internal.ChainBean +import io.nekohasekai.sagernet.fmt.naive.NaiveBean +import io.nekohasekai.sagernet.fmt.naive.buildNaiveConfig +import io.nekohasekai.sagernet.fmt.naive.toUri +import io.nekohasekai.sagernet.fmt.shadowsocks.* +import io.nekohasekai.sagernet.fmt.socks.SOCKSBean +import io.nekohasekai.sagernet.fmt.socks.toUri +import io.nekohasekai.sagernet.fmt.ssh.SSHBean +import io.nekohasekai.sagernet.fmt.trojan.TrojanBean +import io.nekohasekai.sagernet.fmt.trojan.toUri +import io.nekohasekai.sagernet.fmt.trojan_go.TrojanGoBean +import io.nekohasekai.sagernet.fmt.trojan_go.buildTrojanGoConfig +import io.nekohasekai.sagernet.fmt.trojan_go.toUri +import io.nekohasekai.sagernet.fmt.tuic.TuicBean +import io.nekohasekai.sagernet.fmt.tuic.buildTuicConfig +import io.nekohasekai.sagernet.fmt.v2ray.* +import io.nekohasekai.sagernet.fmt.wireguard.WireGuardBean +import io.nekohasekai.sagernet.ktx.app +import io.nekohasekai.sagernet.ktx.applyDefaultValues +import io.nekohasekai.sagernet.ui.profile.* +import moe.matsuri.nb4a.Protocols +import moe.matsuri.nb4a.proxy.neko.* +import moe.matsuri.nb4a.proxy.config.ConfigBean +import moe.matsuri.nb4a.proxy.config.ConfigSettingActivity +import moe.matsuri.nb4a.proxy.neko.NekoBean +import moe.matsuri.nb4a.proxy.neko.NekoSettingActivity +import moe.matsuri.nb4a.proxy.neko.haveStandardLink +import moe.matsuri.nb4a.proxy.neko.shareLink + +@Entity( + tableName = "proxy_entities", indices = [Index("groupId", name = "groupId")] +) +data class ProxyEntity( + @PrimaryKey(autoGenerate = true) var id: Long = 0L, + var groupId: Long = 0L, + var type: Int = 0, + var userOrder: Long = 0L, + var tx: Long = 0L, + var rx: Long = 0L, + var status: Int = 0, + var ping: Int = 0, + var uuid: String = "", + var error: String? = null, + var socksBean: SOCKSBean? = null, + var httpBean: HttpBean? = null, + var ssBean: ShadowsocksBean? = null, + var vmessBean: VMessBean? = null, + var trojanBean: TrojanBean? = null, + var trojanGoBean: TrojanGoBean? = null, + var naiveBean: NaiveBean? = null, + var hysteriaBean: HysteriaBean? = null, + var tuicBean: TuicBean? = null, + var sshBean: SSHBean? = null, + var wgBean: WireGuardBean? = null, + var chainBean: ChainBean? = null, + var nekoBean: NekoBean? = null, + var configBean: ConfigBean? = null, +) : Serializable() { + + companion object { + const val TYPE_SOCKS = 0 + const val TYPE_HTTP = 1 + const val TYPE_SS = 2 + const val TYPE_VMESS = 4 + + const val TYPE_TROJAN = 6 + const val TYPE_TROJAN_GO = 7 + const val TYPE_NAIVE = 9 + const val TYPE_HYSTERIA = 15 + + const val TYPE_SSH = 17 + const val TYPE_WG = 18 + + const val TYPE_TUIC = 20 + + const val TYPE_CONFIG = 998 + const val TYPE_NEKO = 999 + + const val TYPE_CHAIN = 8 + + val chainName by lazy { app.getString(R.string.proxy_chain) } + + private val placeHolderBean = SOCKSBean().applyDefaultValues() + + @JvmField + val CREATOR = object : Serializable.CREATOR() { + + override fun newInstance(): ProxyEntity { + return ProxyEntity() + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + } + + @Ignore + @Transient + var info: String = "" + + @Ignore + @Transient + var dirty: Boolean = false + + override fun initializeDefaultValues() { + } + + override fun serializeToBuffer(output: ByteBufferOutput) { + output.writeInt(0) + + output.writeLong(id) + output.writeLong(groupId) + output.writeInt(type) + output.writeLong(userOrder) + output.writeLong(tx) + output.writeLong(rx) + output.writeInt(status) + output.writeInt(ping) + output.writeString(uuid) + output.writeString(error) + + val data = KryoConverters.serialize(requireBean()) + output.writeVarInt(data.size, true) + output.writeBytes(data) + + output.writeBoolean(dirty) + } + + override fun deserializeFromBuffer(input: ByteBufferInput) { + val version = input.readInt() + + id = input.readLong() + groupId = input.readLong() + type = input.readInt() + userOrder = input.readLong() + tx = input.readLong() + rx = input.readLong() + status = input.readInt() + ping = input.readInt() + uuid = input.readString() + error = input.readString() + putByteArray(input.readBytes(input.readVarInt(true))) + + dirty = input.readBoolean() + } + + + fun putByteArray(byteArray: ByteArray) { + when (type) { + TYPE_SOCKS -> socksBean = KryoConverters.socksDeserialize(byteArray) + TYPE_HTTP -> httpBean = KryoConverters.httpDeserialize(byteArray) + TYPE_SS -> ssBean = KryoConverters.shadowsocksDeserialize(byteArray) + TYPE_VMESS -> vmessBean = KryoConverters.vmessDeserialize(byteArray) + TYPE_TROJAN -> trojanBean = KryoConverters.trojanDeserialize(byteArray) + TYPE_TROJAN_GO -> trojanGoBean = KryoConverters.trojanGoDeserialize(byteArray) + TYPE_NAIVE -> naiveBean = KryoConverters.naiveDeserialize(byteArray) + TYPE_HYSTERIA -> hysteriaBean = KryoConverters.hysteriaDeserialize(byteArray) + TYPE_SSH -> sshBean = KryoConverters.sshDeserialize(byteArray) + TYPE_WG -> wgBean = KryoConverters.wireguardDeserialize(byteArray) + TYPE_TUIC -> tuicBean = KryoConverters.tuicDeserialize(byteArray) + TYPE_CHAIN -> chainBean = KryoConverters.chainDeserialize(byteArray) + TYPE_NEKO -> nekoBean = KryoConverters.nekoDeserialize(byteArray) + TYPE_CONFIG -> configBean = KryoConverters.configDeserialize(byteArray) + } + } + + fun displayType() = when (type) { + TYPE_SOCKS -> socksBean!!.protocolName() + TYPE_HTTP -> if (httpBean!!.isTLS()) "HTTPS" else "HTTP" + TYPE_SS -> "Shadowsocks" + TYPE_VMESS -> if (vmessBean!!.isVLESS) "VLESS" else "VMess" + TYPE_TROJAN -> "Trojan" + TYPE_TROJAN_GO -> "Trojan-Go" + TYPE_NAIVE -> "Naïve" + TYPE_HYSTERIA -> "Hysteria" + TYPE_SSH -> "SSH" + TYPE_WG -> "WireGuard" + TYPE_TUIC -> "TUIC" + TYPE_CHAIN -> chainName + TYPE_NEKO -> nekoBean!!.displayType() + TYPE_CONFIG -> configBean!!.displayType() + else -> "Undefined type $type" + } + + fun displayName() = requireBean().displayName() + fun displayAddress() = requireBean().displayAddress() + + fun requireBean(): AbstractBean { + return when (type) { + TYPE_SOCKS -> socksBean + TYPE_HTTP -> httpBean + TYPE_SS -> ssBean + TYPE_VMESS -> vmessBean + TYPE_TROJAN -> trojanBean + TYPE_TROJAN_GO -> trojanGoBean + TYPE_NAIVE -> naiveBean + TYPE_HYSTERIA -> hysteriaBean + TYPE_SSH -> sshBean + TYPE_WG -> wgBean + TYPE_TUIC -> tuicBean + TYPE_CHAIN -> chainBean + TYPE_NEKO -> nekoBean + TYPE_CONFIG -> configBean + else -> error("Undefined type $type") + } ?: error("Null ${displayType()} profile") + } + + fun haveLink(): Boolean { + return when (type) { + TYPE_CHAIN -> false + else -> true + } + } + + fun haveStandardLink(): Boolean { + return when (requireBean()) { + is TuicBean -> false + is SSHBean -> false + is WireGuardBean -> false + is NekoBean -> nekoBean!!.haveStandardLink() + is ConfigBean -> false + else -> true + } + } + + fun toLink(compact: Boolean = false): String? = with(requireBean()) { + when (this) { + is SOCKSBean -> toUri() + is HttpBean -> toUri() + is ShadowsocksBean -> toUri() + is VMessBean -> if (compact) toV2rayN() else toUri() + is TrojanBean -> toUri() + is TrojanGoBean -> toUri() + is NaiveBean -> toUri() + is HysteriaBean -> toUri() + is SSHBean -> toUniversalLink() + is WireGuardBean -> toUniversalLink() + is TuicBean -> toUniversalLink() + is ConfigBean -> toUniversalLink() + is NekoBean -> shareLink() + else -> null + } + } + + fun exportConfig(): Pair { + var name = "${requireBean().displayName()}.json" + + return with(requireBean()) { + StringBuilder().apply { + val config = buildConfig(this@ProxyEntity) + append(config.config) + + if (!config.externalIndex.all { it.chain.isEmpty() }) { + name = "profiles.txt" + } + + for ((chain) in config.externalIndex) { + chain.entries.forEachIndexed { index, (port, profile) -> + when (val bean = profile.requireBean()) { + is TrojanGoBean -> { + append("\n\n") + append(bean.buildTrojanGoConfig(port)) + } + is NaiveBean -> { + append("\n\n") + append(bean.buildNaiveConfig(port)) + } + is HysteriaBean -> { + append("\n\n") + append(bean.buildHysteriaConfig(port, null)) + } + is TuicBean -> { + append("\n\n") + append(bean.buildTuicConfig(port, null)) + } + } + } + } + }.toString() + } to name + } + + fun needExternal(): Boolean { + return when (type) { + TYPE_TROJAN_GO -> true + TYPE_NAIVE -> true + TYPE_HYSTERIA -> !hysteriaBean!!.canUseSingBox() + TYPE_TUIC -> true + TYPE_NEKO -> true + else -> false + } + } + + fun isV2RayNetworkTcp(): Boolean { + val bean = requireBean() as StandardV2RayBean + return when (bean.type) { + "tcp", "ws", "http" -> true + else -> false + } + } + + fun needCoreMux(): Boolean { + return when (type) { + TYPE_VMESS -> isV2RayNetworkTcp() && Protocols.shouldEnableMux("vmess") + TYPE_TROJAN -> isV2RayNetworkTcp() && Protocols.shouldEnableMux("trojan") + TYPE_SS -> !ssBean!!.sUoT && Protocols.shouldEnableMux("shadowsocks") + else -> false + } + } + + fun putBean(bean: AbstractBean): ProxyEntity { + socksBean = null + httpBean = null + ssBean = null + vmessBean = null + trojanBean = null + trojanGoBean = null + naiveBean = null + hysteriaBean = null + sshBean = null + wgBean = null + tuicBean = null + chainBean = null + configBean = null + nekoBean = null + + when (bean) { + is SOCKSBean -> { + type = TYPE_SOCKS + socksBean = bean + } + is HttpBean -> { + type = TYPE_HTTP + httpBean = bean + } + is ShadowsocksBean -> { + type = TYPE_SS + ssBean = bean + } + is VMessBean -> { + type = TYPE_VMESS + vmessBean = bean + } + is TrojanBean -> { + type = TYPE_TROJAN + trojanBean = bean + } + is TrojanGoBean -> { + type = TYPE_TROJAN_GO + trojanGoBean = bean + } + is NaiveBean -> { + type = TYPE_NAIVE + naiveBean = bean + } + is HysteriaBean -> { + type = TYPE_HYSTERIA + hysteriaBean = bean + } + is SSHBean -> { + type = TYPE_SSH + sshBean = bean + } + is WireGuardBean -> { + type = TYPE_WG + wgBean = bean + } + is TuicBean -> { + type = TYPE_TUIC + tuicBean = bean + } + is ChainBean -> { + type = TYPE_CHAIN + chainBean = bean + } + is NekoBean -> { + type = TYPE_NEKO + nekoBean = bean + } + is ConfigBean -> { + type = TYPE_CONFIG + configBean = bean + } + else -> error("Undefined type $type") + } + return this + } + + fun settingIntent(ctx: Context, isSubscription: Boolean): Intent { + return Intent( + ctx, when (type) { + TYPE_SOCKS -> SocksSettingsActivity::class.java + TYPE_HTTP -> HttpSettingsActivity::class.java + TYPE_SS -> ShadowsocksSettingsActivity::class.java + TYPE_VMESS -> VMessSettingsActivity::class.java + TYPE_TROJAN -> TrojanSettingsActivity::class.java + TYPE_TROJAN_GO -> TrojanGoSettingsActivity::class.java + TYPE_NAIVE -> NaiveSettingsActivity::class.java + TYPE_HYSTERIA -> HysteriaSettingsActivity::class.java + TYPE_SSH -> SSHSettingsActivity::class.java + TYPE_WG -> WireGuardSettingsActivity::class.java + TYPE_TUIC -> TuicSettingsActivity::class.java + TYPE_CHAIN -> ChainSettingsActivity::class.java + TYPE_NEKO -> NekoSettingActivity::class.java + TYPE_CONFIG -> ConfigSettingActivity::class.java + else -> throw IllegalArgumentException() + } + ).apply { + putExtra(ProfileSettingsActivity.EXTRA_PROFILE_ID, id) + putExtra(ProfileSettingsActivity.EXTRA_IS_SUBSCRIPTION, isSubscription) + } + } + + @androidx.room.Dao + interface Dao { + + @Query("select * from proxy_entities") + fun getAll(): List + + @Query("SELECT id FROM proxy_entities WHERE groupId = :groupId ORDER BY userOrder") + fun getIdsByGroup(groupId: Long): List + + @Query("SELECT * FROM proxy_entities WHERE groupId = :groupId ORDER BY userOrder") + fun getByGroup(groupId: Long): List + + @Query("SELECT * FROM proxy_entities WHERE id in (:proxyIds)") + fun getEntities(proxyIds: List): List + + @Query("SELECT COUNT(*) FROM proxy_entities WHERE groupId = :groupId") + fun countByGroup(groupId: Long): Long + + @Query("SELECT MAX(userOrder) + 1 FROM proxy_entities WHERE groupId = :groupId") + fun nextOrder(groupId: Long): Long? + + @Query("SELECT * FROM proxy_entities WHERE id = :proxyId") + fun getById(proxyId: Long): ProxyEntity? + + @Query("DELETE FROM proxy_entities WHERE id IN (:proxyId)") + fun deleteById(proxyId: Long): Int + + @Query("DELETE FROM proxy_entities WHERE groupId = :groupId") + fun deleteByGroup(groupId: Long) + + @Query("DELETE FROM proxy_entities WHERE groupId in (:groupId)") + fun deleteByGroup(groupId: LongArray) + + @Delete + fun deleteProxy(proxy: ProxyEntity): Int + + @Delete + fun deleteProxy(proxies: List): Int + + @Update + fun updateProxy(proxy: ProxyEntity): Int + + @Update + fun updateProxy(proxies: List): Int + + @Insert + fun addProxy(proxy: ProxyEntity): Long + + @Insert + fun insert(proxies: List) + + @Query("DELETE FROM proxy_entities WHERE groupId = :groupId") + fun deleteAll(groupId: Long): Int + + @Query("DELETE FROM proxy_entities") + fun reset() + + } + + override fun describeContents(): Int { + return 0 + } +} \ 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 0000000..5d2f94f --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/database/ProxyGroup.kt @@ -0,0 +1,140 @@ +package io.nekohasekai.sagernet.database + +import androidx.room.* +import com.esotericsoftware.kryo.io.ByteBufferInput +import com.esotericsoftware.kryo.io.ByteBufferOutput +import io.nekohasekai.sagernet.GroupOrder +import io.nekohasekai.sagernet.GroupType +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.fmt.Serializable +import io.nekohasekai.sagernet.ktx.app +import io.nekohasekai.sagernet.ktx.applyDefaultValues + +@Entity(tableName = "proxy_groups") +data class ProxyGroup( + @PrimaryKey(autoGenerate = true) var id: Long = 0L, + var userOrder: Long = 0L, + var ungrouped: Boolean = false, + var name: String? = null, + var type: Int = GroupType.BASIC, + var subscription: SubscriptionBean? = null, + var order: Int = GroupOrder.ORIGIN, +) : Serializable() { + + @Transient + var export = false + + override fun initializeDefaultValues() { + subscription?.applyDefaultValues() + } + + override fun serializeToBuffer(output: ByteBufferOutput) { + if (export) { + + output.writeInt(0) + output.writeString(name) + output.writeInt(type) + val subscription = subscription!! + subscription.serializeForShare(output) + + } else { + output.writeInt(0) + output.writeLong(id) + output.writeLong(userOrder) + output.writeBoolean(ungrouped) + output.writeString(name) + output.writeInt(type) + + if (type == GroupType.SUBSCRIPTION) { + subscription?.serializeToBuffer(output) + } + output.writeInt(order) + } + } + + override fun deserializeFromBuffer(input: ByteBufferInput) { + if (export) { + val version = input.readInt() + + name = input.readString() + type = input.readInt() + val subscription = SubscriptionBean() + this.subscription = subscription + + subscription.deserializeFromShare(input) + } else { + val version = input.readInt() + + id = input.readLong() + userOrder = input.readLong() + ungrouped = input.readBoolean() + name = input.readString() + type = input.readInt() + + if (type == GroupType.SUBSCRIPTION) { + val subscription = SubscriptionBean() + this.subscription = subscription + + subscription.deserializeFromBuffer(input) + } + order = input.readInt() + } + } + + fun displayName(): String { + return name.takeIf { !it.isNullOrBlank() } ?: app.getString(R.string.group_default) + } + + @androidx.room.Dao + interface Dao { + + @Query("SELECT * FROM proxy_groups ORDER BY userOrder") + fun allGroups(): List + + @Query("SELECT * FROM proxy_groups WHERE type = ${GroupType.SUBSCRIPTION}") + suspend fun subscriptions(): List + + @Query("SELECT MAX(userOrder) + 1 FROM proxy_groups") + fun nextOrder(): Long? + + @Query("SELECT * FROM proxy_groups WHERE id = :groupId") + fun getById(groupId: Long): ProxyGroup? + + @Query("DELETE FROM proxy_groups WHERE id = :groupId") + fun deleteById(groupId: Long): Int + + @Delete + fun deleteGroup(group: ProxyGroup) + + @Delete + fun deleteGroup(groupList: List) + + @Insert + fun createGroup(group: ProxyGroup): Long + + @Update + fun updateGroup(group: ProxyGroup) + + @Query("DELETE FROM proxy_groups") + fun reset() + + @Insert + fun insert(groupList: List) + + } + + companion object { + @JvmField + val CREATOR = object : Serializable.CREATOR() { + + override fun newInstance(): ProxyGroup { + return ProxyGroup() + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/RuleEntity.kt b/app/src/main/java/io/nekohasekai/sagernet/database/RuleEntity.kt new file mode 100644 index 0000000..20cbbc0 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/database/RuleEntity.kt @@ -0,0 +1,106 @@ +package io.nekohasekai.sagernet.database + +import android.os.Parcelable +import androidx.room.* +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.ktx.app +import kotlinx.parcelize.Parcelize + +@Entity(tableName = "rules") +@Parcelize +data class RuleEntity( + @PrimaryKey(autoGenerate = true) var id: Long = 0L, + var name: String = "", + var userOrder: Long = 0L, + var enabled: Boolean = false, + var domains: String = "", + var ip: String = "", + var port: String = "", + var sourcePort: String = "", + var network: String = "", + var source: String = "", + var protocol: String = "", + var outbound: Long = 0, + var packages: List = listOf(), +) : Parcelable { + + fun displayName(): String { + return name.takeIf { it.isNotBlank() } ?: "Rule $id" + } + + fun mkSummary(): String { + var summary = "" + if (domains.isNotBlank()) summary += "$domains\n" + if (ip.isNotBlank()) summary += "$ip\n" + if (source.isNotBlank()) summary += "source: $source\n" + if (sourcePort.isNotBlank()) summary += "sourcePort: $sourcePort\n" + if (port.isNotBlank()) summary += "port: $port\n" + if (network.isNotBlank()) summary += "network: $network\n" + if (protocol.isNotBlank()) summary += "protocol: $protocol\n" + if (packages.isNotEmpty()) summary += app.getString( + R.string.apps_message, packages.size + ) + "\n" + val lines = summary.trim().split("\n") + return if (lines.size > 3) { + lines.subList(0, 3).joinToString("\n", postfix = "\n...") + } else { + summary.trim() + } + } + + fun displayOutbound(): String { + return when (outbound) { + 0L -> app.getString(R.string.route_proxy) + -1L -> app.getString(R.string.route_bypass) + -2L -> app.getString(R.string.route_block) + else -> ProfileManager.getProfile(outbound)?.displayName() + ?: app.getString(R.string.route_proxy) + } + } + + @androidx.room.Dao + interface Dao { + + @Query("SELECT * from rules WHERE (packages != '') AND enabled = 1") + fun checkVpnNeeded(): List + + @Query("SELECT * FROM rules ORDER BY userOrder") + fun allRules(): List + + @Query("SELECT * FROM rules WHERE enabled = :enabled ORDER BY userOrder") + fun enabledRules(enabled: Boolean = true): List + + @Query("SELECT MAX(userOrder) + 1 FROM rules") + fun nextOrder(): Long? + + @Query("SELECT * FROM rules WHERE id = :ruleId") + fun getById(ruleId: Long): RuleEntity? + + @Query("DELETE FROM rules WHERE id = :ruleId") + fun deleteById(ruleId: Long): Int + + @Delete + fun deleteRule(rule: RuleEntity) + + @Delete + fun deleteRules(rules: List) + + @Insert + fun createRule(rule: RuleEntity): Long + + @Update + fun updateRule(rule: RuleEntity) + + @Update + fun updateRules(rules: List) + + @Query("DELETE FROM rules") + fun reset() + + @Insert + fun insert(rules: List) + + } + + +} \ 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 0000000..a88d9fa --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt @@ -0,0 +1,48 @@ +package io.nekohasekai.sagernet.database + +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import dev.matrix.roomigrant.GenerateRoomMigrations +import io.nekohasekai.sagernet.Key +import io.nekohasekai.sagernet.SagerNet +import io.nekohasekai.sagernet.fmt.KryoConverters +import io.nekohasekai.sagernet.fmt.gson.GsonConverters +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +@Database( + entities = [ProxyGroup::class, ProxyEntity::class, RuleEntity::class], + version = 1 +) +@TypeConverters(value = [KryoConverters::class, GsonConverters::class]) +@GenerateRoomMigrations +abstract class SagerDatabase : RoomDatabase() { + + companion object { + @OptIn(DelicateCoroutinesApi::class) + @Suppress("EXPERIMENTAL_API_USAGE") + private val instance by lazy { + SagerNet.application.getDatabasePath(Key.DB_PROFILE).parentFile?.mkdirs() + Room.databaseBuilder(SagerNet.application, SagerDatabase::class.java, Key.DB_PROFILE) + .addMigrations(*SagerDatabase_Migrations.build()) + .allowMainThreadQueries() + .enableMultiInstanceInvalidation() + .fallbackToDestructiveMigration() + .setQueryExecutor { GlobalScope.launch { it.run() } } + .build() + } + + val groupDao get() = instance.groupDao() + val proxyDao get() = instance.proxyDao() + val rulesDao get() = instance.rulesDao() + + } + + abstract fun groupDao(): ProxyGroup.Dao + abstract fun proxyDao(): ProxyEntity.Dao + abstract fun rulesDao(): RuleEntity.Dao + +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/SubscriptionBean.java b/app/src/main/java/io/nekohasekai/sagernet/database/SubscriptionBean.java new file mode 100644 index 0000000..1e7e07a --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/database/SubscriptionBean.java @@ -0,0 +1,138 @@ +package io.nekohasekai.sagernet.database; + +import androidx.annotation.NonNull; + +import com.esotericsoftware.kryo.io.ByteBufferInput; +import com.esotericsoftware.kryo.io.ByteBufferOutput; + +import java.util.ArrayList; +import java.util.List; + +import io.nekohasekai.sagernet.fmt.Serializable; + +public class SubscriptionBean extends Serializable { + + public Integer type; + public String link; + public String token; + public Boolean forceResolve; + public Boolean deduplication; + public Boolean updateWhenConnectedOnly; + public String customUserAgent; + public Boolean autoUpdate; + public Integer autoUpdateDelay; + public Integer lastUpdated; + + // SIP008 + + public Long bytesUsed; + public Long bytesRemaining; + + // Open Online Config + + public String username; + public Integer expiryDate; + public List protocols; + + + // https://github.com/crossutility/Quantumult/blob/master/extra-subscription-feature.md + + public String subscriptionUserinfo; + + public SubscriptionBean() { + } + + @Override + public void serializeToBuffer(ByteBufferOutput output) { + output.writeInt(1); + + output.writeInt(type); + + output.writeString(link); + + output.writeBoolean(forceResolve); + output.writeBoolean(deduplication); + output.writeBoolean(updateWhenConnectedOnly); + output.writeString(customUserAgent); + output.writeBoolean(autoUpdate); + output.writeInt(autoUpdateDelay); + output.writeInt(lastUpdated); + + output.writeString(subscriptionUserinfo); + } + + public void serializeForShare(ByteBufferOutput output) { + output.writeInt(0); + + output.writeInt(type); + + output.writeString(link); + + output.writeBoolean(forceResolve); + output.writeBoolean(deduplication); + output.writeBoolean(updateWhenConnectedOnly); + output.writeString(customUserAgent); + } + + @Override + public void deserializeFromBuffer(ByteBufferInput input) { + int version = input.readInt(); + + type = input.readInt(); + link = input.readString(); + forceResolve = input.readBoolean(); + deduplication = input.readBoolean(); + updateWhenConnectedOnly = input.readBoolean(); + customUserAgent = input.readString(); + autoUpdate = input.readBoolean(); + autoUpdateDelay = input.readInt(); + lastUpdated = input.readInt(); + subscriptionUserinfo = input.readString(); + } + + public void deserializeFromShare(ByteBufferInput input) { + int version = input.readInt(); + + type = input.readInt(); + link = input.readString(); + forceResolve = input.readBoolean(); + deduplication = input.readBoolean(); + updateWhenConnectedOnly = input.readBoolean(); + customUserAgent = input.readString(); + } + + @Override + public void initializeDefaultValues() { + if (type == null) type = 0; + if (link == null) link = ""; + if (token == null) token = ""; + if (forceResolve == null) forceResolve = false; + if (deduplication == null) deduplication = false; + if (updateWhenConnectedOnly == null) updateWhenConnectedOnly = false; + if (customUserAgent == null) customUserAgent = ""; + if (autoUpdate == null) autoUpdate = false; + if (autoUpdateDelay == null) autoUpdateDelay = 1440; + if (lastUpdated == null) lastUpdated = 0; + + if (bytesUsed == null) bytesUsed = 0L; + if (bytesRemaining == null) bytesRemaining = 0L; + + if (username == null) username = ""; + if (expiryDate == null) expiryDate = 0; + if (protocols == null) protocols = new ArrayList<>(); + } + + public static final Creator CREATOR = new CREATOR() { + @NonNull + @Override + public SubscriptionBean newInstance() { + return new SubscriptionBean(); + } + + @Override + public SubscriptionBean[] newArray(int size) { + return new SubscriptionBean[size]; + } + }; + +} 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 0000000..501b18f --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/database/preference/EditTextPreferenceModifiers.kt @@ -0,0 +1,43 @@ +package io.nekohasekai.sagernet.database.preference + +import android.graphics.Typeface +import android.text.InputFilter +import android.view.inputmethod.EditorInfo +import android.widget.EditText +import androidx.preference.EditTextPreference + +object EditTextPreferenceModifiers { + object Monospace : EditTextPreference.OnBindEditTextListener { + override fun onBindEditText(editText: EditText) { + editText.typeface = Typeface.MONOSPACE + } + } + + object Hosts : EditTextPreference.OnBindEditTextListener { + + override fun onBindEditText(editText: EditText) { + editText.setHorizontallyScrolling(true) + editText.setSelection(editText.text.length) + } + } + + object Port : EditTextPreference.OnBindEditTextListener { + private val portLengthFilter = arrayOf(InputFilter.LengthFilter(5)) + + override fun onBindEditText(editText: EditText) { + editText.inputType = EditorInfo.TYPE_CLASS_NUMBER + editText.filters = portLengthFilter + editText.setSingleLine() + editText.setSelection(editText.text.length) + } + } + + object Number : EditTextPreference.OnBindEditTextListener { + + override fun onBindEditText(editText: EditText) { + editText.inputType = EditorInfo.TYPE_CLASS_NUMBER + editText.setSingleLine() + editText.setSelection(editText.text.length) + } + } +} 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 0000000..0d13ee9 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/database/preference/KeyValuePair.kt @@ -0,0 +1,170 @@ +package io.nekohasekai.sagernet.database.preference + +import android.os.Parcel +import android.os.Parcelable +import androidx.room.* +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer + +@Entity +class KeyValuePair() : Parcelable { + companion object { + const val TYPE_UNINITIALIZED = 0 + const val TYPE_BOOLEAN = 1 + const val TYPE_FLOAT = 2 + + @Deprecated("Use TYPE_LONG.") + const val TYPE_INT = 3 + const val TYPE_LONG = 4 + const val TYPE_STRING = 5 + const val TYPE_STRING_SET = 6 + + @JvmField + val CREATOR = object : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): KeyValuePair { + return KeyValuePair(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + } + + @androidx.room.Dao + interface Dao { + + @Query("SELECT * FROM `KeyValuePair`") + fun all(): List + + @Query("SELECT * FROM `KeyValuePair` WHERE `key` = :key") + operator fun get(key: String): KeyValuePair? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun put(value: KeyValuePair): Long + + @Query("DELETE FROM `KeyValuePair` WHERE `key` = :key") + fun delete(key: String): Int + + @Query("DELETE FROM `KeyValuePair`") + fun reset(): Int + + @Insert + fun insert(list: List) + } + + @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 + } + + @Suppress("IMPLICIT_CAST_TO_ANY") + override fun toString(): String { + return when (valueType) { + TYPE_BOOLEAN -> boolean + TYPE_FLOAT -> float + TYPE_LONG -> long + TYPE_STRING -> string + TYPE_STRING_SET -> stringSet + else -> null + }?.toString() ?: "null" + } + + constructor(parcel: Parcel) : this() { + key = parcel.readString()!! + valueType = parcel.readInt() + value = parcel.createByteArray()!! + } + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeString(key) + parcel.writeInt(valueType) + parcel.writeByteArray(value) + } + + override fun describeContents(): Int { + return 0 + } + +} 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 0000000..9cca6d3 --- /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 0000000..5c8a7bd --- /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.SagerNet +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +@Database(entities = [KeyValuePair::class], version = 1) +@GenerateRoomMigrations +abstract class PublicDatabase : RoomDatabase() { + companion object { + private val instance by lazy { + SagerNet.application.getDatabasePath(Key.DB_PROFILE).parentFile?.mkdirs() + Room.databaseBuilder(SagerNet.application, PublicDatabase::class.java, Key.DB_PUBLIC) + .allowMainThreadQueries() + .enableMultiInstanceInvalidation() + .fallbackToDestructiveMigration() + .setQueryExecutor { GlobalScope.launch { it.run() } } + .build() + } + + val kvPairDao get() = instance.keyValuePairDao() + } + + abstract fun keyValuePairDao(): KeyValuePair.Dao + +} 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 0000000..4d90479 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/database/preference/RoomPreferenceDataStore.kt @@ -0,0 +1,90 @@ +package io.nekohasekai.sagernet.database.preference + +import androidx.preference.PreferenceDataStore + +@Suppress("MemberVisibilityCanBePrivate", "unused") +open class RoomPreferenceDataStore(private val kvPairDao: KeyValuePair.Dao) : + PreferenceDataStore() { + + fun getBoolean(key: String) = kvPairDao[key]?.boolean + fun getFloat(key: String) = kvPairDao[key]?.float + fun getInt(key: String) = kvPairDao[key]?.long?.toInt() + fun getLong(key: String) = kvPairDao[key]?.long + fun getString(key: String) = kvPairDao[key]?.string + fun getStringSet(key: String) = kvPairDao[key]?.stringSet + fun reset() = kvPairDao.reset() + + override fun getBoolean(key: String, defValue: Boolean) = getBoolean(key) ?: defValue + override fun getFloat(key: String, defValue: Float) = getFloat(key) ?: defValue + override fun getInt(key: String, defValue: Int) = getInt(key) ?: defValue + override fun getLong(key: String, defValue: Long) = getLong(key) ?: defValue + override fun getString(key: String, defValue: String?) = getString(key) ?: defValue + override fun getStringSet(key: String, defValue: MutableSet?) = + 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) { + val listeners = synchronized(listeners) { + listeners.toList() + } + listeners.forEach { it.onPreferenceDataStoreChanged(this, key) } + } + + fun registerChangeListener(listener: OnPreferenceDataStoreChangeListener) { + synchronized(listeners) { + listeners.add(listener) + } + } + + fun unregisterChangeListener(listener: OnPreferenceDataStoreChangeListener) { + synchronized(listeners) { + listeners.remove(listener) + } + } +} 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 0000000..53dda06 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/AbstractBean.java @@ -0,0 +1,151 @@ +package io.nekohasekai.sagernet.fmt; + +import androidx.annotation.NonNull; + +import com.esotericsoftware.kryo.io.ByteBufferInput; +import com.esotericsoftware.kryo.io.ByteBufferOutput; + +import org.jetbrains.annotations.NotNull; + +import java.util.Arrays; + +import io.nekohasekai.sagernet.ktx.NetsKt; +import moe.matsuri.nb4a.utils.JavaUtil; + +public abstract class AbstractBean extends Serializable { + + public String serverAddress; + public Integer serverPort; + + public String name; + + // + + public String customOutboundJson; + public String customConfigJson; + + // + public transient String finalAddress; + public transient int finalPort; + + public String displayName() { + if (JavaUtil.isNotBlank(name)) { + return name; + } else { + return displayAddress(); + } + } + + public String displayAddress() { + return NetsKt.wrapIPV6Host(serverAddress) + ":" + serverPort; + } + + public String network() { + return "tcp,udp"; + } + + public boolean canICMPing() { + return true; + } + + public boolean canTCPing() { + return true; + } + + public boolean canMapping() { + return true; + } + + @Override + public void initializeDefaultValues() { + if (JavaUtil.isNullOrBlank(serverAddress)) { + serverAddress = "127.0.0.1"; + } else if (serverAddress.startsWith("[") && serverAddress.endsWith("]")) { + serverAddress = NetsKt.unwrapIPV6Host(serverAddress); + } + if (serverPort == null) { + serverPort = 1080; + } + if (name == null) name = ""; + + finalAddress = serverAddress; + finalPort = serverPort; + + if (customOutboundJson == null) customOutboundJson = ""; + if (customConfigJson == null) customConfigJson = ""; + } + + + private transient boolean serializeWithoutName; + + @Override + public void serializeToBuffer(@NonNull ByteBufferOutput output) { + serialize(output); + + output.writeInt(1); + if (!serializeWithoutName) { + output.writeString(name); + } + output.writeString(customOutboundJson); + output.writeString(customConfigJson); + } + + @Override + public void deserializeFromBuffer(@NonNull ByteBufferInput input) { + deserialize(input); + + int extraVersion = input.readInt(); + + name = input.readString(); + customOutboundJson = input.readString(); + customConfigJson = input.readString(); + } + + public void serialize(ByteBufferOutput output) { + output.writeString(serverAddress); + output.writeInt(serverPort); + } + + public void deserialize(ByteBufferInput input) { + serverAddress = input.readString(); + serverPort = input.readInt(); + } + + @NotNull + @Override + public abstract AbstractBean clone(); + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + try { + serializeWithoutName = true; + ((AbstractBean) o).serializeWithoutName = true; + return Arrays.equals(KryoConverters.serialize(this), KryoConverters.serialize((AbstractBean) o)); + } finally { + serializeWithoutName = false; + ((AbstractBean) o).serializeWithoutName = false; + } + } + + @Override + public int hashCode() { + try { + serializeWithoutName = true; + return Arrays.hashCode(KryoConverters.serialize(this)); + } finally { + serializeWithoutName = false; + } + } + + @NotNull + @Override + public String toString() { + return getClass().getSimpleName() + " " + JavaUtil.gson.toJson(this); + } + + public void applyFeatureSettings(AbstractBean other) { + } + +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt new file mode 100644 index 0000000..0dbf390 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt @@ -0,0 +1,710 @@ +package io.nekohasekai.sagernet.fmt + +import io.nekohasekai.sagernet.IPv6Mode +import io.nekohasekai.sagernet.Key +import io.nekohasekai.sagernet.bg.VpnService +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.ProxyEntity +import io.nekohasekai.sagernet.database.ProxyEntity.Companion.TYPE_CONFIG +import io.nekohasekai.sagernet.database.SagerDatabase +import io.nekohasekai.sagernet.fmt.ConfigBuildResult.IndexEntity +import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean +import io.nekohasekai.sagernet.fmt.hysteria.buildSingBoxOutboundHysteriaBean +import io.nekohasekai.sagernet.fmt.hysteria.isMultiPort +import io.nekohasekai.sagernet.fmt.internal.ChainBean +import io.nekohasekai.sagernet.fmt.shadowsocks.ShadowsocksBean +import io.nekohasekai.sagernet.fmt.shadowsocks.buildSingBoxOutboundShadowsocksBean +import io.nekohasekai.sagernet.fmt.socks.SOCKSBean +import io.nekohasekai.sagernet.fmt.socks.buildSingBoxOutboundSocksBean +import io.nekohasekai.sagernet.fmt.ssh.SSHBean +import io.nekohasekai.sagernet.fmt.ssh.buildSingBoxOutboundSSHBean +import io.nekohasekai.sagernet.fmt.tuic.TuicBean +import moe.matsuri.nb4a.SingBoxOptions.* +import io.nekohasekai.sagernet.fmt.v2ray.StandardV2RayBean +import io.nekohasekai.sagernet.fmt.v2ray.buildSingBoxOutboundStandardV2RayBean +import io.nekohasekai.sagernet.fmt.wireguard.WireGuardBean +import io.nekohasekai.sagernet.fmt.wireguard.buildSingBoxOutboundWireguardBean +import io.nekohasekai.sagernet.ktx.isIpAddress +import io.nekohasekai.sagernet.ktx.mkPort +import io.nekohasekai.sagernet.utils.PackageCache +import moe.matsuri.nb4a.DNS.applyDNSNetworkSettings +import moe.matsuri.nb4a.DNS.makeSingBoxRule +import moe.matsuri.nb4a.proxy.config.ConfigBean +import moe.matsuri.nb4a.plugin.Plugins +import moe.matsuri.nb4a.utils.JavaUtil.gson +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +const val TAG_MIXED = "mixed-in" +const val TAG_TRANS = "trans-in" + +const val TAG_PROXY = "proxy" +const val TAG_DIRECT = "direct" +const val TAG_BYPASS = "bypass" +const val TAG_BLOCK = "block" + +const val TAG_DNS_IN = "dns-in" +const val TAG_DNS_OUT = "dns-out" + +const val LOCALHOST = "127.0.0.1" +const val LOCAL_DNS_SERVER = "underlying://0.0.0.0" + +class ConfigBuildResult( + var config: String, + var externalIndex: List, + var outboundTags: List, + var outboundTagMain: String, + var trafficMap: Map, + val alerts: List>, +) { + data class IndexEntity(var chain: LinkedHashMap) +} + +fun mergeJSON(j: String, to: MutableMap) { + if (j.isNullOrBlank()) return + val m = gson.fromJson(j, to.javaClass) + m.forEach { (k, v) -> + if (v is Map<*, *> && to[k] is Map<*, *>) { + val currentMap = (to[k] as Map<*, *>).toMutableMap() + currentMap += v + to[k] = currentMap + } else { + to[k] = v + } + } +} + +fun buildConfig( + proxy: ProxyEntity, forTest: Boolean = false +): ConfigBuildResult { + + if (proxy.type == TYPE_CONFIG) { + val bean = proxy.requireBean() as ConfigBean + if (bean.type == 0) { + return ConfigBuildResult( + bean.config, + listOf(), + listOf(TAG_PROXY), // + TAG_PROXY, // + mapOf( + TAG_PROXY to proxy + ), + listOf() + ) + } + } + + val outboundTags = ArrayList() + var outboundTagMain = TAG_BYPASS + val trafficMap = HashMap() + val globalOutbounds = ArrayList() + + fun ProxyEntity.resolveChain(): MutableList { + val bean = requireBean() + if (bean is ChainBean) { + val beans = SagerDatabase.proxyDao.getEntities(bean.proxies) + val beansMap = beans.associateBy { it.id } + val beanList = ArrayList() + for (proxyId in bean.proxies) { + val item = beansMap[proxyId] ?: continue + beanList.addAll(item.resolveChain()) + } + return beanList.asReversed() + } + return mutableListOf(this) + } + + val proxies = proxy.resolveChain() + val extraRules = if (forTest) listOf() else SagerDatabase.rulesDao.enabledRules() + val extraProxies = + if (forTest) mapOf() else SagerDatabase.proxyDao.getEntities(extraRules.mapNotNull { rule -> + rule.outbound.takeIf { it > 0 && it != proxy.id } + }.toHashSet().toList()).associate { it.id to it.resolveChain() } + + val uidListDNSRemote = mutableListOf() + val uidListDNSDirect = mutableListOf() + val domainListDNSRemote = mutableListOf() + val domainListDNSDirect = mutableListOf() + val domainListDNSBlock = mutableListOf() + val bypassDNSBeans = hashSetOf() + val isVPN = DataStore.serviceMode == Key.MODE_VPN + val bind = if (!forTest && DataStore.allowAccess) "0.0.0.0" else LOCALHOST + val remoteDns = DataStore.remoteDns.split("\n") + .mapNotNull { dns -> dns.trim().takeIf { it.isNotBlank() && !it.startsWith("#") } } + var directDNS = DataStore.directDns.split("\n") + .mapNotNull { dns -> dns.trim().takeIf { it.isNotBlank() && !it.startsWith("#") } } + val enableDnsRouting = DataStore.enableDnsRouting + val useFakeDns = DataStore.enableFakeDns && !forTest && DataStore.ipv6Mode != IPv6Mode.ONLY + val needSniff = DataStore.trafficSniffing + val externalIndexMap = ArrayList() + val requireTransproxy = if (forTest) false else DataStore.requireTransproxy + val ipv6Mode = if (forTest) IPv6Mode.ENABLE else DataStore.ipv6Mode + val resolveDestination = DataStore.resolveDestination + val alerts = mutableListOf>() + + var optionsToMerge: String = "" + + return MyOptions().apply { + if (!forTest && DataStore.enableClashAPI) experimental = ExperimentalOptions().apply { + clash_api = ClashAPIOptions().apply { + external_controller = "127.0.0.1:9090" + external_ui = "../files/yacd" + cache_file = "../cache/clash.db" + } + } + + dns = DNSOptions().apply { + // TODO nb4a hosts? +// hosts = DataStore.hosts.split("\n") +// .filter { it.isNotBlank() } +// .associate { it.substringBefore(" ") to it.substringAfter(" ") } +// .toMutableMap() + + servers = mutableListOf() + rules = mutableListOf() + + when (ipv6Mode) { + IPv6Mode.DISABLE -> { + strategy = "ipv4_only" + } + IPv6Mode.ONLY -> { + strategy = "ipv6_only" + } + } + } + + inbounds = mutableListOf() + + if (!forTest) { + if (isVPN) inbounds.add(Inbound_TunOptions().apply { + type = "tun" + tag = "tun-in" + stack = if (DataStore.tunImplementation == 1) "system" else "gvisor" + sniff = needSniff + endpoint_independent_nat = true + when (ipv6Mode) { + IPv6Mode.DISABLE -> { + inet4_address = listOf(VpnService.PRIVATE_VLAN4_CLIENT + "/28") + } + IPv6Mode.ONLY -> { + inet6_address = listOf(VpnService.PRIVATE_VLAN6_CLIENT + "/126") + } + else -> { + inet4_address = listOf(VpnService.PRIVATE_VLAN4_CLIENT + "/28") + inet6_address = listOf(VpnService.PRIVATE_VLAN6_CLIENT + "/126") + } + } + }) + inbounds.add(Inbound_MixedOptions().apply { + type = "mixed" + tag = TAG_MIXED + listen = bind + listen_port = DataStore.mixedPort + if (needSniff) { + sniff = true +// destOverride = when { +// useFakeDns && !trafficSniffing -> listOf("fakedns") +// useFakeDns -> listOf("fakedns", "http", "tls", "quic") +// else -> listOf("http", "tls", "quic") +// } +// metadataOnly = useFakeDns && !trafficSniffing +// routeOnly = true + } + }) + } + + if (requireTransproxy) { + if (DataStore.transproxyMode == 1) { + inbounds.add(Inbound_TProxyOptions().apply { + type = "tproxy" + tag = TAG_TRANS + listen = bind + listen_port = DataStore.transproxyPort + sniff = needSniff + }) + } else { + inbounds.add(Inbound_RedirectOptions().apply { + type = "redirect" + tag = TAG_TRANS + listen = bind + listen_port = DataStore.transproxyPort + sniff = needSniff + }) + } + } + + outbounds = mutableListOf() + + // init routing object + route = RouteOptions().apply { + auto_detect_interface = true + rules = mutableListOf() + } + + // returns outbound tag + fun buildChain( + chainId: Long, profileList: List + ): String { + var currentOutbound = mutableMapOf() + lateinit var pastOutbound: MutableMap + lateinit var pastInboundTag: String + var pastEntity: ProxyEntity? = null + val externalChainMap = LinkedHashMap() + externalIndexMap.add(IndexEntity(externalChainMap)) + val chainOutbounds = ArrayList>() + + // chainTagOut: v2ray outbound tag for this chain + var chainTagOut = "" + var chainTag = "c-$chainId" + var muxApplied = false + + fun genDomainStrategy(noAsIs: Boolean): String { + return when { + !resolveDestination && !noAsIs -> "" + ipv6Mode == IPv6Mode.DISABLE -> "ipv4_only" + ipv6Mode == IPv6Mode.PREFER -> "prefer_ipv6" + ipv6Mode == IPv6Mode.ONLY -> "ipv6_only" + else -> "prefer_ipv4" + } + } + + var currentDomainStrategy = genDomainStrategy(false) + + profileList.forEachIndexed { index, proxyEntity -> + val bean = proxyEntity.requireBean() + + // tagOut: v2ray outbound tag for a profile + // profile2 (in) (global) tag g-(id) + // profile1 tag (chainTag)-(id) + // profile0 (out) tag (chainTag)-(id) / single: "proxy" + var tagOut = "$chainTag-${proxyEntity.id}" + + // needGlobal: can only contain one? + var needGlobal = false + + // first profile set as global + if (index == profileList.lastIndex) { + needGlobal = true + tagOut = "g-" + proxyEntity.id + bypassDNSBeans += proxyEntity.requireBean() + } + + // last profile set as "proxy" + if (chainId == 0L && index == 0) { + tagOut = TAG_PROXY + } + + // chain rules + if (index > 0) { + // chain route/proxy rules + if (pastEntity!!.needExternal()) { + route.rules.add(Rule_DefaultOptions().apply { + inbound = listOf(pastInboundTag) + outbound = tagOut + }) + } else { + pastOutbound["detour"] = tagOut + } + } else { + // index == 0 means last profile in chain / not chain + chainTagOut = tagOut + outboundTags.add(tagOut) + if (chainId == 0L) outboundTagMain = tagOut + } + + if (needGlobal) { + if (globalOutbounds.contains(proxyEntity.id)) { + return@forEachIndexed + } + globalOutbounds.add(proxyEntity.id) + } + + // include g-xx + trafficMap[tagOut] = proxyEntity + + // Chain outbound + if (proxyEntity.needExternal()) { + val localPort = mkPort() + externalChainMap[localPort] = proxyEntity + currentOutbound = Outbound_SocksOptions().apply { + type = "socks" + server = LOCALHOST + server_port = localPort + }.asMap() + } else { + // internal outbound + + currentOutbound = when (bean) { + is ConfigBean -> + gson.fromJson(bean.config, currentOutbound.javaClass) + is StandardV2RayBean -> + buildSingBoxOutboundStandardV2RayBean(bean).asMap() + is HysteriaBean -> + buildSingBoxOutboundHysteriaBean(bean).asMap() + is SOCKSBean -> + buildSingBoxOutboundSocksBean(bean).asMap() + is ShadowsocksBean -> + buildSingBoxOutboundShadowsocksBean(bean).asMap() + is WireGuardBean -> + buildSingBoxOutboundWireguardBean(bean).asMap() + is SSHBean -> + buildSingBoxOutboundSSHBean(bean).asMap() + else -> throw IllegalStateException("can't reach") + } + + currentOutbound.apply { + // TODO nb4a keepAliveInterval? +// val keepAliveInterval = DataStore.tcpKeepAliveInterval +// val needKeepAliveInterval = keepAliveInterval !in intArrayOf(0, 15) + + if (!muxApplied && proxyEntity.needCoreMux()) { + muxApplied = true + currentOutbound["multiplex"] = MultiplexOptions().apply { + enabled = true + max_streams = DataStore.muxConcurrency + } + } + } + + // custom JSON merge + if (bean.customOutboundJson.isNotBlank()) { + mergeJSON(bean.customOutboundJson, currentOutbound) + } + if (index == 0 && bean.customConfigJson.isNotBlank()) { + optionsToMerge = bean.customConfigJson + } + + } + + pastEntity?.requireBean()?.apply { + // don't loopback + if (currentDomainStrategy != "" && !serverAddress.isIpAddress()) { + domainListDNSDirect.add("full:$serverAddress") + } + } + if (forTest) { + currentDomainStrategy = "" + } + + currentOutbound["tag"] = tagOut + currentOutbound["domain_strategy"] = currentDomainStrategy + + // External proxy need a dokodemo-door inbound to forward the traffic + // For external proxy software, their traffic must goes to v2ray-core to use protected fd. + if (bean.canMapping() && proxyEntity.needExternal()) { + // With ss protect, don't use mapping + var needExternal = true + if (index == profileList.lastIndex) { + val pluginId = when (bean) { + is HysteriaBean -> "hysteria-plugin" + is TuicBean -> "tuic-plugin" + else -> "" + } + if (Plugins.isUsingMatsuriExe(pluginId)) { + needExternal = false + } else if (bean is HysteriaBean) { + throw Exception("not supported hysteria-plugin (SagerNet)") + } + } + if (needExternal) { + val mappingPort = mkPort() + bean.finalAddress = LOCALHOST + bean.finalPort = mappingPort + + inbounds.add(Inbound_DirectOptions().apply { + type = "direct" + listen = LOCALHOST + listen_port = mappingPort + tag = "$chainTag-mapping-${proxyEntity.id}" + + override_address = bean.serverAddress + override_port = bean.serverPort + + pastInboundTag = tag + + // no chain rule and not outbound, so need to set to direct + if (index == profileList.lastIndex) { + route.rules.add(Rule_DefaultOptions().apply { + inbound = listOf(tag) + outbound = TAG_DIRECT + }) + } + }) + } + } + + outbounds.add(currentOutbound) + chainOutbounds.add(currentOutbound) + pastOutbound = currentOutbound + pastEntity = proxyEntity + } + + return chainTagOut + } + + val tagProxy = buildChain(0, proxies) + val tagMap = mutableMapOf() + extraProxies.forEach { (key, entities) -> + tagMap[key] = buildChain(key, entities) + } + + // apply user rules + for (rule in extraRules) { + val _uidList = rule.packages.map { + PackageCache[it]?.takeIf { uid -> uid >= 1000 } + }.toHashSet().filterNotNull() + + if (rule.packages.isNotEmpty()) { + if (!isVPN) { + alerts.add(0 to rule.displayName()) + continue + } + } + route.rules.add(Rule_DefaultOptions().apply { + if (rule.packages.isNotEmpty()) { + PackageCache.awaitLoadSync() + user_id = _uidList + } + + var _domainList: List? = null + if (rule.domains.isNotBlank()) { + _domainList = rule.domains.split("\n") + makeSingBoxRule(_domainList, false) + } + if (rule.ip.isNotBlank()) { + makeSingBoxRule(rule.ip.split("\n"), true) + } + if (rule.port.isNotBlank()) { + port = rule.port.split("\n").map { it.toIntOrNull() ?: 0 } + } + if (rule.sourcePort.isNotBlank()) { + source_port = rule.sourcePort.split("\n").map { it.toIntOrNull() ?: 0 } + } + if (rule.network.isNotBlank()) { + network = rule.network + } + if (rule.source.isNotBlank()) { + source_ip_cidr = rule.source.split("\n") + } + if (rule.protocol.isNotBlank()) { + protocol = rule.protocol.split("\n") + } + + // also bypass lookup + // cannot use other outbound profile to lookup... + if (rule.outbound == -1L) { + uidListDNSDirect += _uidList + if (_domainList != null) domainListDNSDirect += _domainList + } else if (rule.outbound == 0L) { + uidListDNSRemote += _uidList + if (_domainList != null) domainListDNSRemote += _domainList + } else if (rule.outbound == -2L) { + if (_domainList != null) domainListDNSBlock += _domainList + } + + outbound = when (val outId = rule.outbound) { + 0L -> tagProxy + -1L -> TAG_BYPASS + -2L -> TAG_BLOCK + else -> if (outId == proxy.id) tagProxy else tagMap[outId] + ?: throw Exception("invalid rule") + } + }) + } + + for (freedom in arrayOf(TAG_DIRECT, TAG_BYPASS)) outbounds.add(Outbound().apply { + tag = freedom + type = "direct" + }.asMap()) + + outbounds.add(Outbound().apply { + tag = TAG_BLOCK + type = "block" + }.asMap()) + + if (!forTest) { + inbounds.add(0, Inbound_DirectOptions().apply { + type = "direct" + tag = TAG_DNS_IN + listen = bind + listen_port = DataStore.localDNSPort + override_address = if (!remoteDns.first().isIpAddress()) { + "8.8.8.8" + } else { + remoteDns.first() + } + override_port = 53 + }) + + outbounds.add(Outbound().apply { + type = "dns" + tag = TAG_DNS_OUT + }.asMap()) + } + + if (DataStore.directDnsUseSystem) { + // finally able to use "localDns" now... + directDNS = listOf(LOCAL_DNS_SERVER) + } + + // routing for DNS server + for (dns in remoteDns) { + if (!dns.isIpAddress()) continue + route.rules.add(Rule_DefaultOptions().apply { + outbound = tagProxy + ip_cidr = listOf(dns) + }) + } + + for (dns in directDNS) { + if (!dns.isIpAddress()) continue + route.rules.add(Rule_DefaultOptions().apply { + outbound = TAG_DIRECT + ip_cidr = listOf(dns) + }) + } + + // Bypass Lookup for the first profile + bypassDNSBeans.forEach { + var serverAddr = it.serverAddress + if (it is HysteriaBean && it.isMultiPort()) { + serverAddr = it.serverAddress.substringBeforeLast(":") + } + + if (!serverAddr.isIpAddress()) { + domainListDNSDirect.add("full:${serverAddr}") + } + } + + remoteDns.forEach { + var address = it + if (address.contains("://")) { + address = address.substringAfter("://") + } + "https://$address".toHttpUrlOrNull()?.apply { + if (!host.isIpAddress()) { + domainListDNSDirect.add("full:$host") + } + } + } + + // remote dns obj + remoteDns.firstOrNull()?.apply { + val d = this + dns.servers.add(DNSServerOptions().apply { + address = d + tag = "dns-remote" + address_resolver = "dns-direct" + applyDNSNetworkSettings(false) + }) + } + + // add directDNS objects here + directDNS.firstOrNull()?.apply { + val d = this + dns.servers.add(DNSServerOptions().apply { + address = d + tag = "dns-direct" + detour = "direct" + address_resolver = "dns-local" + applyDNSNetworkSettings(true) + }) + } + dns.servers.add(DNSServerOptions().apply { + address = LOCAL_DNS_SERVER + tag = "dns-local" + detour = "direct" + }) + dns.servers.add(DNSServerOptions().apply { + address = "rcode://success" + tag = "dns-block" + }) + + // dns object user rules + if (enableDnsRouting) { + if (domainListDNSRemote.isNotEmpty() || uidListDNSRemote.isNotEmpty()) { + dns.rules.add( + DNSRule_DefaultOptions().apply { + makeSingBoxRule(domainListDNSRemote.toHashSet().toList()) + user_id = uidListDNSRemote.toHashSet().toList() + server = "dns-remote" + } + ) + } + if (domainListDNSDirect.isNotEmpty() || uidListDNSDirect.isNotEmpty()) { + dns.rules.add( + DNSRule_DefaultOptions().apply { + makeSingBoxRule(domainListDNSDirect.toHashSet().toList()) + user_id = uidListDNSDirect.toHashSet().toList() + server = "dns-direct" + } + ) + } + } + if (domainListDNSBlock.isNotEmpty()) { + dns.rules.add( + DNSRule_DefaultOptions().apply { + makeSingBoxRule(domainListDNSBlock.toHashSet().toList()) + server = "dns-block" + } + ) + } + + // Disable DNS for test + if (forTest) { + dns.servers.clear() + dns.rules.clear() + } + + if (!forTest) { + route.rules.add(Rule_DefaultOptions().apply { + inbound = listOf(TAG_DNS_IN) + outbound = TAG_DNS_OUT + }) + route.rules.add(Rule_DefaultOptions().apply { + port = listOf(53) + outbound = TAG_DNS_OUT + }) // TODO new mode use system dns? + if (DataStore.bypassLan && DataStore.bypassLanInCoreOnly) { + route.rules.add(Rule_DefaultOptions().apply { + outbound = TAG_BYPASS + geoip = listOf("private") + }) + } + // block mcast + route.rules.add(Rule_DefaultOptions().apply { + ip_cidr = listOf("224.0.0.0/3", "ff00::/8") + source_ip_cidr = listOf("224.0.0.0/3", "ff00::/8") + outbound = TAG_BLOCK + }) + dns.rules.add(DNSRule_DefaultOptions().apply { + domain_suffix = listOf(".arpa.", ".arpa") + server = "dns-block" + }) + } + + // fakedns obj + if (useFakeDns) { + dns.servers.add(DNSServerOptions().apply { + address = "fakedns://" + VpnService.FAKEDNS_VLAN4_CLIENT + "/15" + tag = "dns-fake" + strategy = "ipv4_only" + }) + dns.rules.add(DNSRule_DefaultOptions().apply { + inbound = listOf("tun-in") + server = "dns-fake" + }) + } + }.let { + ConfigBuildResult( + gson.toJson(it.asMap().apply { + mergeJSON(optionsToMerge, this) + }), + externalIndexMap, + outboundTags, + outboundTagMain, + trafficMap, + alerts + ) + } + +} 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 0000000..f5fce41 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/KryoConverters.java @@ -0,0 +1,149 @@ +package io.nekohasekai.sagernet.fmt; + +import androidx.room.TypeConverter; + +import com.esotericsoftware.kryo.KryoException; +import com.esotericsoftware.kryo.io.ByteBufferInput; +import com.esotericsoftware.kryo.io.ByteBufferOutput; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; + +import io.nekohasekai.sagernet.database.SubscriptionBean; +import io.nekohasekai.sagernet.fmt.http.HttpBean; +import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean; +import io.nekohasekai.sagernet.fmt.internal.ChainBean; +import io.nekohasekai.sagernet.fmt.naive.NaiveBean; +import io.nekohasekai.sagernet.fmt.shadowsocks.ShadowsocksBean; +import io.nekohasekai.sagernet.fmt.socks.SOCKSBean; +import io.nekohasekai.sagernet.fmt.ssh.SSHBean; +import io.nekohasekai.sagernet.fmt.trojan.TrojanBean; +import io.nekohasekai.sagernet.fmt.trojan_go.TrojanGoBean; +import io.nekohasekai.sagernet.fmt.tuic.TuicBean; +import io.nekohasekai.sagernet.fmt.v2ray.VMessBean; +import io.nekohasekai.sagernet.fmt.wireguard.WireGuardBean; +import io.nekohasekai.sagernet.ktx.KryosKt; +import io.nekohasekai.sagernet.ktx.Logs; +import moe.matsuri.nb4a.proxy.neko.NekoBean; +import moe.matsuri.nb4a.proxy.config.ConfigBean; +import moe.matsuri.nb4a.utils.JavaUtil; + +public class KryoConverters { + + private static final byte[] NULL = new byte[0]; + + @TypeConverter + public static byte[] serialize(Serializable bean) { + if (bean == null) return NULL; + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ByteBufferOutput buffer = KryosKt.byteBuffer(out); + bean.serializeToBuffer(buffer); + buffer.flush(); + buffer.close(); + return out.toByteArray(); + } + + public static T deserialize(T bean, byte[] bytes) { + if (bytes == null) return bean; + ByteArrayInputStream input = new ByteArrayInputStream(bytes); + ByteBufferInput buffer = KryosKt.byteBuffer(input); + try { + bean.deserializeFromBuffer(buffer); + } catch (KryoException e) { + Logs.INSTANCE.w(e); + } + bean.initializeDefaultValues(); + return bean; + } + + @TypeConverter + public static SOCKSBean socksDeserialize(byte[] bytes) { + if (JavaUtil.isEmpty(bytes)) return null; + return deserialize(new SOCKSBean(), bytes); + } + + @TypeConverter + public static HttpBean httpDeserialize(byte[] bytes) { + if (JavaUtil.isEmpty(bytes)) return null; + return deserialize(new HttpBean(), bytes); + } + + @TypeConverter + public static ShadowsocksBean shadowsocksDeserialize(byte[] bytes) { + if (JavaUtil.isEmpty(bytes)) return null; + return deserialize(new ShadowsocksBean(), bytes); + } + + @TypeConverter + public static ConfigBean configDeserialize(byte[] bytes) { + if (JavaUtil.isEmpty(bytes)) return null; + return deserialize(new ConfigBean(), bytes); + } + + @TypeConverter + public static VMessBean vmessDeserialize(byte[] bytes) { + if (JavaUtil.isEmpty(bytes)) return null; + return deserialize(new VMessBean(), bytes); + } + + @TypeConverter + public static TrojanBean trojanDeserialize(byte[] bytes) { + if (JavaUtil.isEmpty(bytes)) return null; + return deserialize(new TrojanBean(), bytes); + } + + @TypeConverter + public static TrojanGoBean trojanGoDeserialize(byte[] bytes) { + if (JavaUtil.isEmpty(bytes)) return null; + return deserialize(new TrojanGoBean(), bytes); + } + + @TypeConverter + public static NaiveBean naiveDeserialize(byte[] bytes) { + if (JavaUtil.isEmpty(bytes)) return null; + return deserialize(new NaiveBean(), bytes); + } + + @TypeConverter + public static HysteriaBean hysteriaDeserialize(byte[] bytes) { + if (JavaUtil.isEmpty(bytes)) return null; + return deserialize(new HysteriaBean(), bytes); + } + + @TypeConverter + public static SSHBean sshDeserialize(byte[] bytes) { + if (JavaUtil.isEmpty(bytes)) return null; + return deserialize(new SSHBean(), bytes); + } + + @TypeConverter + public static WireGuardBean wireguardDeserialize(byte[] bytes) { + if (JavaUtil.isEmpty(bytes)) return null; + return deserialize(new WireGuardBean(), bytes); + } + + @TypeConverter + public static TuicBean tuicDeserialize(byte[] bytes) { + if (JavaUtil.isEmpty(bytes)) return null; + return deserialize(new TuicBean(), bytes); + } + + @TypeConverter + public static ChainBean chainDeserialize(byte[] bytes) { + if (JavaUtil.isEmpty(bytes)) return null; + return deserialize(new ChainBean(), bytes); + } + + @TypeConverter + public static NekoBean nekoDeserialize(byte[] bytes) { + if (JavaUtil.isEmpty(bytes)) return null; + return deserialize(new NekoBean(), bytes); + } + + @TypeConverter + public static SubscriptionBean subscriptionDeserialize(byte[] bytes) { + if (JavaUtil.isEmpty(bytes)) return null; + return deserialize(new SubscriptionBean(), bytes); + } + +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/PluginEntry.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/PluginEntry.kt new file mode 100644 index 0000000..749631d --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/PluginEntry.kt @@ -0,0 +1,58 @@ +package io.nekohasekai.sagernet.fmt + +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.SagerNet + +enum class PluginEntry( + val pluginId: String, + val displayName: String, + val packageName: String, // for play and f-droid page + val downloadSource: DownloadSource = DownloadSource() +) { + TrojanGo( + "trojan-go-plugin", + SagerNet.application.getString(R.string.action_trojan_go), + "io.nekohasekai.sagernet.plugin.trojan_go" + ), + NaiveProxy( + "naive-plugin", + SagerNet.application.getString(R.string.action_naive), + "io.nekohasekai.sagernet.plugin.naive" + ), + Hysteria( + "hysteria-plugin", + SagerNet.application.getString(R.string.action_hysteria), + "moe.matsuri.exe.hysteria", DownloadSource( + playStore = false, + fdroid = false, + downloadLink = "https://github.com/MatsuriDayo/plugins/releases?q=Hysteria" + ) + ), + TUIC( + "tuic-plugin", + SagerNet.application.getString(R.string.action_tuic), + "io.nekohasekai.sagernet.plugin.tuic", + DownloadSource(fdroid = false) + ), + ; + + data class DownloadSource( + val playStore: Boolean = true, + val fdroid: Boolean = true, + val downloadLink: String = "https://sagernet.org/download/" + ) + + companion object { + + fun find(name: String): PluginEntry? { + for (pluginEntry in enumValues()) { + if (name == pluginEntry.pluginId) { + return pluginEntry + } + } + return null + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/Serializable.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/Serializable.kt new file mode 100644 index 0000000..b2c49ad --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/Serializable.kt @@ -0,0 +1,27 @@ +package io.nekohasekai.sagernet.fmt + +import android.os.Parcel +import android.os.Parcelable +import com.esotericsoftware.kryo.io.ByteBufferInput +import com.esotericsoftware.kryo.io.ByteBufferOutput + +abstract class Serializable : Parcelable { + abstract fun initializeDefaultValues() + abstract fun serializeToBuffer(output: ByteBufferOutput) + abstract fun deserializeFromBuffer(input: ByteBufferInput) + + override fun describeContents() = 0 + + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.writeByteArray(KryoConverters.serialize(this)) + } + + abstract class CREATOR : Parcelable.Creator { + abstract fun newInstance(): T + + override fun createFromParcel(source: Parcel): T { + return KryoConverters.deserialize(newInstance(), source.createByteArray()) + } + } + +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/TypeMap.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/TypeMap.kt new file mode 100644 index 0000000..c55c58b --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/TypeMap.kt @@ -0,0 +1,30 @@ +package io.nekohasekai.sagernet.fmt + +import io.nekohasekai.sagernet.database.ProxyEntity + +object TypeMap : HashMap() { + init { + this["socks"] = ProxyEntity.TYPE_SOCKS + this["http"] = ProxyEntity.TYPE_HTTP + this["ss"] = ProxyEntity.TYPE_SS + this["vmess"] = ProxyEntity.TYPE_VMESS + this["trojan"] = ProxyEntity.TYPE_TROJAN + this["trojan-go"] = ProxyEntity.TYPE_TROJAN_GO + this["naive"] = ProxyEntity.TYPE_NAIVE + this["hysteria"] = ProxyEntity.TYPE_HYSTERIA + this["ssh"] = ProxyEntity.TYPE_SSH + this["wg"] = ProxyEntity.TYPE_WG + this["tuic"] = ProxyEntity.TYPE_TUIC + this["neko"] = ProxyEntity.TYPE_NEKO + this["config"] = ProxyEntity.TYPE_CONFIG + } + + val reversed = HashMap() + + init { + TypeMap.forEach { (key, type) -> + reversed[type] = key + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/UniversalFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/UniversalFmt.kt new file mode 100644 index 0000000..c9840ae --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/UniversalFmt.kt @@ -0,0 +1,36 @@ +package io.nekohasekai.sagernet.fmt + +import io.nekohasekai.sagernet.database.ProxyEntity +import io.nekohasekai.sagernet.database.ProxyGroup +import moe.matsuri.nb4a.utils.Util + +fun parseUniversal(link: String): AbstractBean { + return if (link.contains("?")) { + val type = link.substringAfter("sn://").substringBefore("?") + ProxyEntity(type = TypeMap[type] ?: error("Type $type not found")).apply { + putByteArray(Util.zlibDecompress(Util.b64Decode(link.substringAfter("?")))) + }.requireBean() + } else { + val type = link.substringAfter("sn://").substringBefore(":") + ProxyEntity(type = TypeMap[type] ?: error("Type $type not found")).apply { + putByteArray(Util.b64Decode(link.substringAfter(":").substringAfter(":"))) + }.requireBean() + } +} + +fun AbstractBean.toUniversalLink(): String { + var link = "sn://" + link += TypeMap.reversed[ProxyEntity().putBean(this).type] + link += "?" + link += Util.b64EncodeUrlSafe(Util.zlibCompress(KryoConverters.serialize(this), 9)) + return link +} + + +fun ProxyGroup.toUniversalLink(): String { + var link = "sn://subscription?" + export = true + link += Util.b64EncodeUrlSafe(Util.zlibCompress(KryoConverters.serialize(this), 9)) + export = false + return link +} \ No newline at end of file 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 0000000..41f1fc6 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/GsonConverters.java @@ -0,0 +1,35 @@ +package io.nekohasekai.sagernet.fmt.gson; + +import androidx.room.TypeConverter; + +import java.util.Collection; +import java.util.List; +import java.util.Set; + +import kotlin.collections.CollectionsKt; +import kotlin.collections.SetsKt; +import moe.matsuri.nb4a.utils.JavaUtil; + +public class GsonConverters { + + @TypeConverter + public static String toJson(Object value) { + if (value instanceof Collection) { + if (((Collection) value).isEmpty()) return ""; + } + return JavaUtil.gson.toJson(value); + } + + @TypeConverter + public static List toList(String value) { + if (JavaUtil.isNullOrBlank(value)) return CollectionsKt.listOf(); + return JavaUtil.gson.fromJson(value, List.class); + } + + @TypeConverter + public static Set toSet(String value) { + if (JavaUtil.isNullOrBlank(value)) return SetsKt.setOf(); + return JavaUtil.gson.fromJson(value, Set.class); + } + +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/http/HttpBean.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/http/HttpBean.java new file mode 100644 index 0000000..a92d2cf --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/http/HttpBean.java @@ -0,0 +1,59 @@ +package io.nekohasekai.sagernet.fmt.http; + +import androidx.annotation.NonNull; + +import com.esotericsoftware.kryo.io.ByteBufferInput; +import com.esotericsoftware.kryo.io.ByteBufferOutput; + +import org.jetbrains.annotations.NotNull; + +import io.nekohasekai.sagernet.fmt.KryoConverters; +import io.nekohasekai.sagernet.fmt.v2ray.StandardV2RayBean; + +public class HttpBean extends StandardV2RayBean { + + public String username; + public String password; + + @Override + public void initializeDefaultValues() { + super.initializeDefaultValues(); + if (username == null) username = ""; + if (password == null) password = ""; + } + + @Override + public void serialize(ByteBufferOutput output) { + output.writeInt(0); + super.serialize(output); + output.writeString(username); + output.writeString(password); + } + + @Override + public void deserialize(ByteBufferInput input) { + int version = input.readInt(); + super.deserialize(input); + username = input.readString(); + password = input.readString(); + } + + @NotNull + @Override + public HttpBean clone() { + return KryoConverters.deserialize(new HttpBean(), KryoConverters.serialize(this)); + } + + public static final Creator CREATOR = new CREATOR() { + @NonNull + @Override + public HttpBean newInstance() { + return new HttpBean(); + } + + @Override + public HttpBean[] newArray(int size) { + return new HttpBean[size]; + } + }; +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/http/HttpFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/http/HttpFmt.kt new file mode 100644 index 0000000..a190067 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/http/HttpFmt.kt @@ -0,0 +1,46 @@ +package io.nekohasekai.sagernet.fmt.http + +import io.nekohasekai.sagernet.fmt.v2ray.isTLS +import io.nekohasekai.sagernet.fmt.v2ray.setTLS +import io.nekohasekai.sagernet.ktx.urlSafe +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +fun parseHttp(link: String): HttpBean { + val httpUrl = link.toHttpUrlOrNull() ?: error("Invalid http(s) link: $link") + + if (httpUrl.encodedPath != "/") error("Not http proxy") + + return HttpBean().apply { + serverAddress = httpUrl.host + serverPort = httpUrl.port + username = httpUrl.username + password = httpUrl.password + sni = httpUrl.queryParameter("sni") + name = httpUrl.fragment + setTLS(httpUrl.scheme == "https") + } +} + +fun HttpBean.toUri(): String { + val builder = HttpUrl.Builder().scheme(if (isTLS()) "https" else "http").host(serverAddress) + + if (serverPort in 1..65535) { + builder.port(serverPort) + } + + if (username.isNotBlank()) { + builder.username(username) + } + if (password.isNotBlank()) { + builder.password(password) + } + if (sni.isNotBlank()) { + builder.addQueryParameter("sni", sni) + } + if (name.isNotBlank()) { + builder.encodedFragment(name.urlSafe()) + } + + return builder.toString() +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaBean.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaBean.java new file mode 100644 index 0000000..3a4ffb3 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaBean.java @@ -0,0 +1,155 @@ +package io.nekohasekai.sagernet.fmt.hysteria; + +import androidx.annotation.NonNull; + +import com.esotericsoftware.kryo.io.ByteBufferInput; +import com.esotericsoftware.kryo.io.ByteBufferOutput; + +import org.jetbrains.annotations.NotNull; + +import io.nekohasekai.sagernet.fmt.AbstractBean; +import io.nekohasekai.sagernet.fmt.KryoConverters; + +public class HysteriaBean extends AbstractBean { + + public static final int TYPE_NONE = 0; + public static final int TYPE_STRING = 1; + public static final int TYPE_BASE64 = 2; + + public Integer authPayloadType; + public String authPayload; + + public static final int PROTOCOL_UDP = 0; + public static final int PROTOCOL_FAKETCP = 1; + public static final int PROTOCOL_WECHAT_VIDEO = 2; + + public Integer protocol; + + public String obfuscation; + public String sni; + public String alpn; + public String caText; + + public Integer uploadMbps; + public Integer downloadMbps; + public Boolean allowInsecure; + public Integer streamReceiveWindow; + public Integer connectionReceiveWindow; + public Boolean disableMtuDiscovery; + public Integer hopInterval; + + @Override + public boolean canMapping() { + return protocol != PROTOCOL_FAKETCP; + } + + @Override + public void initializeDefaultValues() { + super.initializeDefaultValues(); + if (authPayloadType == null) authPayloadType = TYPE_NONE; + if (authPayload == null) authPayload = ""; + if (protocol == null) protocol = PROTOCOL_UDP; + if (obfuscation == null) obfuscation = ""; + if (sni == null) sni = ""; + if (alpn == null) alpn = ""; + if (caText == null) caText = ""; + + if (uploadMbps == null) uploadMbps = 10; + if (downloadMbps == null) downloadMbps = 50; + if (allowInsecure == null) allowInsecure = false; + + if (streamReceiveWindow == null) streamReceiveWindow = 0; + if (connectionReceiveWindow == null) connectionReceiveWindow = 0; + if (disableMtuDiscovery == null) disableMtuDiscovery = false; + if (hopInterval == null) hopInterval = 10; + } + + @Override + public void serialize(ByteBufferOutput output) { + output.writeInt(5); + super.serialize(output); + output.writeInt(authPayloadType); + output.writeString(authPayload); + output.writeInt(protocol); + output.writeString(obfuscation); + output.writeString(sni); + output.writeString(alpn); + + output.writeInt(uploadMbps); + output.writeInt(downloadMbps); + output.writeBoolean(allowInsecure); + + output.writeString(caText); + output.writeInt(streamReceiveWindow); + output.writeInt(connectionReceiveWindow); + output.writeBoolean(disableMtuDiscovery); + output.writeInt(hopInterval); + + } + + @Override + public void deserialize(ByteBufferInput input) { + int version = input.readInt(); + super.deserialize(input); + authPayloadType = input.readInt(); + authPayload = input.readString(); + if (version >= 3) { + protocol = input.readInt(); + } + obfuscation = input.readString(); + sni = input.readString(); + if (version >= 2) { + alpn = input.readString(); + } + uploadMbps = input.readInt(); + downloadMbps = input.readInt(); + allowInsecure = input.readBoolean(); + if (version >= 1) { + caText = input.readString(); + streamReceiveWindow = input.readInt(); + connectionReceiveWindow = input.readInt(); + if (version != 4) disableMtuDiscovery = input.readBoolean(); // note: skip 4 + } + if (version >= 5) { + hopInterval = input.readInt(); + } + } + + @Override + public void applyFeatureSettings(AbstractBean other) { + if (!(other instanceof HysteriaBean)) return; + HysteriaBean bean = ((HysteriaBean) other); + bean.uploadMbps = uploadMbps; + bean.downloadMbps = downloadMbps; + bean.allowInsecure = allowInsecure; + bean.disableMtuDiscovery = disableMtuDiscovery; + bean.hopInterval = hopInterval; + } + + @Override + public String displayAddress() { + if (HysteriaFmtKt.isMultiPort(this)) { + return serverAddress; + } + return super.displayAddress(); + } + + @NotNull + @Override + public HysteriaBean clone() { + return KryoConverters.deserialize(new HysteriaBean(), KryoConverters.serialize(this)); + } + + public static final Creator CREATOR = new CREATOR() { + @NonNull + @Override + public HysteriaBean newInstance() { + return new HysteriaBean(); + } + + @Override + public HysteriaBean[] newArray(int size) { + return new HysteriaBean[size]; + } + }; +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaFmt.kt new file mode 100644 index 0000000..f4de1ac --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaFmt.kt @@ -0,0 +1,237 @@ +package io.nekohasekai.sagernet.fmt.hysteria + +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.fmt.LOCALHOST +import io.nekohasekai.sagernet.ktx.* +import moe.matsuri.nb4a.SingBoxOptions +import moe.matsuri.nb4a.utils.Util +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.json.JSONObject +import java.io.File + + +// hysteria://host:port?auth=123456&peer=sni.domain&insecure=1|0&upmbps=100&downmbps=100&alpn=hysteria&obfs=xplus&obfsParam=123456#remarks + +fun parseHysteria(url: String): HysteriaBean { + val link = url.replace("hysteria://", "https://").toHttpUrlOrNull() ?: error( + "invalid hysteria link $url" + ) + return HysteriaBean().apply { + serverAddress = link.host + serverPort = link.port + name = link.fragment + + link.queryParameter("mport")?.also { + serverAddress = serverAddress.wrapIPV6Host() + ":" + it + } + link.queryParameter("peer")?.also { + sni = it + } + link.queryParameter("auth")?.takeIf { it.isNotBlank() }?.also { + authPayloadType = HysteriaBean.TYPE_STRING + authPayload = it + } + link.queryParameter("insecure")?.also { + allowInsecure = it == "1" + } + link.queryParameter("upmbps")?.also { + uploadMbps = it.toIntOrNull() ?: uploadMbps + } + link.queryParameter("downmbps")?.also { + downloadMbps = it.toIntOrNull() ?: downloadMbps + } + link.queryParameter("alpn")?.also { + alpn = it + } + link.queryParameter("obfsParam")?.also { + obfuscation = it + } + link.queryParameter("protocol")?.also { + when (it) { + "faketcp" -> { + protocol = HysteriaBean.PROTOCOL_FAKETCP + } + "wechat-video" -> { + protocol = HysteriaBean.PROTOCOL_WECHAT_VIDEO + } + } + } + } +} + +fun HysteriaBean.toUri(): String { + val builder = linkBuilder().host(serverAddress.substringBeforeLast(":")).port(serverPort) + if (isMultiPort()) { + builder.addQueryParameter("mport", serverAddress.substringAfterLast(":")) + } + if (allowInsecure) { + builder.addQueryParameter("insecure", "1") + } + if (sni.isNotBlank()) { + builder.addQueryParameter("peer", sni) + } + if (authPayload.isNotBlank()) { + builder.addQueryParameter("auth", authPayload) + } + builder.addQueryParameter("upmbps", "$uploadMbps") + builder.addQueryParameter("downmbps", "$downloadMbps") + if (alpn.isNotBlank()) { + builder.addQueryParameter("alpn", alpn) + } + if (obfuscation.isNotBlank()) { + builder.addQueryParameter("obfs", "xplus") + builder.addQueryParameter("obfsParam", obfuscation) + } + when (protocol) { + HysteriaBean.PROTOCOL_FAKETCP -> { + builder.addQueryParameter("protocol", "faketcp") + } + HysteriaBean.PROTOCOL_WECHAT_VIDEO -> { + builder.addQueryParameter("protocol", "wechat-video") + } + } + if (protocol == HysteriaBean.PROTOCOL_FAKETCP) { + builder.addQueryParameter("protocol", "faketcp") + } + if (name.isNotBlank()) { + builder.encodedFragment(name.urlSafe()) + } + return builder.toLink("hysteria") +} + +fun JSONObject.parseHysteria(): HysteriaBean { + return HysteriaBean().apply { + serverAddress = optString("server") + if (!isMultiPort()) { + serverAddress = optString("server").substringBeforeLast(":") + serverPort = optString("server").substringAfterLast(":").toIntOrNull() ?: 443 + } + uploadMbps = getIntNya("up_mbps") + downloadMbps = getIntNya("down_mbps") + obfuscation = getStr("obfs") + getStr("auth")?.also { + authPayloadType = HysteriaBean.TYPE_BASE64 + authPayload = it + } + getStr("auth_str")?.also { + authPayloadType = HysteriaBean.TYPE_STRING + authPayload = it + } + getStr("protocol")?.also { + when (it) { + "faketcp" -> { + protocol = HysteriaBean.PROTOCOL_FAKETCP + } + "wechat-video" -> { + protocol = HysteriaBean.PROTOCOL_WECHAT_VIDEO + } + } + } + sni = getStr("server_name") + alpn = getStr("alpn") + allowInsecure = getBool("insecure") + + streamReceiveWindow = getIntNya("recv_window_conn") + connectionReceiveWindow = getIntNya("recv_window") + disableMtuDiscovery = getBool("disable_mtu_discovery") + } +} + +fun HysteriaBean.buildHysteriaConfig(port: Int, cacheFile: (() -> File)?): String { + return JSONObject().apply { + put("server", if (isMultiPort()) serverAddress else wrapUri()) + when (protocol) { + HysteriaBean.PROTOCOL_FAKETCP -> { + put("protocol", "faketcp") + } + HysteriaBean.PROTOCOL_WECHAT_VIDEO -> { + put("protocol", "wechat-video") + } + } + put("up_mbps", uploadMbps) + put("down_mbps", downloadMbps) + put( + "socks5", JSONObject( + mapOf( + "listen" to "$LOCALHOST:$port", + ) + ) + ) + put("retry", 5) + put("obfs", obfuscation) + when (authPayloadType) { + HysteriaBean.TYPE_BASE64 -> put("auth", authPayload) + HysteriaBean.TYPE_STRING -> put("auth_str", authPayload) + } + if (sni.isBlank() && finalAddress == LOCALHOST && !serverAddress.isIpAddress()) { + sni = serverAddress + } + if (sni.isNotBlank()) { + put("server_name", sni) + } + if (alpn.isNotBlank()) put("alpn", alpn) + if (caText.isNotBlank() && cacheFile != null) { + val caFile = cacheFile() + caFile.writeText(caText) + put("ca", caFile.absolutePath) + } + + if (allowInsecure) put("insecure", true) + if (streamReceiveWindow > 0) put("recv_window_conn", streamReceiveWindow) + if (connectionReceiveWindow > 0) put("recv_window", connectionReceiveWindow) + if (disableMtuDiscovery) put("disable_mtu_discovery", true) + + // hy 1.2.0 (不兼容) + put("resolver", "udp://127.0.0.1:" + DataStore.localDNSPort) + + put("hop_interval", hopInterval) + }.toStringPretty() +} + +fun HysteriaBean.isMultiPort(): Boolean { + if (!serverAddress.contains(":")) return false + val p = serverAddress.substringAfterLast(":") + if (p.contains("-") || p.contains(",")) return true + return false +} + +fun HysteriaBean.canUseSingBox(): Boolean { + if (isMultiPort() || protocol != HysteriaBean.PROTOCOL_UDP) return false + return true +} + +fun buildSingBoxOutboundHysteriaBean(bean: HysteriaBean): SingBoxOptions.Outbound_HysteriaOptions { + // No multi-port + return SingBoxOptions.Outbound_HysteriaOptions().apply { + type = "hysteria" + server = bean.serverAddress + server_port = bean.serverPort + up_mbps = bean.uploadMbps + down_mbps = bean.downloadMbps + obfs = bean.obfuscation + disable_mtu_discovery = bean.disableMtuDiscovery + when (bean.authPayloadType) { + HysteriaBean.TYPE_BASE64 -> auth = Util.b64Decode(bean.authPayload).toList() + HysteriaBean.TYPE_STRING -> auth_str = bean.authPayload + } + if (bean.streamReceiveWindow > 0) { + recv_window_conn = bean.streamReceiveWindow.toLong() + } + if (bean.connectionReceiveWindow > 0) { + recv_window_conn = bean.connectionReceiveWindow.toLong() + } + tls = SingBoxOptions.OutboundTLSOptions().apply { + if (bean.sni.isNotBlank()) { + server_name = bean.sni + } + if (bean.alpn.isNotBlank()) { + alpn = bean.alpn.split("\n") + } + if (bean.caText.isNotBlank()) { + certificate = bean.caText + } + insecure = bean.allowInsecure + enabled = true + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/internal/ChainBean.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/internal/ChainBean.java new file mode 100644 index 0000000..1388a11 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/internal/ChainBean.java @@ -0,0 +1,80 @@ +package io.nekohasekai.sagernet.fmt.internal; + +import androidx.annotation.NonNull; + +import com.esotericsoftware.kryo.io.ByteBufferInput; +import com.esotericsoftware.kryo.io.ByteBufferOutput; + +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.List; + +import io.nekohasekai.sagernet.fmt.KryoConverters; +import moe.matsuri.nb4a.utils.JavaUtil; + +public class ChainBean extends InternalBean { + + public List proxies; + + @Override + public String displayName() { + if (JavaUtil.isNotBlank(name)) { + return name; + } else { + return "Chain " + Math.abs(hashCode()); + } + } + + @Override + public void initializeDefaultValues() { + super.initializeDefaultValues(); + if (name == null) name = ""; + + if (proxies == null) { + proxies = new ArrayList<>(); + } + } + + @Override + public void serialize(ByteBufferOutput output) { + output.writeInt(1); + output.writeInt(proxies.size()); + for (Long proxy : proxies) { + output.writeLong(proxy); + } + } + + @Override + public void deserialize(ByteBufferInput input) { + int version = input.readInt(); + if (version < 1) { + input.readString(); + input.readInt(); + } + int length = input.readInt(); + proxies = new ArrayList<>(); + for (int i = 0; i < length; i++) { + proxies.add(input.readLong()); + } + } + + @NotNull + @Override + public ChainBean clone() { + return KryoConverters.deserialize(new ChainBean(), KryoConverters.serialize(this)); + } + + public static final Creator CREATOR = new CREATOR() { + @NonNull + @Override + public ChainBean newInstance() { + return new ChainBean(); + } + + @Override + public ChainBean[] newArray(int size) { + return new ChainBean[size]; + } + }; +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/internal/InternalBean.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/internal/InternalBean.java new file mode 100644 index 0000000..2627b3a --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/internal/InternalBean.java @@ -0,0 +1,26 @@ +package io.nekohasekai.sagernet.fmt.internal; + +import io.nekohasekai.sagernet.fmt.AbstractBean; + +public abstract class InternalBean extends AbstractBean { + + @Override + public String displayAddress() { + return ""; + } + + @Override + public boolean canICMPing() { + return false; + } + + @Override + public boolean canTCPing() { + return false; + } + + @Override + public boolean canMapping() { + return false; + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/naive/NaiveBean.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/naive/NaiveBean.java new file mode 100644 index 0000000..841ae59 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/naive/NaiveBean.java @@ -0,0 +1,88 @@ +package io.nekohasekai.sagernet.fmt.naive; + +import androidx.annotation.NonNull; + +import com.esotericsoftware.kryo.io.ByteBufferInput; +import com.esotericsoftware.kryo.io.ByteBufferOutput; + +import org.jetbrains.annotations.NotNull; + +import io.nekohasekai.sagernet.fmt.AbstractBean; +import io.nekohasekai.sagernet.fmt.KryoConverters; + +public class NaiveBean extends AbstractBean { + + /** + * Available proto: https, quic. + */ + public String proto; + public String username; + public String password; + public String extraHeaders; + public String sni; + public String certificates; + public Integer insecureConcurrency; + + @Override + public void initializeDefaultValues() { + if (serverPort == null) serverPort = 443; + super.initializeDefaultValues(); + if (proto == null) proto = "https"; + if (username == null) username = ""; + if (password == null) password = ""; + if (extraHeaders == null) extraHeaders = ""; + if (certificates == null) certificates = ""; + if (sni == null) sni = ""; + if (insecureConcurrency == null) insecureConcurrency = 0; + } + + @Override + public void serialize(ByteBufferOutput output) { + output.writeInt(2); + super.serialize(output); + output.writeString(proto); + output.writeString(username); + output.writeString(password); + // note: sequence is different from SagerNet,,, + output.writeString(extraHeaders); + output.writeString(certificates); + output.writeString(sni); + output.writeInt(insecureConcurrency); + } + + @Override + public void deserialize(ByteBufferInput input) { + int version = input.readInt(); + super.deserialize(input); + proto = input.readString(); + username = input.readString(); + password = input.readString(); + extraHeaders = input.readString(); + if (version >= 2) { + certificates = input.readString(); + sni = input.readString(); + } + if (version >= 1) { + insecureConcurrency = input.readInt(); + } + } + + @NotNull + @Override + public NaiveBean clone() { + return KryoConverters.deserialize(new NaiveBean(), KryoConverters.serialize(this)); + } + + public static final Creator CREATOR = new CREATOR() { + @NonNull + @Override + public NaiveBean newInstance() { + return new NaiveBean(); + } + + @Override + public NaiveBean[] newArray(int size) { + return new NaiveBean[size]; + } + }; +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/naive/NaiveFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/naive/NaiveFmt.kt new file mode 100644 index 0000000..5eb7a62 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/naive/NaiveFmt.kt @@ -0,0 +1,90 @@ +package io.nekohasekai.sagernet.fmt.naive + +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.fmt.LOCALHOST +import io.nekohasekai.sagernet.ktx.* +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.json.JSONObject + +fun parseNaive(link: String): NaiveBean { + val proto = link.substringAfter("+").substringBefore(":") + val url = ("https://" + link.substringAfter("://")).toHttpUrlOrNull() + ?: error("Invalid naive link: $link") + return NaiveBean().also { + it.proto = proto + }.apply { + serverAddress = url.host + serverPort = url.port + username = url.username + password = url.password + sni = url.queryParameter("sni") + certificates = url.queryParameter("cert") + extraHeaders = url.queryParameter("extra-headers")?.unUrlSafe()?.replace("\r\n", "\n") + insecureConcurrency = url.queryParameter("insecure-concurrency")?.toIntOrNull() + name = url.fragment + initializeDefaultValues() + } +} + +fun NaiveBean.toUri(proxyOnly: Boolean = false): String { + val builder = linkBuilder().host(finalAddress).port(finalPort) + if (username.isNotBlank()) { + builder.username(username) + if (password.isNotBlank()) { + builder.password(password) + } + } + if (!proxyOnly) { + if (sni.isNotBlank()) { + builder.addQueryParameter("sni", sni) + } + if (certificates.isNotBlank()) { + builder.addQueryParameter("cert", certificates) + } + if (extraHeaders.isNotBlank()) { + builder.addQueryParameter("extra-headers", extraHeaders) + } + if (name.isNotBlank()) { + builder.encodedFragment(name.urlSafe()) + } + if (insecureConcurrency > 0) { + builder.addQueryParameter("insecure-concurrency", "$insecureConcurrency") + } + } + return builder.toLink(if (proxyOnly) proto else "naive+$proto", false) +} + +fun NaiveBean.buildNaiveConfig(port: Int): String { + return JSONObject().apply { + // process ipv6 + finalAddress = finalAddress.wrapIPV6Host() + serverAddress = serverAddress.wrapIPV6Host() + + // process sni + if (sni.isNotBlank()) { + put("host-resolver-rules", "MAP $sni $finalAddress") + finalAddress = sni + } else { + if (serverAddress.isIpAddress()) { + // for naive, using IP as SNI name hardly happens + // and host-resolver-rules cannot resolve the SNI problem + // so do nothing + } else { + put("host-resolver-rules", "MAP $serverAddress $finalAddress") + finalAddress = serverAddress + } + } + + put("listen", "socks://$LOCALHOST:$port") + put("proxy", toUri(true)) + if (extraHeaders.isNotBlank()) { + put("extra-headers", extraHeaders.split("\n").joinToString("\r\n")) + } + if (DataStore.enableLog) { + put("log", "") + } + if (insecureConcurrency > 0) { + put("insecure-concurrency", insecureConcurrency) + } + }.toStringPretty() +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/shadowsocks/ShadowsocksBean.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/shadowsocks/ShadowsocksBean.java new file mode 100644 index 0000000..10c394f --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/shadowsocks/ShadowsocksBean.java @@ -0,0 +1,71 @@ +package io.nekohasekai.sagernet.fmt.shadowsocks; + +import androidx.annotation.NonNull; + +import com.esotericsoftware.kryo.io.ByteBufferInput; +import com.esotericsoftware.kryo.io.ByteBufferOutput; + +import org.jetbrains.annotations.NotNull; + +import io.nekohasekai.sagernet.fmt.AbstractBean; +import io.nekohasekai.sagernet.fmt.KryoConverters; +import moe.matsuri.nb4a.utils.JavaUtil; + +public class ShadowsocksBean extends AbstractBean { + + public String method; + public String password; + public String plugin; + + public Boolean sUoT; + + @Override + public void initializeDefaultValues() { + super.initializeDefaultValues(); + + if (JavaUtil.isNullOrBlank(method)) method = "aes-256-gcm"; + if (method == null) method = ""; + if (password == null) password = ""; + if (plugin == null) plugin = ""; + if (sUoT == null) sUoT = false; + } + + @Override + public void serialize(ByteBufferOutput output) { + output.writeInt(2); + super.serialize(output); + output.writeString(method); + output.writeString(password); + output.writeString(plugin); + output.writeBoolean(sUoT); + } + + @Override + public void deserialize(ByteBufferInput input) { + int version = input.readInt(); + super.deserialize(input); + method = input.readString(); + password = input.readString(); + plugin = input.readString(); + sUoT = input.readBoolean(); + } + + @NotNull + @Override + public ShadowsocksBean clone() { + return KryoConverters.deserialize(new ShadowsocksBean(), KryoConverters.serialize(this)); + } + + public static final Creator CREATOR = new CREATOR() { + @NonNull + @Override + public ShadowsocksBean newInstance() { + return new ShadowsocksBean(); + } + + @Override + public ShadowsocksBean[] newArray(int size) { + return new ShadowsocksBean[size]; + } + }; +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/shadowsocks/ShadowsocksFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/shadowsocks/ShadowsocksFmt.kt new file mode 100644 index 0000000..7d75924 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/shadowsocks/ShadowsocksFmt.kt @@ -0,0 +1,115 @@ +package io.nekohasekai.sagernet.fmt.shadowsocks + +import moe.matsuri.nb4a.SingBoxOptions +import io.nekohasekai.sagernet.ktx.* +import moe.matsuri.nb4a.utils.Util +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.json.JSONObject + +fun parseShadowsocks(url: String): ShadowsocksBean { + + if (url.substringBefore("#").contains("@")) { + var link = url.replace("ss://", "https://").toHttpUrlOrNull() ?: error( + "invalid ss-android link $url" + ) + + if (link.username.isBlank()) { // fix justmysocks's shit link + link = (("https://" + url.substringAfter("ss://") + .substringBefore("#") + .decodeBase64UrlSafe()).toHttpUrlOrNull() + ?: error("invalid jms link $url") + ).newBuilder().fragment(url.substringAfter("#")).build() + } + + // ss-android style + + if (link.password.isNotBlank()) { + return ShadowsocksBean().apply { + serverAddress = link.host + serverPort = link.port + method = link.username + password = link.password + plugin = link.queryParameter("plugin") ?: "" + name = link.fragment + } + } + + val methodAndPswd = link.username.decodeBase64UrlSafe() + + return ShadowsocksBean().apply { + serverAddress = link.host + serverPort = link.port + method = methodAndPswd.substringBefore(":") + password = methodAndPswd.substringAfter(":") + plugin = link.queryParameter("plugin") ?: "" + name = link.fragment + } + } else { + // v2rayN style + var v2Url = url + + if (v2Url.contains("#")) v2Url = v2Url.substringBefore("#") + + val link = ("https://" + v2Url.substringAfter("ss://") + .decodeBase64UrlSafe()).toHttpUrlOrNull() ?: error("invalid v2rayN link $url") + + return ShadowsocksBean().apply { + serverAddress = link.host + serverPort = link.port + method = link.username + password = link.password + plugin = "" + val remarks = url.substringAfter("#").unUrlSafe() + if (remarks.isNotBlank()) name = remarks + } + } + +} + +fun ShadowsocksBean.toUri(): String { + + val builder = linkBuilder().username(Util.b64EncodeUrlSafe("$method:$password")) + .host(serverAddress) + .port(serverPort) + + if (plugin.isNotBlank()) { + builder.addQueryParameter("plugin", plugin) + } + + if (name.isNotBlank()) { + builder.encodedFragment(name.urlSafe()) + } + + return builder.toLink("ss").replace("$serverPort/", "$serverPort") + +} + +fun JSONObject.parseShadowsocks(): ShadowsocksBean { + return ShadowsocksBean().apply { + serverAddress = getStr("server") + serverPort = getIntNya("server_port") + password = getStr("password") + method = getStr("method") + name = optString("remarks", "") + + val pId = getStr("plugin") + if (!pId.isNullOrBlank()) { + plugin = pId + ";" + optString("plugin_opts", "") + } + } +} + +fun buildSingBoxOutboundShadowsocksBean(bean: ShadowsocksBean): SingBoxOptions.Outbound_ShadowsocksOptions { + return SingBoxOptions.Outbound_ShadowsocksOptions().apply { + type = "shadowsocks" + server = bean.serverAddress + server_port = bean.serverPort + password = bean.password + method = bean.method + udp_over_tcp = bean.sUoT + if (bean.plugin.isNotBlank()) { + plugin = bean.plugin.substringBefore(";") + plugin_opts = bean.plugin.substringAfter(";") + } + } +} 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 0000000..fb07ac1 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/socks/SOCKSBean.java @@ -0,0 +1,110 @@ +package io.nekohasekai.sagernet.fmt.socks; + +import androidx.annotation.NonNull; + +import com.esotericsoftware.kryo.io.ByteBufferInput; +import com.esotericsoftware.kryo.io.ByteBufferOutput; + +import org.jetbrains.annotations.NotNull; + +import io.nekohasekai.sagernet.fmt.AbstractBean; +import io.nekohasekai.sagernet.fmt.KryoConverters; + +public class SOCKSBean extends AbstractBean { + + public Integer protocol; + + public int protocolVersion() { + switch (protocol) { + case 0: + case 1: + return 4; + default: + return 5; + } + } + + public String protocolName() { + switch (protocol) { + case 0: + return "SOCKS4"; + case 1: + return "SOCKS4A"; + default: + return "SOCKS5"; + } + } + + public String protocolVersionName() { + switch (protocol) { + case 0: + return "4"; + case 1: + return "4a"; + default: + return "5"; + } + } + + public String username; + public String password; + + public static final int PROTOCOL_SOCKS4 = 0; + public static final int PROTOCOL_SOCKS4A = 1; + public static final int PROTOCOL_SOCKS5 = 2; + + @Override + public String network() { + if (protocol < PROTOCOL_SOCKS5) return "tcp"; + return super.network(); + } + + @Override + public void initializeDefaultValues() { + super.initializeDefaultValues(); + + if (protocol == null) protocol = PROTOCOL_SOCKS5; + if (username == null) username = ""; + if (password == null) password = ""; + } + + @Override + public void serialize(ByteBufferOutput output) { + output.writeInt(1); + super.serialize(output); + output.writeInt(protocol); + output.writeString(username); + output.writeString(password); + } + + @Override + public void deserialize(ByteBufferInput input) { + int version = input.readInt(); + super.deserialize(input); + if (version >= 1) { + protocol = input.readInt(); + } + username = input.readString(); + password = input.readString(); + } + + @NotNull + @Override + public SOCKSBean clone() { + return KryoConverters.deserialize(new SOCKSBean(), KryoConverters.serialize(this)); + } + + public static final Creator CREATOR = new CREATOR() { + @NonNull + @Override + public SOCKSBean newInstance() { + return new SOCKSBean(); + } + + @Override + public SOCKSBean[] newArray(int size) { + return new SOCKSBean[size]; + } + }; + +} 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 0000000..8f8a1c7 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/socks/SOCKSFmt.kt @@ -0,0 +1,81 @@ +package io.nekohasekai.sagernet.fmt.socks + +import moe.matsuri.nb4a.SingBoxOptions +import io.nekohasekai.sagernet.ktx.* +import moe.matsuri.nb4a.utils.NGUtil +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +fun parseSOCKS(link: String): SOCKSBean { + if (!link.substringAfter("://").contains(":")) { + // v2rayN shit format + var url = link.substringAfter("://") + if (url.contains("#")) { + url = url.substringBeforeLast("#") + } + url = url.decodeBase64UrlSafe() + val httpUrl = "http://$url".toHttpUrlOrNull() ?: error("Invalid v2rayN link content: $url") + return SOCKSBean().apply { + serverAddress = httpUrl.host + serverPort = httpUrl.port + username = httpUrl.username.takeIf { it != "null" } ?: "" + password = httpUrl.password.takeIf { it != "null" } ?: "" + if (link.contains("#")) { + name = link.substringAfter("#").unUrlSafe() + } + } + } else { + val url = ("http://" + link.substringAfter("://")).toHttpUrlOrNull() + ?: error("Not supported: $link") + + return SOCKSBean().apply { + protocol = when { + link.startsWith("socks4://") -> SOCKSBean.PROTOCOL_SOCKS4 + link.startsWith("socks4a://") -> SOCKSBean.PROTOCOL_SOCKS4A + else -> SOCKSBean.PROTOCOL_SOCKS5 + } + serverAddress = url.host + serverPort = url.port + username = url.username + password = url.password + name = url.fragment + } + } +} + +fun SOCKSBean.toUri(): String { + + val builder = HttpUrl.Builder().scheme("http").host(serverAddress).port(serverPort) + if (!username.isNullOrBlank()) builder.username(username) + if (!password.isNullOrBlank()) builder.password(password) + if (!name.isNullOrBlank()) builder.encodedFragment(name.urlSafe()) + return builder.toLink("socks${protocolVersion()}") + +} + +fun SOCKSBean.toV2rayN(): String { + + var link = "" + if (username.isNotBlank()) { + link += username.urlSafe() + ":" + password.urlSafe() + "@" + } + link += "$serverAddress:$serverPort" + link = "socks://" + NGUtil.encode(link) + if (name.isNotBlank()) { + link += "#" + name.urlSafe() + } + + return link + +} + +fun buildSingBoxOutboundSocksBean(bean: SOCKSBean): SingBoxOptions.Outbound_SocksOptions { + return SingBoxOptions.Outbound_SocksOptions().apply { + type = "socks" + server = bean.serverAddress + server_port = bean.serverPort + username = bean.username + password = bean.password + version = bean.protocolVersionName() + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/ssh/SSHBean.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/ssh/SSHBean.java new file mode 100644 index 0000000..cc13e1a --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ssh/SSHBean.java @@ -0,0 +1,98 @@ +package io.nekohasekai.sagernet.fmt.ssh; + +import androidx.annotation.NonNull; + +import com.esotericsoftware.kryo.io.ByteBufferInput; +import com.esotericsoftware.kryo.io.ByteBufferOutput; + +import org.jetbrains.annotations.NotNull; + +import io.nekohasekai.sagernet.fmt.AbstractBean; +import io.nekohasekai.sagernet.fmt.KryoConverters; + +public class SSHBean extends AbstractBean { + + public static final int AUTH_TYPE_NONE = 0; + public static final int AUTH_TYPE_PASSWORD = 1; + public static final int AUTH_TYPE_PRIVATE_KEY = 2; + + public String username; + public Integer authType; + public String password; + public String privateKey; + public String privateKeyPassphrase; + public String publicKey; + + @Override + public void initializeDefaultValues() { + if (serverPort == null) serverPort = 22; + + super.initializeDefaultValues(); + + if (username == null) username = "root"; + if (authType == null) authType = AUTH_TYPE_PASSWORD; + if (password == null) password = ""; + if (privateKey == null) privateKey = ""; + if (privateKeyPassphrase == null) privateKeyPassphrase = ""; + if (publicKey == null) publicKey = ""; + } + + @Override + public void serialize(ByteBufferOutput output) { + output.writeInt(0); + super.serialize(output); + output.writeString(username); + output.writeInt(authType); + switch (authType) { + case AUTH_TYPE_NONE: + break; + case AUTH_TYPE_PASSWORD: + output.writeString(password); + break; + case AUTH_TYPE_PRIVATE_KEY: + output.writeString(privateKey); + output.writeString(privateKeyPassphrase); + break; + } + output.writeString(publicKey); + } + + @Override + public void deserialize(ByteBufferInput input) { + int version = input.readInt(); + super.deserialize(input); + username = input.readString(); + authType = input.readInt(); + switch (authType) { + case AUTH_TYPE_NONE: + break; + case AUTH_TYPE_PASSWORD: + password = input.readString(); + break; + case AUTH_TYPE_PRIVATE_KEY: + privateKey = input.readString(); + privateKeyPassphrase = input.readString(); + break; + } + publicKey = input.readString(); + } + + @NotNull + @Override + public SSHBean clone() { + return KryoConverters.deserialize(new SSHBean(), KryoConverters.serialize(this)); + } + + public static final Creator CREATOR = new CREATOR() { + @NonNull + @Override + public SSHBean newInstance() { + return new SSHBean(); + } + + @Override + public SSHBean[] newArray(int size) { + return new SSHBean[size]; + } + }; +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/ssh/SSHFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/ssh/SSHFmt.kt new file mode 100644 index 0000000..4f76998 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ssh/SSHFmt.kt @@ -0,0 +1,22 @@ +package io.nekohasekai.sagernet.fmt.ssh + +import moe.matsuri.nb4a.SingBoxOptions + +fun buildSingBoxOutboundSSHBean(bean: SSHBean): SingBoxOptions.Outbound_SSHOptions { + return SingBoxOptions.Outbound_SSHOptions().apply { + type = "ssh" + server = bean.serverAddress + server_port = bean.serverPort + user = bean.username + host_key = bean.privateKey.split("\n") + when (bean.authType) { + SSHBean.AUTH_TYPE_PRIVATE_KEY -> { + private_key = bean.privateKey + private_key_passphrase = bean.privateKeyPassphrase + } + else -> { + password = bean.password + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/trojan/TrojanBean.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/trojan/TrojanBean.java new file mode 100644 index 0000000..d283dd6 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/trojan/TrojanBean.java @@ -0,0 +1,69 @@ +package io.nekohasekai.sagernet.fmt.trojan; + +import androidx.annotation.NonNull; + +import com.esotericsoftware.kryo.io.ByteBufferInput; +import com.esotericsoftware.kryo.io.ByteBufferOutput; + +import org.jetbrains.annotations.NotNull; + +import io.nekohasekai.sagernet.fmt.AbstractBean; +import io.nekohasekai.sagernet.fmt.KryoConverters; +import io.nekohasekai.sagernet.fmt.v2ray.StandardV2RayBean; + +public class TrojanBean extends StandardV2RayBean { + + public String password; + + @Override + public void initializeDefaultValues() { + super.initializeDefaultValues(); + if (security == null || security.isEmpty()) security = "tls"; + if (password == null) password = ""; + } + + @Override + public void serialize(ByteBufferOutput output) { + output.writeInt(2); + super.serialize(output); + output.writeString(password); + } + + @Override + public void deserialize(ByteBufferInput input) { + int version = input.readInt(); + if (version >= 2) { + super.deserialize(input); // StandardV2RayBean + password = input.readString(); + } else { + // From AbstractBean + serverAddress = input.readString(); + serverPort = input.readInt(); + // From TrojanBean + password = input.readString(); + security = input.readString(); + sni = input.readString(); + alpn = input.readString(); + if (version == 1) allowInsecure = input.readBoolean(); + } + } + + @NotNull + @Override + public TrojanBean clone() { + return KryoConverters.deserialize(new TrojanBean(), KryoConverters.serialize(this)); + } + + public static final Creator CREATOR = new CREATOR() { + @NonNull + @Override + public TrojanBean newInstance() { + return new TrojanBean(); + } + + @Override + public TrojanBean[] newArray(int size) { + return new TrojanBean[size]; + } + }; +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/trojan/TrojanFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/trojan/TrojanFmt.kt new file mode 100644 index 0000000..0cb475e --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/trojan/TrojanFmt.kt @@ -0,0 +1,23 @@ +package io.nekohasekai.sagernet.fmt.trojan + +import io.nekohasekai.sagernet.fmt.v2ray.parseDuckSoft +import io.nekohasekai.sagernet.fmt.v2ray.toUri +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +fun parseTrojan(server: String): TrojanBean { + + val link = server.replace("trojan://", "https://").toHttpUrlOrNull() + ?: error("invalid trojan link $server") + + return TrojanBean().apply { + parseDuckSoft(link) + link.queryParameter("allowInsecure") + ?.apply { if (this == "1" || this == "true") allowInsecure = true } + link.queryParameter("peer")?.apply { if (this.isNotBlank()) sni = this } + } + +} + +fun TrojanBean.toUri(): String { + return toUri(true).replace("vmess://", "trojan://") +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/trojan_go/TrojanGoBean.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/trojan_go/TrojanGoBean.java new file mode 100644 index 0000000..f02f363 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/trojan_go/TrojanGoBean.java @@ -0,0 +1,176 @@ +package io.nekohasekai.sagernet.fmt.trojan_go; + +import androidx.annotation.NonNull; + +import com.esotericsoftware.kryo.io.ByteBufferInput; +import com.esotericsoftware.kryo.io.ByteBufferOutput; + +import org.jetbrains.annotations.NotNull; + +import io.nekohasekai.sagernet.fmt.AbstractBean; +import io.nekohasekai.sagernet.fmt.KryoConverters; +import moe.matsuri.nb4a.utils.JavaUtil; + +public class TrojanGoBean extends AbstractBean { + + /** + * Trojan 的密码。 + * 不可省略,不能为空字符串,不建议含有非 ASCII 可打印字符。 + * 必须使用 encodeURIComponent 编码。 + */ + public String password; + + /** + * 自定义 TLS 的 SNI。 + * 省略时默认与 trojan-host 同值。不得为空字符串。 + *

+ * 必须使用 encodeURIComponent 编码。 + */ + public String sni; + + /** + * 传输类型。 + * 省略时默认为 original,但不可为空字符串。 + * 目前可选值只有 original 和 ws,未来可能会有 h2、h2+ws 等取值。 + *

+ * 当取值为 original 时,使用原始 Trojan 传输方式,无法方便通过 CDN。 + * 当取值为 ws 时,使用 wss 作为传输层。 + */ + public String type; + + /** + * 自定义 HTTP Host 头。 + * 可以省略,省略时值同 trojan-host。 + * 可以为空字符串,但可能带来非预期情形。 + *

+ * 警告:若你的端口非标准端口(不是 80 / 443),RFC 标准规定 Host 应在主机名后附上端口号,例如 example.com:44333。至于是否遵守,请自行斟酌。 + *

+ * 必须使用 encodeURIComponent 编码。 + */ + public String host; + + /** + * 当传输类型 type 取 ws、h2、h2+ws 时,此项有效。 + * 不可省略,不可为空。 + * 必须以 / 开头。 + * 可以使用 URL 中的 & # ? 等字符,但应当是合法的 URL 路径。 + *

+ * 必须使用 encodeURIComponent 编码。 + */ + public String path; + + /** + * 用于保证 Trojan 流量密码学安全的加密层。 + * 可省略,默认为 none,即不使用加密。 + * 不可以为空字符串。 + *

+ * 必须使用 encodeURIComponent 编码。 + *

+ * 使用 Shadowsocks 算法进行流量加密时,其格式为: + *

+ * ss;method:password + *

+ * 其中 ss 是固定内容,method 是加密方法,必须为下列之一: + *

+ * aes-128-gcm + * aes-256-gcm + * chacha20-ietf-poly1305 + */ + public String encryption; + + /** + * 额外的插件选项。本字段保留。 + * 可省略,但不可以为空字符串。 + */ + // not used in NB4A + public String plugin; + + // --- + + public Boolean allowInsecure; + + @Override + public void initializeDefaultValues() { + super.initializeDefaultValues(); + + if (password == null) password = ""; + if (sni == null) sni = ""; + if (JavaUtil.isNullOrBlank(type)) type = "original"; + if (host == null) host = ""; + if (path == null) path = ""; + if (JavaUtil.isNullOrBlank(encryption)) encryption = "none"; + if (plugin == null) plugin = ""; + if (allowInsecure == null) allowInsecure = false; + } + + @Override + public void serialize(ByteBufferOutput output) { + output.writeInt(1); + super.serialize(output); + output.writeString(password); + output.writeString(sni); + output.writeString(type); + //noinspection SwitchStatementWithTooFewBranches + switch (type) { + case "ws": { + output.writeString(host); + output.writeString(path); + break; + } + } + output.writeString(encryption); + output.writeString(plugin); + output.writeBoolean(allowInsecure); + } + + @Override + public void deserialize(ByteBufferInput input) { + int version = input.readInt(); + super.deserialize(input); + + password = input.readString(); + sni = input.readString(); + type = input.readString(); + //noinspection SwitchStatementWithTooFewBranches + switch (type) { + case "ws": { + host = input.readString(); + path = input.readString(); + break; + } + } + encryption = input.readString(); + plugin = input.readString(); + if (version >= 1) { + allowInsecure = input.readBoolean(); + } + } + + @Override + public void applyFeatureSettings(AbstractBean other) { + if (!(other instanceof TrojanGoBean)) return; + TrojanGoBean bean = ((TrojanGoBean) other); + if (allowInsecure) { + bean.allowInsecure = true; + } + } + + @NotNull + @Override + public TrojanGoBean clone() { + return KryoConverters.deserialize(new TrojanGoBean(), KryoConverters.serialize(this)); + } + + public static final Creator CREATOR = new CREATOR() { + @NonNull + @Override + public TrojanGoBean newInstance() { + return new TrojanGoBean(); + } + + @Override + public TrojanGoBean[] newArray(int size) { + return new TrojanGoBean[size]; + } + }; +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/trojan_go/TrojanGoFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/trojan_go/TrojanGoFmt.kt new file mode 100644 index 0000000..195ba84 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/trojan_go/TrojanGoFmt.kt @@ -0,0 +1,162 @@ +package io.nekohasekai.sagernet.fmt.trojan_go + +import io.nekohasekai.sagernet.IPv6Mode +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.fmt.LOCALHOST +import io.nekohasekai.sagernet.ktx.* +import moe.matsuri.nb4a.Protocols +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.json.JSONArray +import org.json.JSONObject + +fun parseTrojanGo(server: String): TrojanGoBean { + val link = server.replace("trojan-go://", "https://").toHttpUrlOrNull() ?: error( + "invalid trojan-link link $server" + ) + return TrojanGoBean().apply { + serverAddress = link.host + serverPort = link.port + password = link.username + link.queryParameter("sni")?.let { + sni = it + } + link.queryParameter("type")?.let { lType -> + type = lType + + when (type) { + "ws" -> { + link.queryParameter("host")?.let { + host = it + } + link.queryParameter("path")?.let { + path = it + } + } + else -> { + } + } + } + link.queryParameter("encryption")?.let { + encryption = it + } + link.queryParameter("plugin")?.let { + plugin = it + } + link.fragment.takeIf { !it.isNullOrBlank() }?.let { + name = it + } + } +} + +fun TrojanGoBean.toUri(): String { + val builder = linkBuilder().username(password).host(serverAddress).port(serverPort) + if (sni.isNotBlank()) { + builder.addQueryParameter("sni", sni) + } + if (type.isNotBlank() && type != "original") { + builder.addQueryParameter("type", type) + + when (type) { + "ws" -> { + if (host.isNotBlank()) { + builder.addQueryParameter("host", host) + } + if (path.isNotBlank()) { + builder.addQueryParameter("path", path) + } + } + } + } + if (type.isNotBlank() && type != "none") { + builder.addQueryParameter("encryption", encryption) + } + if (plugin.isNotBlank()) { + builder.addQueryParameter("plugin", plugin) + } + + if (name.isNotBlank()) { + builder.encodedFragment(name.urlSafe()) + } + + return builder.toLink("trojan-go") +} + +fun TrojanGoBean.buildTrojanGoConfig(port: Int): String { + return JSONObject().apply { + put("run_type", "client") + put("local_addr", LOCALHOST) + put("local_port", port) + put("remote_addr", finalAddress) + put("remote_port", finalPort) + put("password", JSONArray().apply { + put(password) + }) + put("log_level", if (DataStore.enableLog) 0 else 2) + if (Protocols.shouldEnableMux("trojan-go")) put("mux", JSONObject().apply { + put("enabled", true) + put("concurrency", DataStore.muxConcurrency) + }) + put("tcp", JSONObject().apply { + put("prefer_ipv4", DataStore.ipv6Mode <= IPv6Mode.ENABLE) + }) + + when (type) { + "original" -> { + } + "ws" -> put("websocket", JSONObject().apply { + put("enabled", true) + put("host", host) + put("path", path) + }) + } + + if (sni.isBlank() && finalAddress == LOCALHOST && !serverAddress.isIpAddress()) { + sni = serverAddress + } + + put("ssl", JSONObject().apply { + if (sni.isNotBlank()) put("sni", sni) + if (allowInsecure) put("verify", false) + }) + + when { + encryption == "none" -> { + } + encryption.startsWith("ss;") -> put("shadowsocks", JSONObject().apply { + put("enabled", true) + put("method", encryption.substringAfter(";").substringBefore(":")) + put("password", encryption.substringAfter(":")) + }) + } + }.toStringPretty() +} + +fun JSONObject.parseTrojanGo(): TrojanGoBean { + return TrojanGoBean().applyDefaultValues().apply { + serverAddress = optString("remote_addr", serverAddress) + serverPort = optInt("remote_port", serverPort) + when (val pass = get("password")) { + is String -> { + password = pass + } + is List<*> -> { + password = pass[0] as String + } + } + optJSONArray("ssl")?.apply { + sni = optString("sni", sni) + } + optJSONArray("websocket")?.apply { + if (optBoolean("enabled", false)) { + type = "ws" + host = optString("host", host) + path = optString("path", path) + } + } + optJSONArray("shadowsocks")?.apply { + if (optBoolean("enabled", false)) { + encryption = "ss;${optString("method", "")}:${optString("password", "")}" + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/tuic/TuicBean.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/tuic/TuicBean.java new file mode 100644 index 0000000..a35ec85 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/tuic/TuicBean.java @@ -0,0 +1,97 @@ +package io.nekohasekai.sagernet.fmt.tuic; + +import androidx.annotation.NonNull; + +import com.esotericsoftware.kryo.io.ByteBufferInput; +import com.esotericsoftware.kryo.io.ByteBufferOutput; + +import org.jetbrains.annotations.NotNull; + +import io.nekohasekai.sagernet.fmt.AbstractBean; +import io.nekohasekai.sagernet.fmt.KryoConverters; + +public class TuicBean extends AbstractBean { + + public String token; + public String caText; + public String udpRelayMode; + public String congestionController; + public String alpn; + public Boolean disableSNI; + public Boolean reduceRTT; + public Integer mtu; + public String sni; + public Boolean fastConnect; + public Boolean allowInsecure; + + @Override + public void initializeDefaultValues() { + super.initializeDefaultValues(); + if (token == null) token = ""; + if (caText == null) caText = ""; + if (udpRelayMode == null) udpRelayMode = "native"; + if (congestionController == null) congestionController = "cubic"; + if (alpn == null) alpn = ""; + if (disableSNI == null) disableSNI = false; + if (reduceRTT == null) reduceRTT = false; + if (mtu == null) mtu = 1400; + if (sni == null) sni = ""; + if (fastConnect == null) fastConnect = false; + if (allowInsecure == null) allowInsecure = false; + } + + @Override + public void serialize(ByteBufferOutput output) { + output.writeInt(1); + super.serialize(output); + output.writeString(token); + output.writeString(caText); + output.writeString(udpRelayMode); + output.writeString(congestionController); + output.writeString(alpn); + output.writeBoolean(disableSNI); + output.writeBoolean(reduceRTT); + output.writeInt(mtu); + output.writeString(sni); + output.writeBoolean(fastConnect); + output.writeBoolean(allowInsecure); + } + + @Override + public void deserialize(ByteBufferInput input) { + int version = input.readInt(); + super.deserialize(input); + token = input.readString(); + caText = input.readString(); + udpRelayMode = input.readString(); + congestionController = input.readString(); + alpn = input.readString(); + disableSNI = input.readBoolean(); + reduceRTT = input.readBoolean(); + mtu = input.readInt(); + sni = input.readString(); + if (version >= 1) { + fastConnect = input.readBoolean(); + allowInsecure = input.readBoolean(); + } + } + + @NotNull + @Override + public TuicBean clone() { + return KryoConverters.deserialize(new TuicBean(), KryoConverters.serialize(this)); + } + + public static final Creator CREATOR = new CREATOR() { + @NonNull + @Override + public TuicBean newInstance() { + return new TuicBean(); + } + + @Override + public TuicBean[] newArray(int size) { + return new TuicBean[size]; + } + }; +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/tuic/TuicFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/tuic/TuicFmt.kt new file mode 100644 index 0000000..a74e1b2 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/tuic/TuicFmt.kt @@ -0,0 +1,64 @@ +package io.nekohasekai.sagernet.fmt.tuic + +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.fmt.LOCALHOST +import io.nekohasekai.sagernet.ktx.isIpAddress +import io.nekohasekai.sagernet.ktx.toStringPretty +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import moe.matsuri.nb4a.plugin.Plugins +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.net.InetAddress + +fun TuicBean.buildTuicConfig(port: Int, cacheFile: (() -> File)?): String { + if (Plugins.isUsingMatsuriExe("tuic-plugin")) { + if (!serverAddress.isIpAddress()) { + runBlocking { + finalAddress = withContext(Dispatchers.IO) { + InetAddress.getAllByName(serverAddress) + }?.firstOrNull()?.hostAddress ?: "127.0.0.1" + // TODO network on main thread, tuic don't support "sni" + } + } + } + return JSONObject().apply { + put("relay", JSONObject().apply { + if (sni.isNotBlank()) { + put("server", sni) + put("ip", finalAddress) + } else if (serverAddress.isIpAddress()) { + put("server", finalAddress) + } else { + put("server", serverAddress) + put("ip", finalAddress) + } + put("port", finalPort) + put("token", token) + + if (caText.isNotBlank() && cacheFile != null) { + val caFile = cacheFile() + caFile.writeText(caText) + put("certificates", JSONArray(listOf(caFile.absolutePath))) + } + + put("udp_relay_mode", udpRelayMode) + if (alpn.isNotBlank()) { + put("alpn", JSONArray(alpn.split("\n"))) + } + put("congestion_controller", congestionController) + put("disable_sni", disableSNI) + put("reduce_rtt", reduceRTT) + put("max_udp_relay_packet_size", mtu) + if (fastConnect) put("fast_connect", true) + if (allowInsecure) put("insecure", true) + }) + put("local", JSONObject().apply { + put("ip", LOCALHOST) + put("port", port) + }) + put("log_level", if (DataStore.enableLog) "debug" else "info") + }.toStringPretty() +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/StandardV2RayBean.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/StandardV2RayBean.java new file mode 100644 index 0000000..55ff51a --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/StandardV2RayBean.java @@ -0,0 +1,194 @@ +package io.nekohasekai.sagernet.fmt.v2ray; + +import com.esotericsoftware.kryo.io.ByteBufferInput; +import com.esotericsoftware.kryo.io.ByteBufferOutput; + +import io.nekohasekai.sagernet.fmt.AbstractBean; +import moe.matsuri.nb4a.utils.JavaUtil; + +public abstract class StandardV2RayBean extends AbstractBean { + + public String uuid; + public String encryption; // or VLESS flow + + //////// End of VMess & VLESS //////// + + // "V2Ray Transport" tcp/http/ws/quic/grpc + public String type; + + public String host; + + public String path; + + // --------------------------------------- tls? + + public String security; + + public String sni; + + public String alpn; + + public String utlsFingerprint; + + public Boolean allowInsecure; + + // --------------------------------------- reality + + + public String realityPubKey; + + public String realityShortId; + + + // --------------------------------------- // + + public Integer wsMaxEarlyData; + public String earlyDataHeaderName; + + public String certificates; + + // --------------------------------------- // + + public Integer packetEncoding; // 1:packet 2:xudp + + @Override + public void initializeDefaultValues() { + super.initializeDefaultValues(); + + if (JavaUtil.isNullOrBlank(uuid)) uuid = ""; + + if (JavaUtil.isNullOrBlank(type)) type = "tcp"; + else if ("h2".equals(type)) type = "http"; + + if (JavaUtil.isNullOrBlank(host)) host = ""; + if (JavaUtil.isNullOrBlank(path)) path = ""; + + if (JavaUtil.isNullOrBlank(security)) security = "none"; + if (JavaUtil.isNullOrBlank(sni)) sni = ""; + if (JavaUtil.isNullOrBlank(alpn)) alpn = ""; + + if (JavaUtil.isNullOrBlank(certificates)) certificates = ""; + if (JavaUtil.isNullOrBlank(earlyDataHeaderName)) earlyDataHeaderName = ""; + if (JavaUtil.isNullOrBlank(utlsFingerprint)) utlsFingerprint = ""; + + if (wsMaxEarlyData == null) wsMaxEarlyData = 0; + if (allowInsecure == null) allowInsecure = false; + if (packetEncoding == null) packetEncoding = 0; + + if (realityPubKey == null) realityPubKey = ""; + if (realityShortId == null) realityShortId = ""; + } + + @Override + public void serialize(ByteBufferOutput output) { + output.writeInt(0); + super.serialize(output); + + output.writeString(uuid); + output.writeString(encryption); + if (this instanceof VMessBean) { + output.writeInt(((VMessBean) this).alterId); + } + + output.writeString(type); + switch (type) { + case "tcp": + case "quic": { + break; + } + case "ws": { + output.writeString(host); + output.writeString(path); + output.writeInt(wsMaxEarlyData); + output.writeString(earlyDataHeaderName); + break; + } + case "http": { + output.writeString(host); + output.writeString(path); + break; + } + case "grpc": { + output.writeString(path); + } + } + + output.writeString(security); + if ("tls".equals(security)) { + output.writeString(sni); + output.writeString(alpn); + output.writeString(certificates); + output.writeBoolean(allowInsecure); + output.writeString(utlsFingerprint); + output.writeString(realityPubKey); + output.writeString(realityShortId); + } + + output.writeInt(packetEncoding); + } + + @Override + public void deserialize(ByteBufferInput input) { + int version = input.readInt(); + super.deserialize(input); + uuid = input.readString(); + encryption = input.readString(); + if (this instanceof VMessBean) { + ((VMessBean) this).alterId = input.readInt(); + } + + type = input.readString(); + switch (type) { + case "tcp": + case "quic": { + break; + } + case "ws": { + host = input.readString(); + path = input.readString(); + wsMaxEarlyData = input.readInt(); + earlyDataHeaderName = input.readString(); + break; + } + case "http": { + host = input.readString(); + path = input.readString(); + break; + } + case "grpc": { + path = input.readString(); + } + } + + security = input.readString(); + if ("tls".equals(security)) { + sni = input.readString(); + alpn = input.readString(); + certificates = input.readString(); + allowInsecure = input.readBoolean(); + utlsFingerprint = input.readString(); + realityPubKey = input.readString(); + realityShortId = input.readString(); + } + + packetEncoding = input.readInt(); + } + + @Override + public void applyFeatureSettings(AbstractBean other) { + if (!(other instanceof StandardV2RayBean)) return; + StandardV2RayBean bean = ((StandardV2RayBean) other); + bean.allowInsecure = allowInsecure; + bean.utlsFingerprint = utlsFingerprint; + bean.realityPubKey = realityPubKey; + bean.realityShortId = realityShortId; + } + + public boolean isVLESS() { + if (this instanceof VMessBean) { + return ((VMessBean) this).alterId == -1; + } + return false; + } + +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayFmt.kt new file mode 100644 index 0000000..94a4aa2 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayFmt.kt @@ -0,0 +1,608 @@ +package io.nekohasekai.sagernet.fmt.v2ray + +import com.google.gson.Gson +import io.nekohasekai.sagernet.fmt.http.HttpBean +import io.nekohasekai.sagernet.fmt.trojan.TrojanBean +import moe.matsuri.nb4a.SingBoxOptions.* +import io.nekohasekai.sagernet.ktx.* +import moe.matsuri.nb4a.utils.NGUtil +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.json.JSONObject + +fun StandardV2RayBean.isTLS(): Boolean { + return security == "tls" +} + +fun StandardV2RayBean.setTLS(boolean: Boolean) { + security = if (boolean) "tls" else "" +} + +fun parseV2Ray(link: String): StandardV2RayBean { + // Try parse stupid formats first + + if (!link.contains("?")) { + try { + return parseV2RayN(link) + } catch (e: Exception) { + Logs.i("try v2rayN: " + e.readableMessage) + } + } + + try { + return tryResolveVmess4Kitsunebi(link) + } catch (e: Exception) { + Logs.i("try Kitsunebi: " + e.readableMessage) + } + + // "std" format + + val bean = VMessBean().apply { if (link.startsWith("vless://")) alterId = -1 } + val url = link.replace("vmess://", "https://").replace("vless://", "https://").toHttpUrl() + + if (url.password.isNotBlank()) { + // https://github.com/v2fly/v2fly-github-io/issues/26 (rarely use) + bean.serverAddress = url.host + bean.serverPort = url.port + bean.name = url.fragment + + var protocol = url.username + bean.type = protocol + bean.alterId = url.password.substringAfterLast('-').toInt() + bean.uuid = url.password.substringBeforeLast('-') + + if (protocol.endsWith("+tls")) { + bean.security = "tls" + protocol = protocol.substring(0, protocol.length - 4) + + url.queryParameter("tlsServerName")?.let { + if (it.isNotBlank()) { + bean.sni = it + } + } + } + + when (protocol) { +// "tcp" -> { +// url.queryParameter("type")?.let { type -> +// if (type == "http") { +// bean.headerType = "http" +// url.queryParameter("host")?.let { +// bean.host = it +// } +// } +// } +// } + "http" -> { + url.queryParameter("path")?.let { + bean.path = it + } + url.queryParameter("host")?.let { + bean.host = it.split("|").joinToString(",") + } + } + "ws" -> { + url.queryParameter("path")?.let { + bean.path = it + } + url.queryParameter("host")?.let { + bean.host = it + } + } + "grpc" -> { + url.queryParameter("serviceName")?.let { + bean.path = it + } + } + } + } else { + // also vless format + bean.parseDuckSoft(url) + } + + return bean +} + +// https://github.com/XTLS/Xray-core/issues/91 +// NO allowInsecure +fun StandardV2RayBean.parseDuckSoft(url: HttpUrl) { + serverAddress = url.host + serverPort = url.port + name = url.fragment + + if (this is TrojanBean) { + password = url.username + } else { + uuid = url.username + } + + if (url.pathSegments.size > 1 || url.pathSegments[0].isNotBlank()) { + path = url.pathSegments.joinToString("/") + } + + type = url.queryParameter("type") ?: "tcp" + security = url.queryParameter("security") + if (security == null) { + security = if (this is TrojanBean) { + "tls" + } else { + "none" + } + } + + when (security) { + "tls" -> { + url.queryParameter("sni")?.let { + sni = it + } + url.queryParameter("alpn")?.let { + alpn = it + } + url.queryParameter("cert")?.let { + certificates = it + } + } + } + when (type) { + "http" -> { + url.queryParameter("host")?.let { + host = it + } + url.queryParameter("path")?.let { + path = it + } + } + "ws" -> { + url.queryParameter("host")?.let { + host = it + } + url.queryParameter("path")?.let { + path = it + } + url.queryParameter("ed")?.let { ed -> + wsMaxEarlyData = ed.toInt() + + url.queryParameter("eh")?.let { + earlyDataHeaderName = it + } + } + } + "grpc" -> { + url.queryParameter("serviceName")?.let { + path = it + } + } + } + + url.queryParameter("packetEncoding")?.let { + when (it) { + "packet" -> packetEncoding = 1 + "xudp" -> packetEncoding = 2 + } + } + + url.queryParameter("flow")?.let { + if (isVLESS && it.contains("vision")) { + encryption = it + } + } +} + +// 不确定是谁的格式 +private fun tryResolveVmess4Kitsunebi(server: String): VMessBean { + // vmess://YXV0bzo1YWY1ZDBlYy02ZWEwLTNjNDMtOTNkYi1jYTMwMDg1MDNiZGJAMTgzLjIzMi41Ni4xNjE6MTIwMg + // ?remarks=*%F0%9F%87%AF%F0%9F%87%B5JP%20-355%20TG@moon365free&obfsParam=%7B%22Host%22:%22183.232.56.161%22%7D&path=/v2ray&obfs=websocket&alterId=0 + + var result = server.replace("vmess://", "") + val indexSplit = result.indexOf("?") + if (indexSplit > 0) { + result = result.substring(0, indexSplit) + } + result = NGUtil.decode(result) + + val arr1 = result.split('@') + if (arr1.count() != 2) { + throw IllegalStateException("invalid kitsunebi format") + } + val arr21 = arr1[0].split(':') + val arr22 = arr1[1].split(':') + if (arr21.count() != 2) { + throw IllegalStateException("invalid kitsunebi format") + } + + return VMessBean().apply { + serverAddress = arr22[0] + serverPort = NGUtil.parseInt(arr22[1]) + uuid = arr21[1] + encryption = arr21[0] + if (indexSplit < 0) return@apply + + val url = ("https://localhost/path?" + server.substringAfter("?")).toHttpUrl() + url.queryParameter("remarks")?.apply { name = this } + url.queryParameter("alterId")?.apply { alterId = this.toInt() } + url.queryParameter("path")?.apply { path = this } + url.queryParameter("tls")?.apply { security = "tls" } + url.queryParameter("allowInsecure") + ?.apply { if (this == "1" || this == "true") allowInsecure = true } + url.queryParameter("obfs")?.apply { + type = this.replace("websocket", "ws").replace("none", "tcp") + if (type == "ws") { + url.queryParameter("obfsParam")?.apply { + if (this.startsWith("{")) { + host = JSONObject(this).getStr("Host") + } else if (security == "tls") { + sni = this + } + } + } + } + } +} + +// SagerNet's +// Do not support some format and then throw exception +fun parseV2RayN(link: String): VMessBean { + val result = link.substringAfter("vmess://").decodeBase64UrlSafe() + if (result.contains("= vmess")) { + return parseCsvVMess(result) + } + val bean = VMessBean() + val json = JSONObject(result) + + bean.serverAddress = json.getStr("add") ?: "" + bean.serverPort = json.getIntNya("port") ?: 1080 + bean.encryption = json.getStr("scy") ?: "" + bean.uuid = json.getStr("id") ?: "" + bean.alterId = json.getIntNya("aid") ?: 0 + bean.type = json.getStr("net") ?: "" + bean.host = json.getStr("host") ?: "" + bean.path = json.getStr("path") ?: "" + + when (bean.type) { + "grpc" -> { + bean.path = bean.path + } + } + + bean.name = json.getStr("ps") ?: "" + bean.sni = json.getStr("sni") ?: bean.host + bean.security = json.getStr("tls") + + if (json.optInt("v", 2) < 2) { + when (bean.type) { + "ws" -> { + var path = "" + var host = "" + val lstParameter = bean.host.split(";") + if (lstParameter.isNotEmpty()) { + path = lstParameter[0].trim() + } + if (lstParameter.size > 1) { + path = lstParameter[0].trim() + host = lstParameter[1].trim() + } + bean.path = path + bean.host = host + } + "h2" -> { + var path = "" + var host = "" + val lstParameter = bean.host.split(";") + if (lstParameter.isNotEmpty()) { + path = lstParameter[0].trim() + } + if (lstParameter.size > 1) { + path = lstParameter[0].trim() + host = lstParameter[1].trim() + } + bean.path = path + bean.host = host + } + } + } + + return bean + +} + +private fun parseCsvVMess(csv: String): VMessBean { + + val args = csv.split(",") + + val bean = VMessBean() + + bean.serverAddress = args[1] + bean.serverPort = args[2].toInt() + bean.encryption = args[3] + bean.uuid = args[4].replace("\"", "") + + args.subList(5, args.size).forEach { + + when { + it == "over-tls=true" -> bean.security = "tls" + it.startsWith("tls-host=") -> bean.host = it.substringAfter("=") + it.startsWith("obfs=") -> bean.type = it.substringAfter("=") + it.startsWith("obfs-path=") || it.contains("Host:") -> { + runCatching { + bean.path = it.substringAfter("obfs-path=\"").substringBefore("\"obfs") + } + runCatching { + bean.host = it.substringAfter("Host:").substringBefore("[") + } + + } + + } + + } + + return bean + +} + +data class VmessQRCode( + var v: String = "", + var ps: String = "", + var add: String = "", + var port: String = "", + var id: String = "", + var aid: String = "0", + var scy: String = "", + var net: String = "", + var type: String = "", + var host: String = "", + var path: String = "", + var tls: String = "", + var sni: String = "", + var alpn: String = "" +) + +fun VMessBean.toV2rayN(): String { + if (isVLESS) return toUri(true) + return "vmess://" + VmessQRCode().apply { + v = "2" + ps = this@toV2rayN.name + add = this@toV2rayN.serverAddress + port = this@toV2rayN.serverPort.toString() + id = this@toV2rayN.uuid + aid = this@toV2rayN.alterId.toString() + net = this@toV2rayN.type + host = this@toV2rayN.host + path = this@toV2rayN.path + + when (net) { + "grpc" -> { + path = this@toV2rayN.path + } + } + + tls = if (this@toV2rayN.security == "tls") "tls" else "" + sni = this@toV2rayN.sni + scy = this@toV2rayN.encryption + }.let { + NGUtil.encode(Gson().toJson(it)) + } +} + +fun StandardV2RayBean.toUri(standard: Boolean = true): String { + if (this is VMessBean && alterId > 0) return toV2rayN() + + val builder = linkBuilder() + .username(if (this is TrojanBean) password else uuid) + .host(serverAddress) + .port(serverPort) + .addQueryParameter("type", type) + if (this !is TrojanBean) builder.addQueryParameter("encryption", encryption) + + when (type) { + "tcp" -> {} + "ws", "http" -> { + if (host.isNotBlank()) { + builder.addQueryParameter("host", host) + } + if (path.isNotBlank()) { + if (standard) { + builder.addQueryParameter("path", path) + } else { + builder.encodedPath(path.pathSafe()) + } + } + if (type == "ws") { + if (wsMaxEarlyData > 0) { + builder.addQueryParameter("ed", "$wsMaxEarlyData") + if (earlyDataHeaderName.isNotBlank()) { + builder.addQueryParameter("eh", earlyDataHeaderName) + } + } + } else if (type == "http" && !isTLS()) { + return "" // no fmt? + } + } + "grpc" -> { + if (path.isNotBlank()) { + builder.addQueryParameter("serviceName", path) + } + } + } + + if (security.isNotBlank() && security != "none") { + builder.addQueryParameter("security", security) + when (security) { + "tls" -> { + if (sni.isNotBlank()) { + builder.addQueryParameter("sni", sni) + } + if (alpn.isNotBlank()) { + builder.addQueryParameter("alpn", alpn) + } + if (certificates.isNotBlank()) { + builder.addQueryParameter("cert", certificates) + } + if (allowInsecure) builder.addQueryParameter("allowInsecure", "1") + } + } + } + + when (packetEncoding) { + 1 -> { + builder.addQueryParameter("packetEncoding", "packet") + } + 2 -> { + builder.addQueryParameter("packetEncoding", "xudp") + } + } + + if (name.isNotBlank()) { + builder.encodedFragment(name.urlSafe()) + } + + // TODO vless flow: bean.encryption != "auto" + + return builder.toLink(if (isVLESS) "vless" else "vmess") + +} + +fun buildSingBoxOutboundStreamSettings(bean: StandardV2RayBean): V2RayTransportOptions? { + when (bean.type) { + "tcp" -> { + return null + } + "ws" -> { + return V2RayTransportOptions_WebsocketOptions().apply { + type = "ws" + headers = mutableMapOf() + + if (bean.host.isNotBlank()) { + headers["Host"] = bean.host + } + + if (bean.path.contains("?ed=")) { + path = bean.path.substringBefore("?ed=") + max_early_data = bean.path.substringAfter("?ed=").toIntOrNull() ?: 2048 + early_data_header_name = "Sec-WebSocket-Protocol" + } else { + path = bean.path.takeIf { it.isNotBlank() } ?: "/" + } + + if (bean.wsMaxEarlyData > 0) { + max_early_data = bean.wsMaxEarlyData + } + + if (bean.earlyDataHeaderName.isNotBlank()) { + early_data_header_name = bean.earlyDataHeaderName + } + } + } + "http" -> { + return V2RayTransportOptions_HTTPOptions().apply { + type = "http" + if (bean.host.isNotBlank()) { + host = bean.host.split(",") + } + path = bean.path.takeIf { it.isNotBlank() } ?: "/" + } + } + "quic" -> { + return V2RayTransportOptions().apply { + type = "quic" + } + } + "grpc" -> { + return V2RayTransportOptions_GRPCOptions().apply { + type = "grpc" + service_name = bean.path + } + } + } + +// if (needKeepAliveInterval) { +// sockopt = StreamSettingsObject.SockoptObject().apply { +// tcpKeepAliveInterval = keepAliveInterval +// } +// } + + return null +} + +fun buildSingBoxOutboundTLS(bean: StandardV2RayBean): OutboundTLSOptions? { + if (bean.security != "tls") return null + return OutboundTLSOptions().apply { + enabled = true + insecure = bean.allowInsecure + if (bean.sni.isNotBlank()) server_name = bean.sni + if (bean.alpn.isNotBlank()) alpn = bean.alpn.split("\n") + if (bean.certificates.isNotBlank()) certificate = bean.certificates + if (bean.utlsFingerprint.isNotBlank()) { + utls = OutboundUTLSOptions().apply { + enabled = true + fingerprint = bean.utlsFingerprint + } + } + if (bean.realityPubKey.isNotBlank() && bean.realityShortId.isNotBlank()) { + reality = OutboundRealityOptions().apply { + enabled = true + public_key = bean.realityPubKey + short_id = bean.realityShortId + } + } + } +} + +fun buildSingBoxOutboundStandardV2RayBean(bean: StandardV2RayBean): Outbound { + when (bean) { + is HttpBean -> { + return Outbound_HTTPOptions().apply { + type = "http" + server = bean.serverAddress + server_port = bean.serverPort + username = bean.username + password = bean.password + tls = buildSingBoxOutboundTLS(bean) + } + } + is VMessBean -> { + if (bean.isVLESS) return Outbound_VLESSOptions().apply { + type = "vless" + server = bean.serverAddress + server_port = bean.serverPort + uuid = bean.uuid + if (bean.encryption.isNotBlank() && bean.encryption != "auto") { + flow = bean.encryption + } + when (bean.packetEncoding) { + 0 -> packet_encoding = "" + 1 -> packet_encoding = "packetaddr" + 2 -> packet_encoding = "xudp" + } + tls = buildSingBoxOutboundTLS(bean) + transport = buildSingBoxOutboundStreamSettings(bean) + } + return Outbound_VMessOptions().apply { + type = "vmess" + server = bean.serverAddress + server_port = bean.serverPort + uuid = bean.uuid + alter_id = bean.alterId + security = bean.encryption.takeIf { it.isNotBlank() } ?: "auto" + when (bean.packetEncoding) { + 0 -> packet_encoding = "" + 1 -> packet_encoding = "packetaddr" + 2 -> packet_encoding = "xudp" + } + tls = buildSingBoxOutboundTLS(bean) + transport = buildSingBoxOutboundStreamSettings(bean) + } + } + is TrojanBean -> { + return Outbound_TrojanOptions().apply { + type = "trojan" + server = bean.serverAddress + server_port = bean.serverPort + password = bean.password + tls = buildSingBoxOutboundTLS(bean) + transport = buildSingBoxOutboundStreamSettings(bean) + } + } + else -> throw IllegalStateException("can't reach") + } +} 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 0000000..84044da --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/VMessBean.java @@ -0,0 +1,40 @@ +package io.nekohasekai.sagernet.fmt.v2ray; + +import androidx.annotation.NonNull; + +import org.jetbrains.annotations.NotNull; + +import io.nekohasekai.sagernet.fmt.KryoConverters; +import moe.matsuri.nb4a.utils.JavaUtil; + +public class VMessBean extends StandardV2RayBean { + + public Integer alterId; // alterID == -1 --> VLESS + + @Override + public void initializeDefaultValues() { + super.initializeDefaultValues(); + + alterId = alterId != null ? alterId : 0; + encryption = JavaUtil.isNotBlank(encryption) ? encryption : "auto"; + } + + @NotNull + @Override + public VMessBean clone() { + return KryoConverters.deserialize(new VMessBean(), KryoConverters.serialize(this)); + } + + public static final Creator CREATOR = new CREATOR() { + @NonNull + @Override + public VMessBean newInstance() { + return new VMessBean(); + } + + @Override + public VMessBean[] newArray(int size) { + return new VMessBean[size]; + } + }; +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/wireguard/WireGuardBean.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/wireguard/WireGuardBean.java new file mode 100644 index 0000000..ecd990d --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/wireguard/WireGuardBean.java @@ -0,0 +1,75 @@ +package io.nekohasekai.sagernet.fmt.wireguard; + +import androidx.annotation.NonNull; + +import com.esotericsoftware.kryo.io.ByteBufferInput; +import com.esotericsoftware.kryo.io.ByteBufferOutput; + +import org.jetbrains.annotations.NotNull; + +import io.nekohasekai.sagernet.fmt.AbstractBean; +import io.nekohasekai.sagernet.fmt.KryoConverters; + +public class WireGuardBean extends AbstractBean { + + public String localAddress; + public String privateKey; + public String peerPublicKey; + public String peerPreSharedKey; + public Integer mtu; + public String reserved; + + @Override + public void initializeDefaultValues() { + super.initializeDefaultValues(); + if (localAddress == null) localAddress = ""; + if (privateKey == null) privateKey = ""; + if (peerPublicKey == null) peerPublicKey = ""; + if (peerPreSharedKey == null) peerPreSharedKey = ""; + if (mtu == null) mtu = 1420; + if (reserved == null) reserved = ""; + } + + @Override + public void serialize(ByteBufferOutput output) { + output.writeInt(2); + super.serialize(output); + output.writeString(localAddress); + output.writeString(privateKey); + output.writeString(peerPublicKey); + output.writeString(peerPreSharedKey); + output.writeInt(mtu); + output.writeString(reserved); + } + + @Override + public void deserialize(ByteBufferInput input) { + int version = input.readInt(); + super.deserialize(input); + localAddress = input.readString(); + privateKey = input.readString(); + peerPublicKey = input.readString(); + peerPreSharedKey = input.readString(); + mtu = input.readInt(); + reserved = input.readString(); + } + + @NotNull + @Override + public WireGuardBean clone() { + return KryoConverters.deserialize(new WireGuardBean(), KryoConverters.serialize(this)); + } + + public static final Creator CREATOR = new CREATOR() { + @NonNull + @Override + public WireGuardBean newInstance() { + return new WireGuardBean(); + } + + @Override + public WireGuardBean[] newArray(int size) { + return new WireGuardBean[size]; + } + }; +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/wireguard/WireGuardFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/wireguard/WireGuardFmt.kt new file mode 100644 index 0000000..57526eb --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/wireguard/WireGuardFmt.kt @@ -0,0 +1,17 @@ +package io.nekohasekai.sagernet.fmt.wireguard + +import moe.matsuri.nb4a.SingBoxOptions + +fun buildSingBoxOutboundWireguardBean(bean: WireGuardBean): SingBoxOptions.Outbound_WireGuardOptions_Fix { + return SingBoxOptions.Outbound_WireGuardOptions_Fix().apply { + type = "wireguard" + server = bean.serverAddress + server_port = bean.serverPort + local_address = bean.localAddress.split("\n") + private_key = bean.privateKey + peer_public_key = bean.peerPublicKey + pre_shared_key = bean.peerPreSharedKey + mtu = bean.mtu + if (bean.reserved.isNotBlank()) reserved = bean.reserved + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/group/GroupInterfaceAdapter.kt b/app/src/main/java/io/nekohasekai/sagernet/group/GroupInterfaceAdapter.kt new file mode 100644 index 0000000..7d0295e --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/group/GroupInterfaceAdapter.kt @@ -0,0 +1,102 @@ +package io.nekohasekai.sagernet.group + +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.GroupManager +import io.nekohasekai.sagernet.database.ProxyGroup +import io.nekohasekai.sagernet.ktx.onMainDispatcher +import io.nekohasekai.sagernet.ktx.runOnMainDispatcher +import io.nekohasekai.sagernet.ui.ThemedActivity +import kotlinx.coroutines.delay +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +class GroupInterfaceAdapter(val context: ThemedActivity) : GroupManager.Interface { + + override suspend fun confirm(message: String): Boolean { + return suspendCoroutine { + runOnMainDispatcher { + MaterialAlertDialogBuilder(context).setTitle(R.string.confirm) + .setMessage(message) + .setPositiveButton(R.string.yes) { _, _ -> it.resume(true) } + .setNegativeButton(R.string.no) { _, _ -> it.resume(false) } + .setOnCancelListener { _ -> it.resume(false) } + .show() + } + } + } + + override suspend fun onUpdateSuccess( + group: ProxyGroup, + changed: Int, + added: List, + updated: Map, + deleted: List, + duplicate: List, + byUser: Boolean + ) { + if (changed == 0 && duplicate.isEmpty()) { + if (byUser) context.snackbar( + context.getString( + R.string.group_no_difference, group.displayName() + ) + ).show() + } else { + context.snackbar(context.getString(R.string.group_updated, group.name, changed)).show() + + var status = "" + if (added.isNotEmpty()) { + status += context.getString( + R.string.group_added, added.joinToString("\n", postfix = "\n\n") + ) + } + if (updated.isNotEmpty()) { + status += context.getString(R.string.group_changed, + updated.map { it }.joinToString("\n", postfix = "\n\n") { + if (it.key == it.value) it.key else "${it.key} => ${it.value}" + }) + } + if (deleted.isNotEmpty()) { + status += context.getString( + R.string.group_deleted, deleted.joinToString("\n", postfix = "\n\n") + ) + } + if (duplicate.isNotEmpty()) { + status += context.getString( + R.string.group_duplicate, duplicate.joinToString("\n", postfix = "\n\n") + ) + } + + onMainDispatcher { + delay(1000L) + + MaterialAlertDialogBuilder(context).setTitle( + context.getString( + R.string.group_diff, group.displayName() + ) + ).setMessage(status.trim()).setPositiveButton(android.R.string.ok, null).show() + } + + } + + } + + override suspend fun onUpdateFailure(group: ProxyGroup, message: String) { + onMainDispatcher { + context.snackbar(message).show() + } + } + + override suspend fun alert(message: String) { + return suspendCoroutine { + runOnMainDispatcher { + MaterialAlertDialogBuilder(context).setTitle(R.string.ooc_warning) + .setMessage(message) + .setPositiveButton(android.R.string.ok) { _, _ -> it.resume(Unit) } + .setOnCancelListener { _ -> it.resume(Unit) } + .show() + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/group/GroupUpdater.kt b/app/src/main/java/io/nekohasekai/sagernet/group/GroupUpdater.kt new file mode 100644 index 0000000..426e3d7 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/group/GroupUpdater.kt @@ -0,0 +1,171 @@ +package io.nekohasekai.sagernet.group + +import io.nekohasekai.sagernet.* +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.GroupManager +import io.nekohasekai.sagernet.database.ProxyGroup +import io.nekohasekai.sagernet.database.SubscriptionBean +import io.nekohasekai.sagernet.fmt.AbstractBean +import io.nekohasekai.sagernet.fmt.http.HttpBean +import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean +import io.nekohasekai.sagernet.fmt.naive.NaiveBean +import io.nekohasekai.sagernet.fmt.trojan.TrojanBean +import io.nekohasekai.sagernet.fmt.trojan_go.TrojanGoBean +import io.nekohasekai.sagernet.fmt.v2ray.StandardV2RayBean +import io.nekohasekai.sagernet.fmt.v2ray.isTLS +import io.nekohasekai.sagernet.ktx.* +import kotlinx.coroutines.* +import java.net.Inet4Address +import java.net.InetAddress +import java.util.* +import java.util.concurrent.atomic.AtomicInteger + +@Suppress("EXPERIMENTAL_API_USAGE") +abstract class GroupUpdater { + + abstract suspend fun doUpdate( + proxyGroup: ProxyGroup, + subscription: SubscriptionBean, + userInterface: GroupManager.Interface?, + byUser: Boolean + ) + + data class Progress( + var max: Int + ) { + var progress by AtomicInteger() + } + + protected suspend fun forceResolve( + profiles: List, groupId: Long? + ) { + val ipv6Mode = DataStore.ipv6Mode + val lookupPool = newFixedThreadPoolContext(5, "DNS Lookup") + val lookupJobs = mutableListOf() + val progress = Progress(profiles.size) + if (groupId != null) { + GroupUpdater.progress[groupId] = progress + GroupManager.postReload(groupId) + } + val ipv6First = ipv6Mode >= IPv6Mode.PREFER + + for (profile in profiles) { + when (profile) { + // SNI rewrite unsupported + is NaiveBean -> continue + } + + if (profile.serverAddress.isIpAddress()) continue + + lookupJobs.add(GlobalScope.launch(lookupPool) { + try { + val results = if ( + SagerNet.underlyingNetwork != null && + DataStore.enableFakeDns && + DataStore.serviceState.started && + DataStore.serviceMode == Key.MODE_VPN + ) { + // FakeDNS + SagerNet.underlyingNetwork!! + .getAllByName(profile.serverAddress) + .filterNotNull() + } else { + // System DNS is enough (when VPN connected, it uses v2ray-core) + InetAddress.getAllByName(profile.serverAddress).filterNotNull() + } + if (results.isEmpty()) error("empty response") + rewriteAddress(profile, results, ipv6First) + } catch (e: Exception) { + Logs.d("Lookup ${profile.serverAddress} failed: ${e.readableMessage}", e) + } + if (groupId != null) { + progress.progress++ + GroupManager.postReload(groupId) + } + }) + } + + lookupJobs.joinAll() + lookupPool.close() + } + + protected fun rewriteAddress( + bean: AbstractBean, addresses: List, ipv6First: Boolean + ) { + val address = addresses.sortedBy { (it is Inet4Address) xor ipv6First }[0].hostAddress + + with(bean) { + when (this) { + is HttpBean -> { + if (isTLS() && sni.isBlank()) sni = bean.serverAddress + } + is StandardV2RayBean -> { + when (security) { + "tls" -> if (sni.isBlank()) sni = bean.serverAddress + } + } + is TrojanBean -> { + if (sni.isBlank()) sni = bean.serverAddress + } + is TrojanGoBean -> { + if (sni.isBlank()) sni = bean.serverAddress + } + is HysteriaBean -> { + if (sni.isBlank()) sni = bean.serverAddress + } + } + + bean.serverAddress = address + } + } + + companion object { + + val updating = Collections.synchronizedSet(mutableSetOf()) + val progress = Collections.synchronizedMap(mutableMapOf()) + + fun startUpdate(proxyGroup: ProxyGroup, byUser: Boolean) { + runOnDefaultDispatcher { + executeUpdate(proxyGroup, byUser) + } + } + + suspend fun executeUpdate(proxyGroup: ProxyGroup, byUser: Boolean): Boolean { + return coroutineScope { + if (!updating.add(proxyGroup.id)) cancel() + GroupManager.postReload(proxyGroup.id) + + val subscription = proxyGroup.subscription!! + val connected = DataStore.serviceState.connected + val userInterface = GroupManager.userInterface + + if (byUser && (subscription.link?.startsWith("http://") == true || subscription.updateWhenConnectedOnly) && !connected) { + if (userInterface == null || !userInterface.confirm(app.getString(R.string.update_subscription_warning))) { + finishUpdate(proxyGroup) + cancel() + return@coroutineScope true + } + } + + try { + RawUpdater.doUpdate(proxyGroup, subscription, userInterface, byUser) + true + } catch (e: Throwable) { + Logs.w(e) + userInterface?.onUpdateFailure(proxyGroup, e.readableMessage) + finishUpdate(proxyGroup) + false + } + } + } + + + suspend fun finishUpdate(proxyGroup: ProxyGroup) { + updating.remove(proxyGroup.id) + progress.remove(proxyGroup.id) + GroupManager.postUpdate(proxyGroup) + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt b/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt new file mode 100644 index 0000000..980104d --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt @@ -0,0 +1,495 @@ +package io.nekohasekai.sagernet.group + +import android.net.Uri +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.* +import io.nekohasekai.sagernet.fmt.AbstractBean +import io.nekohasekai.sagernet.fmt.http.HttpBean +import io.nekohasekai.sagernet.fmt.hysteria.parseHysteria +import io.nekohasekai.sagernet.fmt.shadowsocks.ShadowsocksBean +import io.nekohasekai.sagernet.fmt.shadowsocks.parseShadowsocks +import io.nekohasekai.sagernet.fmt.socks.SOCKSBean +import io.nekohasekai.sagernet.fmt.trojan.TrojanBean +import io.nekohasekai.sagernet.fmt.trojan_go.parseTrojanGo +import io.nekohasekai.sagernet.fmt.v2ray.VMessBean +import io.nekohasekai.sagernet.fmt.v2ray.isTLS +import io.nekohasekai.sagernet.fmt.v2ray.setTLS +import io.nekohasekai.sagernet.fmt.wireguard.WireGuardBean +import io.nekohasekai.sagernet.ktx.* +import libcore.Libcore +import moe.matsuri.nb4a.Protocols +import org.ini4j.Ini +import org.json.JSONArray +import org.json.JSONObject +import org.json.JSONTokener +import org.yaml.snakeyaml.TypeDescription +import org.yaml.snakeyaml.Yaml +import org.yaml.snakeyaml.error.YAMLException +import java.io.StringReader + +@Suppress("EXPERIMENTAL_API_USAGE") +object RawUpdater : GroupUpdater() { + + override suspend fun doUpdate( + proxyGroup: ProxyGroup, + subscription: SubscriptionBean, + userInterface: GroupManager.Interface?, + byUser: Boolean + ) { + + val link = subscription.link + var proxies: List + if (link.startsWith("content://")) { + val contentText = app.contentResolver.openInputStream(Uri.parse(link)) + ?.bufferedReader() + ?.readText() + + proxies = contentText?.let { parseRaw(contentText) } + ?: error(app.getString(R.string.no_proxies_found_in_subscription)) + } else { + + val response = Libcore.newHttpClient().apply { + trySocks5(DataStore.mixedPort) + when (DataStore.appTLSVersion) { + "1.3" -> restrictedTLS() + } + }.newRequest().apply { + setURL(subscription.link) + setUserAgent(subscription.customUserAgent.takeIf { it.isNotBlank() } ?: USER_AGENT) + }.execute() + + proxies = parseRaw(response.contentString) + ?: error(app.getString(R.string.no_proxies_found)) + + subscription.subscriptionUserinfo = response.getHeader("Subscription-Userinfo") + } + + val proxiesMap = LinkedHashMap() + for (proxy in proxies) { + var index = 0 + var name = proxy.displayName() + while (proxiesMap.containsKey(name)) { + println("Exists name: $name") + index++ + name = name.replace(" (${index - 1})", "") + name = "$name ($index)" + proxy.name = name + } + proxiesMap[proxy.displayName()] = proxy + } + proxies = proxiesMap.values.toList() + + if (subscription.forceResolve) forceResolve(proxies, proxyGroup.id) + + val exists = SagerDatabase.proxyDao.getByGroup(proxyGroup.id) + val duplicate = ArrayList() + if (subscription.deduplication) { + Logs.d("Before deduplication: ${proxies.size}") + val uniqueProxies = LinkedHashSet() + val uniqueNames = HashMap() + for (_proxy in proxies) { + val proxy = Protocols.Deduplication(_proxy) + if (!uniqueProxies.add(proxy)) { + val index = uniqueProxies.indexOf(proxy) + if (uniqueNames.containsKey(proxy)) { + val name = uniqueNames[proxy]!!.replace(" ($index)", "") + if (name.isNotBlank()) { + duplicate.add("$name ($index)") + uniqueNames[proxy] = "" + } + } + duplicate.add(_proxy.displayName() + " ($index)") + } else { + uniqueNames[proxy] = _proxy.displayName() + } + } + uniqueProxies.retainAll(uniqueNames.keys) + proxies = uniqueProxies.toList().map { it.bean } + } + + Logs.d("New profiles: ${proxies.size}") + + val nameMap = proxies.associateBy { bean -> + bean.displayName() + } + + Logs.d("Unique profiles: ${nameMap.size}") + + val toDelete = ArrayList() + val toReplace = exists.mapNotNull { entity -> + val name = entity.displayName() + if (nameMap.contains(name)) name to entity else let { + toDelete.add(entity) + null + } + }.toMap() + + Logs.d("toDelete profiles: ${toDelete.size}") + Logs.d("toReplace profiles: ${toReplace.size}") + + val toUpdate = ArrayList() + val added = mutableListOf() + val updated = mutableMapOf() + val deleted = toDelete.map { it.displayName() } + + var userOrder = 1L + var changed = toDelete.size + for ((name, bean) in nameMap.entries) { + if (toReplace.contains(name)) { + val entity = toReplace[name]!! + val existsBean = entity.requireBean() + existsBean.applyFeatureSettings(bean) + when { + existsBean != bean -> { + changed++ + entity.putBean(bean) + toUpdate.add(entity) + updated[entity.displayName()] = name + + Logs.d("Updated profile: $name") + } + entity.userOrder != userOrder -> { + entity.putBean(bean) + toUpdate.add(entity) + entity.userOrder = userOrder + + Logs.d("Reordered profile: $name") + } + else -> { + Logs.d("Ignored profile: $name") + } + } + } else { + changed++ + SagerDatabase.proxyDao.addProxy(ProxyEntity( + groupId = proxyGroup.id, userOrder = userOrder + ).apply { + putBean(bean) + }) + added.add(name) + Logs.d("Inserted profile: $name") + } + userOrder++ + } + + SagerDatabase.proxyDao.updateProxy(toUpdate).also { + Logs.d("Updated profiles: $it") + } + + SagerDatabase.proxyDao.deleteProxy(toDelete).also { + Logs.d("Deleted profiles: $it") + } + + val existCount = SagerDatabase.proxyDao.countByGroup(proxyGroup.id).toInt() + + if (existCount != proxies.size) { + Logs.e("Exist profiles: $existCount, new profiles: ${proxies.size}") + } + + subscription.lastUpdated = (System.currentTimeMillis() / 1000).toInt() + SagerDatabase.groupDao.updateGroup(proxyGroup) + finishUpdate(proxyGroup) + + userInterface?.onUpdateSuccess( + proxyGroup, changed, added, updated, deleted, duplicate, byUser + ) + } + + @Suppress("UNCHECKED_CAST") + suspend fun parseRaw(text: String): List? { + + val proxies = mutableListOf() + + if (text.contains("proxies:")) { + + try { + + // clash + for (proxy in (Yaml().apply { + addTypeDescription(TypeDescription(String::class.java, "str")) + }.loadAs(text, Map::class.java)["proxies"] as? (List>) ?: error( + app.getString(R.string.no_proxies_found_in_file) + ))) { + // Note: YAML numbers parsed as "Long" + + when (proxy["type"] as String) { + "socks5" -> { + proxies.add(SOCKSBean().apply { + serverAddress = proxy["server"] as String + serverPort = proxy["port"].toString().toInt() + username = proxy["username"]?.toString() + password = proxy["password"]?.toString() + name = proxy["name"]?.toString() + }) + } + "http" -> { + proxies.add(HttpBean().apply { + serverAddress = proxy["server"] as String + serverPort = proxy["port"].toString().toInt() + username = proxy["username"]?.toString() + password = proxy["password"]?.toString() + setTLS(proxy["tls"]?.toString() == "true") + sni = proxy["sni"]?.toString() + name = proxy["name"]?.toString() + }) + } + "ss" -> { + val ssPlugin = mutableListOf() + if (proxy.contains("plugin")) { + val opts = proxy["plugin-opts"] as Map + when (proxy["plugin"]) { + "obfs" -> { + ssPlugin.apply { + add("obfs-local") + add("obfs=" + (opts["mode"]?.toString() ?: "")) + add("obfs-host=" + (opts["host"]?.toString() ?: "")) + } + } + "v2ray-plugin" -> { + ssPlugin.apply { + add("v2ray-plugin") + add("mode=" + (opts["mode"]?.toString() ?: "")) + if (opts["mode"]?.toString() == "true") add("tls") + add("host=" + (opts["host"]?.toString() ?: "")) + add("path=" + (opts["path"]?.toString() ?: "")) + if (opts["mux"]?.toString() == "true") add("mux=8") + } + } + } + } + proxies.add(ShadowsocksBean().apply { + serverAddress = proxy["server"] as String + serverPort = proxy["port"].toString().toInt() + password = proxy["password"]?.toString() + method = clashCipher(proxy["cipher"] as String) + plugin = ssPlugin.joinToString(";") + name = proxy["name"]?.toString() + }) + } + "vmess" -> { + val bean = VMessBean() + for (opt in proxy) { + when (opt.key) { + "name" -> bean.name = opt.value?.toString() + "server" -> bean.serverAddress = opt.value as String + "port" -> bean.serverPort = opt.value.toString().toInt() + "uuid" -> bean.uuid = opt.value as String + "alterId" -> bean.alterId = opt.value.toString().toInt() + "cipher" -> bean.encryption = opt.value as String + "network" -> { + bean.type = opt.value as String + // Clash "network" fix + when (bean.type) { + "h2" -> bean.type = "http" + } + } + "tls" -> bean.security = + if (opt.value?.toString() == "true") "tls" else "" + "skip-cert-verify" -> bean.allowInsecure = + opt.value?.toString() == "true" + "ws-path" -> bean.path = opt.value?.toString() + "ws-headers" -> for (wsHeader in (opt.value as Map)) { + when (wsHeader.key.lowercase()) { + "host" -> bean.host = wsHeader.value.toString() + } + } + "ws-opts", "ws-opt" -> for (wsOpt in (opt.value as Map)) { + when (wsOpt.key.lowercase()) { + "headers" -> for (wsHeader in (opt.value as Map)) { + when (wsHeader.key.lowercase()) { + "host" -> bean.host = wsHeader.value.toString() + } + } + "path" -> { + bean.path = wsOpt.value.toString() + } + "max-early-data" -> { + bean.wsMaxEarlyData = wsOpt.value.toString().toInt() + } + "early-data-header-name" -> { + bean.earlyDataHeaderName = wsOpt.value.toString() + } + } + } + "servername" -> bean.host = opt.value?.toString() + // The format of the VMessBean is wrong, so the `host` `path` has some strange transformations here. + "h2-opts", "h2-opt" -> for (h2Opt in (opt.value as Map)) { + when (h2Opt.key.lowercase()) { + "host" -> bean.host = + (h2Opt.value as List).first() + "path" -> bean.path = h2Opt.value.toString() + } + } + "http-opts", "http-opt" -> for (httpOpt in (opt.value as Map)) { + when (httpOpt.key.lowercase()) { + "path" -> bean.path = + (httpOpt.value as List).first() + "headers" -> for (hdr in (httpOpt.value as Map)) { + when (hdr.key.lowercase()) { + "host" -> bean.host = + (hdr.value as List).first() + } + } + } + } + "grpc-opts", "grpc-opt" -> for (grpcOpt in (opt.value as Map)) { + when (grpcOpt.key.lowercase()) { + "grpc-service-name" -> bean.path = + grpcOpt.value.toString() + } + } + } + } + if (bean.isTLS() && bean.sni.isNullOrBlank() && !bean.host.isNullOrBlank()) { + bean.sni = bean.host + } + proxies.add(bean) + } + "trojan" -> { + val bean = TrojanBean() + bean.security = "tls" + for (opt in proxy) { + when (opt.key) { + "name" -> bean.name = opt.value?.toString() + "server" -> bean.serverAddress = opt.value as String + "port" -> bean.serverPort = opt.value.toString().toInt() + "password" -> bean.password = opt.value?.toString() + "sni" -> bean.sni = opt.value?.toString() + "skip-cert-verify" -> bean.allowInsecure = + opt.value?.toString() == "true" + "network" -> when (opt.value) { + "ws", "grpc" -> bean.type = opt.value?.toString() + } + "ws-opts", "ws-opt" -> for (wsOpt in (opt.value as Map)) { + when (wsOpt.key.lowercase()) { + "headers" -> for (wsHeader in (opt.value as Map)) { + when (wsHeader.key.lowercase()) { + "host" -> bean.host = wsHeader.value.toString() + } + } + "path" -> { + bean.path = wsOpt.value.toString() + } + } + } + "grpc-opts", "grpc-opt" -> for (grpcOpt in (opt.value as Map)) { + when (grpcOpt.key.lowercase()) { + "grpc-service-name" -> bean.path = + grpcOpt.value.toString() + } + } + } + } + proxies.add(bean) + } + } + } + proxies.forEach { it.initializeDefaultValues() } + return proxies + } catch (e: YAMLException) { + Logs.w(e) + } + } else if (text.contains("[Interface]")) { + // wireguard + try { + proxies.addAll(parseWireGuard(text)) + return proxies + } catch (e: Exception) { + Logs.w(e) + } + } + + try { + val json = JSONTokener(text).nextValue() + return parseJSON(json) + } catch (ignored: Exception) { + } + + try { + return parseProxies(text.decodeBase64UrlSafe()).takeIf { it.isNotEmpty() } + ?: error("Not found") + } catch (e: Exception) { + Logs.w(e) + } + + try { + return parseProxies(text).takeIf { it.isNotEmpty() } ?: error("Not found") + } catch (e: SubscriptionFoundException) { + throw e + } catch (ignored: Exception) { + } + + return null + } + + fun clashCipher(cipher: String): String { + return when (cipher) { + "dummy" -> "none" + else -> cipher + } + } + + fun parseWireGuard(conf: String): List { + val ini = Ini(StringReader(conf)) + val iface = ini["Interface"] ?: error("Missing 'Interface' selection") + val bean = WireGuardBean().applyDefaultValues() + val localAddresses = iface.getAll("Address") + if (localAddresses.isNullOrEmpty()) error("Empty address in 'Interface' selection") + bean.localAddress = localAddresses.flatMap { it.split(",") }.let { address -> + address.joinToString("\n") { it.substringBefore("/") } + } + bean.privateKey = iface["PrivateKey"] + val peers = ini.getAll("Peer") + if (peers.isNullOrEmpty()) error("Missing 'Peer' selections") + val beans = mutableListOf() + for (peer in peers) { + val endpoint = peer["Endpoint"] + if (endpoint.isNullOrBlank() || !endpoint.contains(":")) { + continue + } + + val peerBean = bean.clone() + peerBean.serverAddress = endpoint.substringBeforeLast(":") + peerBean.serverPort = endpoint.substringAfterLast(":").toIntOrNull() ?: continue + peerBean.peerPublicKey = peer["PublicKey"] ?: continue + peerBean.peerPreSharedKey = peer["PresharedKey"] + beans.add(peerBean.applyDefaultValues()) + } + if (beans.isEmpty()) error("Empty available peer list") + return beans + } + + fun parseJSON(json: Any): List { + val proxies = ArrayList() + + if (json is JSONObject) { + when { + json.has("server") && (json.has("up") || json.has("up_mbps")) -> { + return listOf(json.parseHysteria()) + } + json.has("method") -> { + return listOf(json.parseShadowsocks()) + } + json.has("remote_addr") -> { + return listOf(json.parseTrojanGo()) + } + else -> json.forEach { _, it -> + if (isJsonObjectValid(it)) { + proxies.addAll(parseJSON(it)) + } + } + } + } else { + json as JSONArray + json.forEach { _, it -> + if (isJsonObjectValid(it)) { + proxies.addAll(parseJSON(it)) + } + } + } + + proxies.forEach { it.initializeDefaultValues() } + return proxies + } + +} 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 0000000..af3c96c --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Asyncs.kt @@ -0,0 +1,33 @@ +@file:Suppress("EXPERIMENTAL_API_USAGE") + +package io.nekohasekai.sagernet.ktx + +import kotlinx.coroutines.* + +fun block(block: suspend CoroutineScope.() -> Unit): suspend CoroutineScope.() -> Unit { + return block +} + +fun runOnDefaultDispatcher(block: suspend CoroutineScope.() -> Unit) = + GlobalScope.launch(Dispatchers.Default, block = block) + +suspend fun onDefaultDispatcher(block: suspend CoroutineScope.() -> T) = + withContext(Dispatchers.Default, block = block) + +fun runOnIoDispatcher(block: suspend CoroutineScope.() -> Unit) = + GlobalScope.launch(Dispatchers.IO, block = block) + +suspend fun onIoDispatcher(block: suspend CoroutineScope.() -> T) = + withContext(Dispatchers.IO, block = block) + +fun runOnMainDispatcher(block: suspend CoroutineScope.() -> Unit) = + GlobalScope.launch(Dispatchers.Main.immediate, block = block) + +suspend fun onMainDispatcher(block: suspend CoroutineScope.() -> T) = + withContext(Dispatchers.Main.immediate, block = block) + +fun runBlockingOnMainDispatcher(block: suspend CoroutineScope.() -> Unit) { + runBlocking { + GlobalScope.launch(Dispatchers.Main.immediate, block = block) + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/Browsers.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/Browsers.kt new file mode 100644 index 0000000..44eba1c --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Browsers.kt @@ -0,0 +1,29 @@ + package io.nekohasekai.sagernet.ktx + +import android.content.Context +import android.net.Uri +import androidx.browser.customtabs.CustomTabColorSchemeParams +import androidx.browser.customtabs.CustomTabsIntent +import io.nekohasekai.sagernet.R + +fun Context.launchCustomTab(link: String) { + CustomTabsIntent.Builder().apply { + setColorScheme(CustomTabsIntent.COLOR_SCHEME_SYSTEM) + setColorSchemeParams( + CustomTabsIntent.COLOR_SCHEME_LIGHT, + CustomTabColorSchemeParams.Builder().apply { + setToolbarColor(getColorAttr(R.attr.colorPrimary)) + }.build() + ) + setColorSchemeParams( + CustomTabsIntent.COLOR_SCHEME_DARK, + CustomTabColorSchemeParams.Builder().apply { + setToolbarColor(getColorAttr(R.attr.colorPrimary)) + }.build() + ) + }.build().apply { + if (intent.resolveActivity(packageManager) != null) { + launchUrl(this@launchCustomTab, Uri.parse(link)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/Dialogs.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/Dialogs.kt new file mode 100644 index 0000000..53a5f69 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Dialogs.kt @@ -0,0 +1,16 @@ +package io.nekohasekai.sagernet.ktx + +import android.content.Context +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.Fragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import io.nekohasekai.sagernet.R + +fun Context.alert(text: String): AlertDialog { + return MaterialAlertDialogBuilder(this).setTitle(R.string.error_title) + .setMessage(text) + .setPositiveButton(android.R.string.ok, null) + .create() +} + +fun Fragment.alert(text: String) = requireContext().alert(text) \ 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 0000000..1c7a034 --- /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/Formats.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/Formats.kt new file mode 100644 index 0000000..ba3f51a --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Formats.kt @@ -0,0 +1,232 @@ +package io.nekohasekai.sagernet.ktx + +import com.google.gson.JsonParser +import io.nekohasekai.sagernet.fmt.AbstractBean +import io.nekohasekai.sagernet.fmt.Serializable +import io.nekohasekai.sagernet.fmt.http.parseHttp +import io.nekohasekai.sagernet.fmt.hysteria.parseHysteria +import io.nekohasekai.sagernet.fmt.naive.parseNaive +import io.nekohasekai.sagernet.fmt.parseUniversal +import io.nekohasekai.sagernet.fmt.shadowsocks.parseShadowsocks +import io.nekohasekai.sagernet.fmt.socks.parseSOCKS +import io.nekohasekai.sagernet.fmt.trojan.parseTrojan +import io.nekohasekai.sagernet.fmt.trojan_go.parseTrojanGo +import io.nekohasekai.sagernet.fmt.v2ray.parseV2Ray +import moe.matsuri.nb4a.proxy.neko.NekoJSInterface +import moe.matsuri.nb4a.plugin.NekoPluginManager +import moe.matsuri.nb4a.proxy.neko.parseShareLink +import moe.matsuri.nb4a.utils.JavaUtil.gson +import moe.matsuri.nb4a.utils.Util +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +// JSON & Base64 + +fun JSONObject.toStringPretty(): String { + return gson.toJson(JsonParser.parseString(this.toString())) +} + +inline fun JSONArray.filterIsInstance(): List { + val list = mutableListOf() + for (i in 0 until this.length()) { + if (this[i] is T) list.add(this[i] as T) + } + return list +} + +inline fun JSONArray.forEach(action: (Int, Any) -> Unit) { + for (i in 0 until this.length()) { + action(i, this[i]) + } +} + +inline fun JSONObject.forEach(action: (String, Any) -> Unit) { + for (k in this.keys()) { + action(k, this.get(k)) + } +} + +fun isJsonObjectValid(j: Any): Boolean { + if (j is JSONObject) return true + if (j is JSONArray) return true + try { + JSONObject(j as String) + } catch (ex: JSONException) { + try { + JSONArray(j) + } catch (ex1: JSONException) { + return false + } + } + return true +} + +// wtf hutool +fun JSONObject.getStr(name: String): String? { + val obj = this.opt(name) ?: return null + if (obj is String) { + if (obj.isBlank()) { + return null + } + return obj + } else { + return null + } +} + +fun JSONObject.getBool(name: String): Boolean? { + return try { + getBoolean(name) + } catch (ignored: Exception) { + null + } +} + + +// 重名了喵 +fun JSONObject.getIntNya(name: String): Int? { + return try { + getInt(name) + } catch (ignored: Exception) { + null + } +} + + +fun String.decodeBase64UrlSafe(): String { + return String(Util.b64Decode(this)) +} + +// Sub + +class SubscriptionFoundException(val link: String) : RuntimeException() + +suspend fun parseProxies(text: String): List { + val links = text.split('\n').flatMap { it.trim().split(' ') } + val linksByLine = text.split('\n').map { it.trim() } + + val entities = ArrayList() + val entitiesByLine = ArrayList() + + suspend fun String.parseLink(entities: ArrayList) { + if (startsWith("clash://install-config?") || startsWith("sn://subscription?")) { + throw SubscriptionFoundException(this) + } + + if (startsWith("sn://")) { + Logs.d("Try parse universal link: $this") + runCatching { + entities.add(parseUniversal(this)) + }.onFailure { + Logs.w(it) + } + } else if (startsWith("socks://") || startsWith("socks4://") || startsWith("socks4a://") || startsWith( + "socks5://" + ) + ) { + Logs.d("Try parse socks link: $this") + runCatching { + entities.add(parseSOCKS(this)) + }.onFailure { + Logs.w(it) + } + } else if (matches("(http|https)://.*".toRegex())) { + Logs.d("Try parse http link: $this") + runCatching { + entities.add(parseHttp(this)) + }.onFailure { + Logs.w(it) + } + } else if (startsWith("vmess://")) { + Logs.d("Try parse v2ray link: $this") + runCatching { + entities.add(parseV2Ray(this)) + }.onFailure { + Logs.w(it) + } + } else if (startsWith("vless://")) { + Logs.d("Try parse vless link: $this") + runCatching { + entities.add(parseV2Ray(this)) + }.onFailure { + Logs.w(it) + } + } else if (startsWith("trojan://")) { + Logs.d("Try parse trojan link: $this") + runCatching { + entities.add(parseTrojan(this)) + }.onFailure { + Logs.w(it) + } + } else if (startsWith("trojan-go://")) { + Logs.d("Try parse trojan-go link: $this") + runCatching { + entities.add(parseTrojanGo(this)) + }.onFailure { + Logs.w(it) + } + } else if (startsWith("ss://")) { + Logs.d("Try parse shadowsocks link: $this") + runCatching { + entities.add(parseShadowsocks(this)) + }.onFailure { + Logs.w(it) + } + } else if (startsWith("naive+")) { + Logs.d("Try parse naive link: $this") + runCatching { + entities.add(parseNaive(this)) + }.onFailure { + Logs.w(it) + } + } else if (startsWith("hysteria://")) { + Logs.d("Try parse hysteria link: $this") + runCatching { + entities.add(parseHysteria(this)) + }.onFailure { + Logs.w(it) + } + } else { // Neko Plugins + NekoPluginManager.getProtocols().forEach { obj -> + obj.protocolConfig.optJSONArray("links")?.forEach { _, any -> + if (any is String && startsWith(any)) { + runCatching { + entities.add( + parseShareLink( + obj.plgId, obj.protocolId, this@parseLink + ) + ) + }.onFailure { + Logs.w(it) + } + } + } + } + } + } + + for (link in links) { + link.parseLink(entities) + } + for (link in linksByLine) { + link.parseLink(entitiesByLine) + } + var isBadLink = false + if (entities.onEach { it.initializeDefaultValues() }.size == entitiesByLine.onEach { it.initializeDefaultValues() }.size) run test@{ + entities.forEachIndexed { index, bean -> + val lineBean = entitiesByLine[index] + if (bean == lineBean && bean.displayName() != lineBean.displayName()) { + isBadLink = true + return@test + } + } + } + NekoJSInterface.Default.destroyAllJsi() + return if (entities.size > entitiesByLine.size) entities else entitiesByLine +} + +fun T.applyDefaultValues(): T { + initializeDefaultValues() + return this +} \ No newline at end of file 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 0000000..6440fe2 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Kryos.kt @@ -0,0 +1,61 @@ +package io.nekohasekai.sagernet.ktx + +import android.os.Parcel +import android.os.Parcelable +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) + +fun ByteBufferInput.readStringList(): List { + return mutableListOf().apply { + repeat(readInt()) { + add(readString()) + } + } +} + +fun ByteBufferInput.readStringSet(): Set { + return linkedSetOf().apply { + repeat(readInt()) { + add(readString()) + } + } +} + + +fun ByteBufferOutput.writeStringList(list: List) { + writeInt(list.size) + for (str in list) writeString(str) +} + +fun ByteBufferOutput.writeStringList(list: Set) { + writeInt(list.size) + for (str in list) writeString(str) +} + +fun Parcelable.marshall(): ByteArray { + val parcel = Parcel.obtain() + writeToParcel(parcel, 0) + val bytes = parcel.marshall() + parcel.recycle() + return bytes +} + +fun ByteArray.unmarshall(): Parcel { + val parcel = Parcel.obtain() + parcel.unmarshall(this, 0, size) + parcel.setDataPosition(0) // This is extremely important! + return parcel +} + +fun ByteArray.unmarshall(constructor: (Parcel) -> T): T { + val parcel = unmarshall() + val result = constructor(parcel) + parcel.recycle() + return result +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/Layouts.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/Layouts.kt new file mode 100644 index 0000000..839bbd8 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Layouts.kt @@ -0,0 +1,75 @@ +package io.nekohasekai.sagernet.ktx + +import android.graphics.Rect +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.ui.MainActivity + +class FixedLinearLayoutManager(val recyclerView: RecyclerView) : + LinearLayoutManager(recyclerView.context, RecyclerView.VERTICAL, false) { + + override fun onLayoutChildren(recycler: RecyclerView.Recycler?, state: RecyclerView.State?) { + try { + super.onLayoutChildren(recycler, state) + } catch (ignored: IndexOutOfBoundsException) { + } + } + + private var listenerDisabled = false + + override fun scrollVerticallyBy( + dx: Int, recycler: RecyclerView.Recycler, + state: RecyclerView.State + ): Int { + // Matsuri style + if (!DataStore.showBottomBar) return super.scrollVerticallyBy(dx, recycler, state) + + // SagerNet Style + val scrollRange = super.scrollVerticallyBy(dx, recycler, state) + if (listenerDisabled) return scrollRange + val activity = recyclerView.context as? MainActivity + if (activity == null) { + listenerDisabled = true + return scrollRange + } + + val overscroll = dx - scrollRange + if (overscroll > 0) { + val view = + (recyclerView.findViewHolderForAdapterPosition(findLastVisibleItemPosition()) + ?: return scrollRange).itemView + val itemLocation = Rect().also { view.getGlobalVisibleRect(it) } + val fabLocation = Rect().also { activity.binding.fab.getGlobalVisibleRect(it) } + if (!itemLocation.contains(fabLocation.left, fabLocation.top) && !itemLocation.contains( + fabLocation.right, + fabLocation.bottom + ) + ) { + return scrollRange + } + activity.binding.fab.apply { + if (isShown) hide() + } + } else { + /*val screen = Rect().also { activity.window.decorView.getGlobalVisibleRect(it) } + val location = Rect().also { activity.stats.getGlobalVisibleRect(it) } + if (screen.bottom < location.bottom) { + return scrollRange + } + val height = location.bottom - location.top + val mH = activity.stats.measuredHeight + + if (mH > height) { + return scrollRange + }*/ + + activity.binding.fab.apply { + if (!isShown) show() + } + } + return scrollRange + } + +} 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 0000000..454ca3a --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Logs.kt @@ -0,0 +1,64 @@ +package io.nekohasekai.sagernet.ktx + +import libcore.Libcore +import java.io.InputStream +import java.io.OutputStream + +object Logs { + + private fun mkTag(): String { + val stackTrace = Thread.currentThread().stackTrace + return stackTrace[4].className.substringAfterLast(".") + } + + // level int use logrus.go + + fun d(message: String) { + Libcore.nekoLogPrintln("[Debug] [${mkTag()}] $message") + } + + fun d(message: String, exception: Throwable) { + Libcore.nekoLogPrintln("[Debug] [${mkTag()}] $message" + "\n" + exception.stackTraceToString()) + } + + fun i(message: String) { + Libcore.nekoLogPrintln("[Info] [${mkTag()}] $message") + } + + fun i(message: String, exception: Throwable) { + Libcore.nekoLogPrintln("[Info] [${mkTag()}] $message" + "\n" + exception.stackTraceToString()) + } + + fun w(message: String) { + Libcore.nekoLogPrintln("[Warning] [${mkTag()}] $message") + } + + fun w(message: String, exception: Throwable) { + Libcore.nekoLogPrintln("[Warning] [${mkTag()}] $message" + "\n" + exception.stackTraceToString()) + } + + fun w(exception: Throwable) { + Libcore.nekoLogPrintln("[Warning] [${mkTag()}] " + exception.stackTraceToString()) + } + + fun e(message: String) { + Libcore.nekoLogPrintln("[Error] [${mkTag()}] $message") + } + + fun e(message: String, exception: Throwable) { + Libcore.nekoLogPrintln("[Error] [${mkTag()}] $message" + "\n" + exception.stackTraceToString()) + } + + fun e(exception: Throwable) { + Libcore.nekoLogPrintln("[Error] [${mkTag()}] " + exception.stackTraceToString()) + } + +} + +fun InputStream.use(out: OutputStream) { + use { input -> + out.use { output -> + input.copyTo(output) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/Nets.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/Nets.kt new file mode 100644 index 0000000..52f098c --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Nets.kt @@ -0,0 +1,65 @@ +@file:Suppress("SpellCheckingInspection") + +package io.nekohasekai.sagernet.ktx + +import io.nekohasekai.sagernet.fmt.AbstractBean +import moe.matsuri.nb4a.utils.NGUtil +import okhttp3.HttpUrl +import java.net.InetSocketAddress +import java.net.Socket + +fun linkBuilder() = HttpUrl.Builder().scheme("https") + +fun HttpUrl.Builder.toLink(scheme: String, appendDefaultPort: Boolean = true): String { + var url = build() + val defaultPort = HttpUrl.defaultPort(url.scheme) + var replace = false + if (appendDefaultPort && url.port == defaultPort) { + url = url.newBuilder().port(14514).build() + replace = true + } + return url.toString().replace("${url.scheme}://", "$scheme://").let { + if (replace) it.replace(":14514", ":$defaultPort") else it + } +} + +fun String.isIpAddress(): Boolean { + return NGUtil.isIpv4Address(this) || NGUtil.isIpv6Address(this) +} + +fun String.isIpAddressV6(): Boolean { + return NGUtil.isIpv6Address(this) +} + +// [2001:4860:4860::8888] -> 2001:4860:4860::8888 +fun String.unwrapIPV6Host(): String { + if (startsWith("[") && endsWith("]")) { + return substring(1, length - 1).unwrapIPV6Host() + } + return this +} + +// [2001:4860:4860::8888] or 2001:4860:4860::8888 -> [2001:4860:4860::8888] +fun String.wrapIPV6Host(): String { + val unwrapped = this.unwrapIPV6Host() + if (unwrapped.isIpAddressV6()) { + return "[$unwrapped]" + } else { + return this + } +} + +fun AbstractBean.wrapUri(): String { + return "${finalAddress.wrapIPV6Host()}:$finalPort" +} + +fun mkPort(): Int { + val socket = Socket() + socket.reuseAddress = true + socket.bind(InetSocketAddress(0)) + val port = socket.localPort + socket.close() + return port +} + +const val USER_AGENT = "NekoBox/0.5 (Prefer Clash Format)" 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 0000000..70c395c --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Preferences.kt @@ -0,0 +1,62 @@ +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.stringSet( + name: String, + defaultValue: () -> Set = { setOf() }, +) = PreferenceProxy(name, defaultValue, ::getStringSet, ::putStringSet) + +fun PreferenceDataStore.stringToInt( + name: String, + defaultValue: () -> Int = { 0 }, +) = PreferenceProxy(name, defaultValue, { key, default -> + getString(key, "$default")?.toIntOrNull() ?: default +}, { key, value -> putString(key, "$value") }) + +fun PreferenceDataStore.stringToIntIfExists( + name: String, + defaultValue: () -> Int = { 0 }, +) = PreferenceProxy(name, defaultValue, { key, default -> + getString(key, "$default")?.toIntOrNull() ?: default +}, { key, value -> putString(key, value.takeIf { it > 0 }?.toString() ?: "") }) + +fun PreferenceDataStore.long( + name: String, + defaultValue: () -> Long = { 0L }, +) = PreferenceProxy(name, defaultValue, ::getLong, ::putLong) + +fun PreferenceDataStore.stringToLong( + name: String, + defaultValue: () -> Long = { 0L }, +) = PreferenceProxy(name, defaultValue, { key, default -> + getString(key, "$default")?.toLongOrNull() ?: default +}, { key, value -> putString(key, "$value") }) + +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 0000000..23e06e9 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Utils.kt @@ -0,0 +1,315 @@ +@file:SuppressLint("SoonBlockedPrivateApi") + +package io.nekohasekai.sagernet.ktx + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.annotation.SuppressLint +import android.content.* +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 android.view.View +import androidx.activity.result.ActivityResultLauncher +import androidx.annotation.AttrRes +import androidx.annotation.ColorRes +import androidx.core.content.ContextCompat +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.preference.Preference +import androidx.recyclerview.widget.LinearSmoothScroller +import androidx.recyclerview.widget.RecyclerView +import com.jakewharton.processphoenix.ProcessPhoenix +import io.nekohasekai.sagernet.BuildConfig +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.SagerNet +import io.nekohasekai.sagernet.bg.Executable +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.ui.MainActivity +import io.nekohasekai.sagernet.ui.ThemedActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import moe.matsuri.nb4a.utils.NGUtil +import java.io.FileDescriptor +import java.net.* +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.reflect.KMutableProperty0 +import kotlin.reflect.KProperty +import kotlin.reflect.KProperty0 + + +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.takeIf { !it.isNullOrBlank() } ?: javaClass.simpleName + +/** + * https://android.googlesource.com/platform/prebuilts/runtime/+/94fec32/appcompat/hiddenapi-light-greylist.txt#9466 + */ + +private val socketGetFileDescriptor = Socket::class.java.getDeclaredMethod("getFileDescriptor\$") +val Socket.fileDescriptor get() = socketGetFileDescriptor.invoke(this) as FileDescriptor + +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 { + 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 + } + +@JvmOverloads +fun DialogFragment.showAllowingStateLoss(fragmentManager: FragmentManager, tag: String? = null) { + if (!fragmentManager.isStateSaved) show(fragmentManager, tag) +} + +fun String.pathSafe(): String { + // " " encoded as + + return URLEncoder.encode(this, "UTF-8") +} + +fun String.urlSafe(): String { + return URLEncoder.encode(this, "UTF-8").replace("+", "%20") +} + +fun String.unUrlSafe(): String { + return NGUtil.urlDecode(this) +} + +fun RecyclerView.scrollTo(index: Int, force: Boolean = false) { + if (force) post { + scrollToPosition(index) + } + postDelayed({ + try { + layoutManager?.startSmoothScroll(object : LinearSmoothScroller(context) { + init { + targetPosition = index + } + + override fun getVerticalSnapPreference(): Int { + return SNAP_TO_START + } + }) + } catch (ignored: IllegalArgumentException) { + } + }, 300L) +} + +val app get() = SagerNet.application + +val shortAnimTime by lazy { + app.resources.getInteger(android.R.integer.config_shortAnimTime).toLong() +} + +fun View.crossFadeFrom(other: View) { + clearAnimation() + other.clearAnimation() + if (visibility == View.VISIBLE && other.visibility == View.GONE) return + alpha = 0F + visibility = View.VISIBLE + animate().alpha(1F).duration = shortAnimTime + other.animate().alpha(0F).setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + other.visibility = View.GONE + } + }).duration = shortAnimTime +} + + +fun Fragment.snackbar(textId: Int) = (requireActivity() as MainActivity).snackbar(textId) +fun Fragment.snackbar(text: CharSequence) = (requireActivity() as MainActivity).snackbar(text) + +fun ThemedActivity.startFilesForResult( + launcher: ActivityResultLauncher, input: String +) { + try { + return launcher.launch(input) + } catch (_: ActivityNotFoundException) { + } catch (_: SecurityException) { + } + snackbar(getString(R.string.file_manager_missing)).show() +} + +fun Fragment.startFilesForResult( + launcher: ActivityResultLauncher, input: String +) { + try { + return launcher.launch(input) + } catch (_: ActivityNotFoundException) { + } catch (_: SecurityException) { + } + (requireActivity() as ThemedActivity).snackbar(getString(R.string.file_manager_missing)).show() +} + +fun Fragment.needReload() { + if (DataStore.serviceState.started) { + snackbar(getString(R.string.restart)).setAction(R.string.apply) { + SagerNet.reloadService() + }.show() + } +} + +fun Fragment.needRestart() { + snackbar("Restart APP to apply changes.").setAction(R.string.apply) { + Executable.killAll(true) + ProcessPhoenix.triggerRebirth( + requireContext(), Intent(requireContext(), MainActivity::class.java) + ) + }.show() +} + +fun Context.getColour(@ColorRes colorRes: Int): Int { + return ContextCompat.getColor(this, colorRes) +} + +fun Context.getColorAttr(@AttrRes resId: Int): Int { + return ContextCompat.getColor(this, TypedValue().also { + theme.resolveAttribute(resId, it, true) + }.resourceId) +} + +var isExpert: Boolean + get() = BuildConfig.DEBUG || DataStore.isExpert + set(value) = TODO() + +val isExpertFlavor = ((BuildConfig.FLAVOR == "expert") || BuildConfig.DEBUG) +const val isOss = BuildConfig.FLAVOR == "oss" +const val isFdroid = BuildConfig.FLAVOR == "fdroid" + +fun Continuation.tryResume(value: T) { + try { + resumeWith(Result.success(value)) + } catch (ignored: IllegalStateException) { + } +} + +fun Continuation.tryResumeWithException(exception: Throwable) { + try { + resumeWith(Result.failure(exception)) + } catch (ignored: IllegalStateException) { + } +} + +operator fun KProperty0.getValue(thisRef: Any?, property: KProperty<*>): F = get() +operator fun KMutableProperty0.setValue( + thisRef: Any?, property: KProperty<*>, value: F +) = set(value) + +operator fun AtomicBoolean.getValue(thisRef: Any?, property: KProperty<*>): Boolean = get() +operator fun AtomicBoolean.setValue(thisRef: Any?, property: KProperty<*>, value: Boolean) = + set(value) + +operator fun AtomicInteger.getValue(thisRef: Any?, property: KProperty<*>): Int = get() +operator fun AtomicInteger.setValue(thisRef: Any?, property: KProperty<*>, value: Int) = set(value) + +operator fun AtomicLong.getValue(thisRef: Any?, property: KProperty<*>): Long = get() +operator fun AtomicLong.setValue(thisRef: Any?, property: KProperty<*>, value: Long) = set(value) + +operator fun AtomicReference.getValue(thisRef: Any?, property: KProperty<*>): T = get() +operator fun AtomicReference.setValue(thisRef: Any?, property: KProperty<*>, value: T) = + set(value) + +operator fun Map.getValue(thisRef: K, property: KProperty<*>) = get(thisRef) +operator fun MutableMap.setValue(thisRef: K, property: KProperty<*>, value: V?) { + + if (value != null) { + + put(thisRef, value) + + } else { + + remove(thisRef) + + } + +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/plugin/PluginManager.kt b/app/src/main/java/io/nekohasekai/sagernet/plugin/PluginManager.kt new file mode 100644 index 0000000..76fd704 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/plugin/PluginManager.kt @@ -0,0 +1,70 @@ +package io.nekohasekai.sagernet.plugin + +import android.content.pm.ComponentInfo +import android.content.pm.ProviderInfo +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.SagerNet +import io.nekohasekai.sagernet.bg.BaseService +import io.nekohasekai.sagernet.ktx.Logs +import moe.matsuri.nb4a.plugin.Plugins +import java.io.File +import java.io.FileNotFoundException + +object PluginManager { + + class PluginNotFoundException(val plugin: String) : FileNotFoundException(plugin), + BaseService.ExpectedException { + override fun getLocalizedMessage() = + SagerNet.application.getString(R.string.plugin_unknown, plugin) + } + + data class InitResult( + val path: String, + val info: ProviderInfo, + ) + + @Throws(Throwable::class) + fun init(pluginId: String): InitResult? { + if (pluginId.isEmpty()) return null + var throwable: Throwable? = null + + try { + val result = initNative(pluginId) + if (result != null) return result + } catch (t: Throwable) { + if (throwable == null) throwable = t else Logs.w(t) + } + + throw throwable ?: PluginNotFoundException(pluginId) + } + + private fun initNative(pluginId: String): InitResult? { + val info = Plugins.getPlugin(pluginId) ?: return null + + try { + initNativeFaster(info)?.also { return InitResult(it, info) } + } catch (t: Throwable) { + Logs.w("Initializing native plugin faster mode failed", t) + } + + Logs.w("Init native returns empty result") + return null + } + + private fun initNativeFaster(provider: ProviderInfo): String? { + return provider.loadString(Plugins.METADATA_KEY_EXECUTABLE_PATH) + ?.let { relativePath -> + File(provider.applicationInfo.nativeLibraryDir).resolve(relativePath).apply { + check(canExecute()) + }.absolutePath + } + } + + fun ComponentInfo.loadString(key: String) = when (val value = metaData.get(key)) { + is String -> value + is Int -> SagerNet.application.packageManager.getResourcesForApplication(applicationInfo) + .getString(value) + null -> null + else -> error("meta-data $key has invalid type ${value.javaClass}") + } +} 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 0000000..5b9a9f8 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/AboutFragment.kt @@ -0,0 +1,183 @@ +package io.nekohasekai.sagernet.ui + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.PowerManager +import android.provider.Settings +import android.text.util.Linkify +import android.view.View +import androidx.activity.result.component1 +import androidx.activity.result.component2 +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.ViewCompat +import androidx.recyclerview.widget.RecyclerView +import com.danielstone.materialaboutlibrary.MaterialAboutFragment +import com.danielstone.materialaboutlibrary.items.MaterialAboutActionItem +import com.danielstone.materialaboutlibrary.model.MaterialAboutCard +import com.danielstone.materialaboutlibrary.model.MaterialAboutList +import io.nekohasekai.sagernet.BuildConfig +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.databinding.LayoutAboutBinding +import io.nekohasekai.sagernet.ktx.* +import io.nekohasekai.sagernet.plugin.PluginManager.loadString +import io.nekohasekai.sagernet.utils.PackageCache +import io.nekohasekai.sagernet.widget.ListHolderListener +import libcore.Libcore +import moe.matsuri.nb4a.plugin.Plugins + +class AboutFragment : ToolbarFragment(R.layout.layout_about) { + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val binding = LayoutAboutBinding.bind(view) + + ViewCompat.setOnApplyWindowInsetsListener(view, ListHolderListener) + toolbar.setTitle(R.string.menu_about) + + parentFragmentManager.beginTransaction() + .replace(R.id.about_fragment_holder, AboutContent()) + .commitAllowingStateLoss() + + runOnDefaultDispatcher { + val license = view.context.assets.open("LICENSE").bufferedReader().readText() + onMainDispatcher { + binding.license.text = license + Linkify.addLinks(binding.license, Linkify.EMAIL_ADDRESSES or Linkify.WEB_URLS) + } + } + } + + class AboutContent : MaterialAboutFragment() { + + val requestIgnoreBatteryOptimizations = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { (resultCode, _) -> + if (resultCode == Activity.RESULT_OK) { + parentFragmentManager.beginTransaction() + .replace(R.id.about_fragment_holder, AboutContent()) + .commitAllowingStateLoss() + } + } + + override fun getMaterialAboutList(activityContext: Context): MaterialAboutList { + + var versionName = BuildConfig.VERSION_NAME + if (!isOss) { + versionName += " ${BuildConfig.FLAVOR}" + } + if (BuildConfig.DEBUG) { + versionName += " DEBUG" + } + + return MaterialAboutList.Builder() + .addCard(MaterialAboutCard.Builder() + .outline(false) + .addItem(MaterialAboutActionItem.Builder() + .icon(R.drawable.ic_baseline_update_24) + .text(R.string.app_version) + .subText(versionName) + .setOnClickAction { + requireContext().launchCustomTab( + "https://github.com/MatsuriDayo/NekoBoxForAndroid/releases" + ) + } + .build()) + .addItem(MaterialAboutActionItem.Builder() + .icon(R.drawable.ic_baseline_airplanemode_active_24) + .text(getString(R.string.version_x, "sing-box")) + .subText(Libcore.versionBox()) + .setOnClickAction { } + .build()) + .apply { + for ((_, pkg) in PackageCache.installedPluginPackages) { + try { + val pluginId = pkg.providers[0].loadString(Plugins.METADATA_KEY_ID) + if (pluginId.isNullOrBlank() || pluginId.startsWith(Plugins.AUTHORITIES_PREFIX_NEKO_PLUGIN)) continue + addItem(MaterialAboutActionItem.Builder() + .icon(R.drawable.ic_baseline_nfc_24) + .text( + getString( + R.string.version_x, + pluginId + ) + " (${Plugins.displayExeProvider(pkg.packageName)})" + ) + .subText("v" + pkg.versionName) + .setOnClickAction { + startActivity(Intent().apply { + action = + Settings.ACTION_APPLICATION_DETAILS_SETTINGS + data = Uri.fromParts( + "package", pkg.packageName, null + ) + }) + } + .build()) + } catch (e: Exception) { + Logs.w(e) + } + } + } + .apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val pm = app.getSystemService(Context.POWER_SERVICE) as PowerManager + if (!pm.isIgnoringBatteryOptimizations(app.packageName)) { + addItem(MaterialAboutActionItem.Builder() + .icon(R.drawable.ic_baseline_running_with_errors_24) + .text(R.string.ignore_battery_optimizations) + .subText(R.string.ignore_battery_optimizations_sum) + .setOnClickAction { + requestIgnoreBatteryOptimizations.launch( + Intent( + Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + Uri.parse("package:${app.packageName}") + ) + ) + } + .build()) + } + } + } + .build()) + .addCard(MaterialAboutCard.Builder() + .outline(false) + .title(R.string.project) + .addItem(MaterialAboutActionItem.Builder() + .icon(R.drawable.ic_baseline_sanitizer_24) + .text(R.string.github) + .setOnClickAction { + requireContext().launchCustomTab( + "https://github.com/MatsuriDayo/NekoBoxForAndroid" + + ) + } + .build()) + .addItem(MaterialAboutActionItem.Builder() + .icon(R.drawable.ic_qu_shadowsocks_foreground) + .text(R.string.telegram) + .setOnClickAction { + requireContext().launchCustomTab( + "https://t.me/MatsuriDayo" + ) + } + .build()) + .build()) + .build() + + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + view.findViewById(R.id.mal_recyclerview).apply { + overScrollMode = RecyclerView.OVER_SCROLL_NEVER + } + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/AppListActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/AppListActivity.kt new file mode 100644 index 0000000..34768ec --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/AppListActivity.kt @@ -0,0 +1,381 @@ +package io.nekohasekai.sagernet.ui + +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.util.SparseBooleanArray +import android.view.* +import android.widget.Filter +import android.widget.Filterable +import androidx.annotation.UiThread +import androidx.core.util.contains +import androidx.core.util.set +import androidx.core.view.ViewCompat +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.core.widget.addTextChangedListener +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.snackbar.Snackbar +import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView +import io.nekohasekai.sagernet.BuildConfig +import io.nekohasekai.sagernet.Key +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.SagerNet +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.databinding.LayoutAppListBinding +import io.nekohasekai.sagernet.databinding.LayoutAppsItemBinding +import io.nekohasekai.sagernet.ktx.* +import io.nekohasekai.sagernet.utils.PackageCache +import io.nekohasekai.sagernet.widget.ListHolderListener +import io.nekohasekai.sagernet.widget.ListListener +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import moe.matsuri.nb4a.proxy.neko.NekoJSInterface +import moe.matsuri.nb4a.plugin.NekoPluginManager +import moe.matsuri.nb4a.plugin.Plugins +import moe.matsuri.nb4a.ui.Dialogs +import kotlin.coroutines.coroutineContext + +class AppListActivity : ThemedActivity() { + companion object { + private const val SWITCH = "switch" + } + + private class ProxiedApp( + private val pm: PackageManager, private val appInfo: ApplicationInfo, + val packageName: String, + ) { + val name: CharSequence = appInfo.loadLabel(pm) // cached for sorting + val icon: Drawable get() = appInfo.loadIcon(pm) + val uid get() = appInfo.uid + val sys get() = (appInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0 + } + + private inner class AppViewHolder(val binding: LayoutAppsItemBinding) : RecyclerView.ViewHolder( + binding.root + ), + View.OnClickListener { + private lateinit var item: ProxiedApp + + init { + binding.root.setOnClickListener(this) + } + + fun bind(app: ProxiedApp) { + item = app + binding.itemicon.setImageDrawable(app.icon) + binding.title.text = app.name + if (forNeko) { + val packageName = app.packageName + val ver = getCachedApps()[packageName]?.versionName ?: "" + binding.desc.text = "$packageName ($ver)" + // + binding.button.isVisible = true + binding.button.setImageDrawable(getDrawable(R.drawable.ic_baseline_info_24)) + binding.button.setOnClickListener { + runOnIoDispatcher { + val jsi = NekoJSInterface(packageName) + jsi.init() + val about = jsi.getAbout() + jsi.destorySuspend() + Dialogs.message( + this@AppListActivity, app.name as String, + "PackageName: ${packageName}\n" + + "Version: ${ver}\n" + + "--------\n" + about + ) + } + } + } else { + binding.desc.text = "${app.packageName} (${app.uid})" + } + handlePayload(listOf(SWITCH)) + } + + fun handlePayload(payloads: List) { + if (payloads.contains(SWITCH)) { + val selected = isProxiedApp(item) + binding.itemcheck.isChecked = selected + binding.button.isVisible = forNeko && selected + } + } + + override fun onClick(v: View?) { + if (isProxiedApp(item)) proxiedUids.delete(item.uid) else proxiedUids[item.uid] = true + DataStore.routePackages = apps.filter { isProxiedApp(it) } + .joinToString("\n") { it.packageName } + + if (forNeko) { + if (isProxiedApp(item)) { + runOnIoDispatcher { + try { + NekoPluginManager.installPlugin(item.packageName) + } catch (e: Exception) { + // failed UI + runOnUiThread { onClick(v) } + Dialogs.logExceptionAndShow(this@AppListActivity, e) { } + } + } + } else { + NekoPluginManager.removeManagedPlugin(item.packageName) + } + } + + appsAdapter.notifyItemRangeChanged(0, appsAdapter.itemCount, SWITCH) + } + } + + private inner class AppsAdapter : RecyclerView.Adapter(), + Filterable, + FastScrollRecyclerView.SectionedAdapter { + var filteredApps = apps + + suspend fun reload() { + apps = getCachedApps().map { (packageName, packageInfo) -> + coroutineContext[Job]!!.ensureActive() + ProxiedApp(packageManager, packageInfo.applicationInfo, packageName) + }.sortedWith(compareBy({ !isProxiedApp(it) }, { it.name.toString() })) + } + + override fun onBindViewHolder(holder: AppViewHolder, position: Int) = + holder.bind(filteredApps[position]) + + override fun onBindViewHolder(holder: AppViewHolder, position: Int, payloads: List) { + if (payloads.isNotEmpty()) { + @Suppress("UNCHECKED_CAST") holder.handlePayload(payloads as List) + return + } + + onBindViewHolder(holder, position) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppViewHolder = + AppViewHolder(LayoutAppsItemBinding.inflate(layoutInflater, parent, false)) + + override fun getItemCount(): Int = filteredApps.size + + private val filterImpl = object : Filter() { + override fun performFiltering(constraint: CharSequence) = FilterResults().apply { + var filteredApps = if (constraint.isEmpty()) apps else apps.filter { + it.name.contains(constraint, true) || it.packageName.contains( + constraint, true + ) || it.uid.toString().contains(constraint) + } + if (!sysApps) filteredApps = filteredApps.filter { !it.sys } + count = filteredApps.size + values = filteredApps + } + + override fun publishResults(constraint: CharSequence, results: FilterResults) { + @Suppress("UNCHECKED_CAST") + filteredApps = results.values as List + notifyDataSetChanged() + } + } + + override fun getFilter(): Filter = filterImpl + + override fun getSectionName(position: Int): String { + return filteredApps[position].name.firstOrNull()?.toString() ?: "" + } + + } + + private val loading by lazy { findViewById(R.id.loading) } + + private lateinit var binding: LayoutAppListBinding + private val proxiedUids = SparseBooleanArray() + private var loader: Job? = null + private var apps = emptyList() + private val appsAdapter = AppsAdapter() + + private fun initProxiedUids(str: String = DataStore.routePackages) { + proxiedUids.clear() + val apps = getCachedApps() + for (line in str.lineSequence()) proxiedUids[(apps[line] + ?: continue).applicationInfo.uid] = true + } + + private fun isProxiedApp(app: ProxiedApp) = proxiedUids[app.uid] + + @UiThread + private fun loadApps() { + loader?.cancel() + loader = lifecycleScope.launchWhenCreated { + loading.crossFadeFrom(binding.list) + val adapter = binding.list.adapter as AppsAdapter + withContext(Dispatchers.IO) { adapter.reload() } + adapter.filter.filter(binding.search.text?.toString() ?: "") + binding.list.crossFadeFrom(loading) + } + } + + private var forNeko = false + + fun getCachedApps(): MutableMap { + val packages = + if (forNeko) PackageCache.installedPluginPackages else PackageCache.installedPackages + return packages.toMutableMap().apply { + remove(BuildConfig.APPLICATION_ID) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + forNeko = intent?.hasExtra(Key.NEKO_PLUGIN_MANAGED) == true + + binding = LayoutAppListBinding.inflate(layoutInflater) + setContentView(binding.root) + + ListHolderListener.setup(this) + setSupportActionBar(binding.toolbar) + supportActionBar?.apply { + setTitle(R.string.select_apps) + setDisplayHomeAsUpEnabled(true) + setHomeAsUpIndicator(R.drawable.ic_navigation_close) + } + + initProxiedUids() + binding.list.layoutManager = LinearLayoutManager(this, RecyclerView.VERTICAL, false) + binding.list.itemAnimator = DefaultItemAnimator() + binding.list.adapter = appsAdapter + + ViewCompat.setOnApplyWindowInsetsListener(binding.root, ListListener) + + binding.search.addTextChangedListener { + appsAdapter.filter.filter(it?.toString() ?: "") + } + + binding.showSystemApps.isChecked = sysApps + binding.showSystemApps.setOnCheckedChangeListener { _, isChecked -> + sysApps = isChecked + appsAdapter.filter.filter(binding.search.text?.toString() ?: "") + } + + if (forNeko) { + DataStore.routePackages = DataStore.nekoPlugins + binding.search.setText(Plugins.AUTHORITIES_PREFIX_NEKO_PLUGIN) + } + + binding.searchLayout.isGone = forNeko + binding.hintNekoPlugin.isGone = !forNeko + binding.actionLearnMore.setOnClickListener { + launchCustomTab("https://matsuridayo.github.io/m-plugin/") + } + + loadApps() + } + + private var sysApps = false + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + if (forNeko) { + menuInflater.inflate(R.menu.app_list_neko_menu, menu) + } else { + menuInflater.inflate(R.menu.app_list_menu, menu) + } + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_invert_selections -> { + runOnDefaultDispatcher { + for (app in apps) { + if (proxiedUids.contains(app.uid)) { + proxiedUids.delete(app.uid) + } else { + proxiedUids[app.uid] = true + } + } + DataStore.routePackages = apps.filter { isProxiedApp(it) } + .joinToString("\n") { it.packageName } + apps = apps.sortedWith(compareBy({ !isProxiedApp(it) }, { it.name.toString() })) + onMainDispatcher { + appsAdapter.filter.filter(binding.search.text?.toString() ?: "") + } + } + + return true + } + R.id.action_clear_selections -> { + runOnDefaultDispatcher { + proxiedUids.clear() + DataStore.routePackages = "" + apps = apps.sortedWith(compareBy({ !isProxiedApp(it) }, { it.name.toString() })) + onMainDispatcher { + appsAdapter.filter.filter(binding.search.text?.toString() ?: "") + } + } + } + R.id.action_export_clipboard -> { + val success = SagerNet.trySetPrimaryClip("false\n${DataStore.routePackages}") + Snackbar.make( + binding.list, + if (success) R.string.action_export_msg else R.string.action_export_err, + Snackbar.LENGTH_LONG + ).show() + return true + } + R.id.action_import_clipboard -> { + val proxiedAppString = + SagerNet.clipboard.primaryClip?.getItemAt(0)?.text?.toString() + if (!proxiedAppString.isNullOrEmpty()) { + val i = proxiedAppString.indexOf('\n') + try { + val apps = if (i < 0) "" else proxiedAppString.substring(i + 1) + DataStore.routePackages = apps + Snackbar.make( + binding.list, R.string.action_import_msg, Snackbar.LENGTH_LONG + ).show() + initProxiedUids(apps) + appsAdapter.notifyItemRangeChanged(0, appsAdapter.itemCount, SWITCH) + return true + } catch (_: IllegalArgumentException) { + } + } + Snackbar.make(binding.list, R.string.action_import_err, Snackbar.LENGTH_LONG).show() + } + R.id.uninstall_all -> { + runOnDefaultDispatcher { + proxiedUids.clear() + DataStore.routePackages = "" + apps = apps.sortedWith(compareBy({ !isProxiedApp(it) }, { it.name.toString() })) + NekoPluginManager.plugins.forEach { + NekoPluginManager.removeManagedPlugin(it) + } + onMainDispatcher { + appsAdapter.notifyItemRangeChanged(0, appsAdapter.itemCount, SWITCH) + } + } + } + } + return super.onOptionsItemSelected(item) + } + + override fun onSupportNavigateUp(): Boolean { + if (!super.onSupportNavigateUp()) finish() + return true + } + + override fun supportNavigateUpTo(upIntent: Intent) = + super.supportNavigateUpTo(upIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)) + + override fun onKeyUp(keyCode: Int, event: KeyEvent?) = if (keyCode == KeyEvent.KEYCODE_MENU) { + if (binding.toolbar.isOverflowMenuShowing) binding.toolbar.hideOverflowMenu() else binding.toolbar.showOverflowMenu() + } else super.onKeyUp(keyCode, event) + + override fun onDestroy() { + loader?.cancel() + if (forNeko) DataStore.nekoPlugins = DataStore.routePackages + super.onDestroy() + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/AppManagerActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/AppManagerActivity.kt new file mode 100644 index 0000000..75d2a6b --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/AppManagerActivity.kt @@ -0,0 +1,477 @@ +package io.nekohasekai.sagernet.ui + +import android.annotation.SuppressLint +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.util.SparseBooleanArray +import android.view.* +import android.widget.Filter +import android.widget.Filterable +import android.widget.TextView +import androidx.annotation.UiThread +import androidx.core.util.contains +import androidx.core.util.set +import androidx.core.view.ViewCompat +import androidx.core.widget.addTextChangedListener +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView +import io.nekohasekai.sagernet.BuildConfig +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.SagerNet +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.databinding.LayoutAppsBinding +import io.nekohasekai.sagernet.databinding.LayoutAppsItemBinding +import io.nekohasekai.sagernet.databinding.LayoutLoadingBinding +import io.nekohasekai.sagernet.ktx.Logs +import io.nekohasekai.sagernet.ktx.crossFadeFrom +import io.nekohasekai.sagernet.ktx.onMainDispatcher +import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher +import io.nekohasekai.sagernet.utils.PackageCache +import io.nekohasekai.sagernet.widget.ListHolderListener +import io.nekohasekai.sagernet.widget.ListListener +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import okhttp3.internal.closeQuietly +import org.jf.dexlib2.dexbacked.DexBackedDexFile +import org.jf.dexlib2.iface.DexFile +import java.io.File +import java.util.zip.ZipException +import java.util.zip.ZipFile +import kotlin.coroutines.coroutineContext + +class AppManagerActivity : ThemedActivity() { + companion object { + @SuppressLint("StaticFieldLeak") + private var instance: AppManagerActivity? = null + private const val SWITCH = "switch" + + private val cachedApps + get() = PackageCache.installedPackages.toMutableMap().apply { + remove(BuildConfig.APPLICATION_ID) + } + } + + private class ProxiedApp( + private val pm: PackageManager, private val appInfo: ApplicationInfo, + val packageName: String, + ) { + val name: CharSequence = appInfo.loadLabel(pm) // cached for sorting + val icon: Drawable get() = appInfo.loadIcon(pm) + val uid get() = appInfo.uid + val sys get() = (appInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0 + } + + private inner class AppViewHolder(val binding: LayoutAppsItemBinding) : RecyclerView.ViewHolder( + binding.root + ), + View.OnClickListener { + private lateinit var item: ProxiedApp + + init { + binding.root.setOnClickListener(this) + } + + fun bind(app: ProxiedApp) { + item = app + binding.itemicon.setImageDrawable(app.icon) + binding.title.text = app.name + binding.desc.text = "${app.packageName} (${app.uid})" + binding.itemcheck.isChecked = isProxiedApp(app) + } + + fun handlePayload(payloads: List) { + if (payloads.contains(SWITCH)) binding.itemcheck.isChecked = isProxiedApp(item) + } + + override fun onClick(v: View?) { + if (isProxiedApp(item)) proxiedUids.delete(item.uid) else proxiedUids[item.uid] = true + DataStore.individual = apps.filter { isProxiedApp(it) } + .joinToString("\n") { it.packageName } + + appsAdapter.notifyItemRangeChanged(0, appsAdapter.itemCount, SWITCH) + } + } + + private inner class AppsAdapter : RecyclerView.Adapter(), + Filterable, + FastScrollRecyclerView.SectionedAdapter { + var filteredApps = apps + + suspend fun reload() { + apps = cachedApps.map { (packageName, packageInfo) -> + coroutineContext[Job]!!.ensureActive() + ProxiedApp(packageManager, packageInfo.applicationInfo, packageName) + }.sortedWith(compareBy({ !isProxiedApp(it) }, { it.name.toString() })) + } + + override fun onBindViewHolder(holder: AppViewHolder, position: Int) = + holder.bind(filteredApps[position]) + + override fun onBindViewHolder(holder: AppViewHolder, position: Int, payloads: List) { + if (payloads.isNotEmpty()) { + @Suppress("UNCHECKED_CAST") holder.handlePayload(payloads as List) + return + } + + onBindViewHolder(holder, position) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppViewHolder = + AppViewHolder(LayoutAppsItemBinding.inflate(layoutInflater, parent, false)) + + override fun getItemCount(): Int = filteredApps.size + + private val filterImpl = object : Filter() { + override fun performFiltering(constraint: CharSequence) = FilterResults().apply { + var filteredApps = if (constraint.isEmpty()) apps else apps.filter { + it.name.contains(constraint, true) || it.packageName.contains( + constraint, true + ) || it.uid.toString().contains(constraint) + } + if (!sysApps) filteredApps = filteredApps.filter { !it.sys } + count = filteredApps.size + values = filteredApps + } + + override fun publishResults(constraint: CharSequence, results: FilterResults) { + @Suppress("UNCHECKED_CAST") filteredApps = results.values as List + notifyDataSetChanged() + } + } + + override fun getFilter(): Filter = filterImpl + + override fun getSectionName(position: Int): String { + return filteredApps[position].name.firstOrNull()?.toString() ?: "" + } + + } + + private val loading by lazy { findViewById(R.id.loading) } + + private lateinit var binding: LayoutAppsBinding + private val proxiedUids = SparseBooleanArray() + private var loader: Job? = null + private var apps = emptyList() + private val appsAdapter = AppsAdapter() + + private fun initProxiedUids(str: String = DataStore.individual) { + proxiedUids.clear() + val apps = cachedApps + for (line in str.lineSequence()) proxiedUids[(apps[line] + ?: continue).applicationInfo.uid] = true + } + + private fun isProxiedApp(app: ProxiedApp) = proxiedUids[app.uid] + + @UiThread + private fun loadApps() { + loader?.cancel() + loader = lifecycleScope.launchWhenCreated { + loading.crossFadeFrom(binding.list) + val adapter = binding.list.adapter as AppsAdapter + withContext(Dispatchers.IO) { adapter.reload() } + adapter.filter.filter(binding.search.text?.toString() ?: "") + binding.list.crossFadeFrom(loading) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = LayoutAppsBinding.inflate(layoutInflater) + setContentView(binding.root) + + ListHolderListener.setup(this) + setSupportActionBar(binding.toolbar) + supportActionBar?.apply { + setTitle(R.string.proxied_apps) + setDisplayHomeAsUpEnabled(true) + setHomeAsUpIndicator(R.drawable.ic_navigation_close) + } + + if (!DataStore.proxyApps) { + DataStore.proxyApps = true + } + + binding.bypassGroup.check(if (DataStore.bypass) R.id.appProxyModeBypass else R.id.appProxyModeOn) + binding.bypassGroup.setOnCheckedChangeListener { _, checkedId -> + when (checkedId) { + R.id.appProxyModeDisable -> { + DataStore.proxyApps = false + finish() + } + R.id.appProxyModeOn -> DataStore.bypass = false + R.id.appProxyModeBypass -> DataStore.bypass = true + } + } + + initProxiedUids() + binding.list.layoutManager = LinearLayoutManager(this, RecyclerView.VERTICAL, false) + binding.list.itemAnimator = DefaultItemAnimator() + binding.list.adapter = appsAdapter + + ViewCompat.setOnApplyWindowInsetsListener(binding.root, ListListener) + + binding.search.addTextChangedListener { + appsAdapter.filter.filter(it?.toString() ?: "") + } + + binding.showSystemApps.isChecked = sysApps + binding.showSystemApps.setOnCheckedChangeListener { _, isChecked -> + sysApps = isChecked + appsAdapter.filter.filter(binding.search.text?.toString() ?: "") + } + + instance = this + loadApps() + } + + private var sysApps = true + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.per_app_proxy_menu, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_scan_china_apps -> { + scanChinaApps() + return true + } + R.id.action_invert_selections -> { + runOnDefaultDispatcher { + for (app in apps) { + if (proxiedUids.contains(app.uid)) { + proxiedUids.delete(app.uid) + } else { + proxiedUids[app.uid] = true + } + } + DataStore.individual = apps.filter { isProxiedApp(it) } + .joinToString("\n") { it.packageName } + apps = apps.sortedWith(compareBy({ !isProxiedApp(it) }, { it.name.toString() })) + onMainDispatcher { + appsAdapter.filter.filter(binding.search.text?.toString() ?: "") + } + } + + return true + } + R.id.action_clear_selections -> { + runOnDefaultDispatcher { + proxiedUids.clear() + DataStore.individual = "" + apps = apps.sortedWith(compareBy({ !isProxiedApp(it) }, { it.name.toString() })) + onMainDispatcher { + appsAdapter.filter.filter(binding.search.text?.toString() ?: "") + } + } + } + R.id.action_export_clipboard -> { + val success = SagerNet.trySetPrimaryClip("${DataStore.bypass}\n${DataStore.individual}") + Snackbar.make( + binding.list, + if (success) R.string.action_export_msg else R.string.action_export_err, + Snackbar.LENGTH_LONG + ).show() + return true + } + R.id.action_import_clipboard -> { + val proxiedAppString = SagerNet.clipboard.primaryClip?.getItemAt(0)?.text?.toString() + if (!proxiedAppString.isNullOrEmpty()) { + val i = proxiedAppString.indexOf('\n') + try { + val (enabled, apps) = if (i < 0) { + proxiedAppString to "" + } else proxiedAppString.substring( + 0, i + ) to proxiedAppString.substring(i + 1) + binding.bypassGroup.check(if (enabled.toBoolean()) R.id.appProxyModeBypass else R.id.appProxyModeOn) + DataStore.individual = apps + Snackbar.make( + binding.list, R.string.action_import_msg, Snackbar.LENGTH_LONG + ).show() + initProxiedUids(apps) + appsAdapter.notifyItemRangeChanged(0, appsAdapter.itemCount, SWITCH) + return true + } catch (_: IllegalArgumentException) { + } + } + Snackbar.make(binding.list, R.string.action_import_err, Snackbar.LENGTH_LONG).show() + } + } + return super.onOptionsItemSelected(item) + } + + @SuppressLint("SetTextI18n") + private fun scanChinaApps() { + + val text: TextView + + val dialog = MaterialAlertDialogBuilder(this).setView( + LayoutLoadingBinding.inflate(layoutInflater).apply { + text = loadingText + }.root + ).setCancelable(false).show() + + val txt = text.text.toString() + + runOnDefaultDispatcher { + val chinaApps = ArrayList>() + val chinaRegex = ("(" + arrayOf( + "com.tencent", + "com.alibaba", + "com.umeng", + "com.qihoo", + "com.ali", + "com.alipay", + "com.amap", + "com.sina", + "com.weibo", + "com.vivo", + "com.xiaomi", + "com.huawei", + "com.taobao", + "com.secneo", + "s.h.e.l.l", + "com.stub", + "com.kiwisec", + "com.secshell", + "com.wrapper", + "cn.securitystack", + "com.mogosec", + "com.secoen", + "com.netease", + "com.mx", + "com.qq.e", + "com.baidu", + "com.bytedance", + "com.bugly", + "com.miui", + "com.oppo", + "com.coloros", + "com.iqoo", + "com.meizu", + "com.gionee", + "cn.nubia" + ).joinToString("|") { "${it.replace(".", "\\.")}\\." } + ").*").toRegex() + + val bypass = DataStore.bypass + val cachedApps = cachedApps + + apps = cachedApps.map { (packageName, packageInfo) -> + kotlin.coroutines.coroutineContext[Job]!!.ensureActive() + ProxiedApp(packageManager, packageInfo.applicationInfo, packageName) + }.sortedWith(compareBy({ !isProxiedApp(it) }, { it.name.toString() })) + + scan@ for ((pkg, app) in cachedApps.entries) { + /*if (!sysApps && app.applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0) { + continue + }*/ + + val index = appsAdapter.filteredApps.indexOfFirst { it.uid == app.applicationInfo.uid } + var changed = false + + onMainDispatcher { + text.text = (txt + " " + app.packageName + "\n\n" + chinaApps.map { it.second } + .reversed() + .joinToString("\n", postfix = "\n")).trim() + } + + try { + + val dex = File(app.applicationInfo.publicSourceDir) + val zipFile = ZipFile(dex) + var dexFile: DexFile + + for (entry in zipFile.entries()) { + if (entry.name.startsWith("classes") && entry.name.endsWith(".dex")) { + val input = zipFile.getInputStream(entry).readBytes() + dexFile = try { + DexBackedDexFile.fromInputStream(null, input.inputStream()) + } catch (e: Exception) { + Logs.w(e) + break + } + for (clazz in dexFile.classes) { + val clazzName = clazz.type.substring(1, clazz.type.length - 1) + .replace("/", ".") + .replace("$", ".") + + if (clazzName.matches(chinaRegex)) { + chinaApps.add( + app to app.applicationInfo.loadLabel(packageManager) + .toString() + ) + zipFile.closeQuietly() + + if (bypass) { + changed = !proxiedUids[app.applicationInfo.uid] + proxiedUids[app.applicationInfo.uid] = true + } else { + proxiedUids.delete(app.applicationInfo.uid) + } + + continue@scan + } + } + } + } + zipFile.closeQuietly() + + if (bypass) { + proxiedUids.delete(app.applicationInfo.uid) + } else { + changed = !proxiedUids[index] + proxiedUids[app.applicationInfo.uid] = true + } + + } catch (e: ZipException) { + Logs.w("Error in pkg ${app.packageName}:${app.versionName}", e) + continue + } + + } + + DataStore.individual = apps.filter { isProxiedApp(it) } + .joinToString("\n") { it.packageName } + + apps = apps.sortedWith(compareBy({ !isProxiedApp(it) }, { it.name.toString() })) + + onMainDispatcher { + appsAdapter.filter.filter(binding.search.text?.toString() ?: "") + + dialog.dismiss() + } + + } + + + } + + override fun supportNavigateUpTo(upIntent: Intent) = + super.supportNavigateUpTo(upIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)) + + override fun onKeyUp(keyCode: Int, event: KeyEvent?) = if (keyCode == KeyEvent.KEYCODE_MENU) { + if (binding.toolbar.isOverflowMenuShowing) binding.toolbar.hideOverflowMenu() else binding.toolbar.showOverflowMenu() + } else super.onKeyUp(keyCode, event) + + override fun onDestroy() { + instance = null + loader?.cancel() + super.onDestroy() + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/AssetsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/AssetsActivity.kt new file mode 100644 index 0000000..be36096 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/AssetsActivity.kt @@ -0,0 +1,342 @@ +package io.nekohasekai.sagernet.ui + +import android.os.Bundle +import android.provider.OpenableColumns +import android.text.format.DateFormat +import android.view.Menu +import android.view.MenuItem +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.isInvisible +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.snackbar.Snackbar +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.databinding.LayoutAssetItemBinding +import io.nekohasekai.sagernet.databinding.LayoutAssetsBinding +import io.nekohasekai.sagernet.ktx.* +import io.nekohasekai.sagernet.widget.UndoSnackbarManager +import libcore.Libcore +import org.json.JSONObject +import java.io.File +import java.io.FileWriter +import java.util.* +import java.util.concurrent.atomic.AtomicInteger + +class AssetsActivity : ThemedActivity() { + + lateinit var adapter: AssetAdapter + lateinit var layout: LayoutAssetsBinding + lateinit var undoManager: UndoSnackbarManager + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val binding = LayoutAssetsBinding.inflate(layoutInflater) + layout = binding + setContentView(binding.root) + + setSupportActionBar(findViewById(R.id.toolbar)) + supportActionBar?.apply { + setTitle(R.string.route_assets) + setDisplayHomeAsUpEnabled(true) + setHomeAsUpIndicator(R.drawable.ic_navigation_close) + } + + binding.recyclerView.layoutManager = FixedLinearLayoutManager(binding.recyclerView) + adapter = AssetAdapter() + binding.recyclerView.adapter = adapter + + binding.refreshLayout.setOnRefreshListener { + adapter.reloadAssets() + binding.refreshLayout.isRefreshing = false + } + binding.refreshLayout.setColorSchemeColors(getColorAttr(R.attr.primaryOrTextPrimary)) + + undoManager = UndoSnackbarManager(this, adapter) + + ItemTouchHelper(object : ItemTouchHelper.SimpleCallback( + 0, ItemTouchHelper.START + ) { + + override fun getSwipeDirs( + recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder + ): Int { + val index = viewHolder.bindingAdapterPosition + if (index < 2) return 0 + return super.getSwipeDirs(recyclerView, viewHolder) + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + val index = viewHolder.bindingAdapterPosition + adapter.remove(index) + undoManager.remove(index to (viewHolder as AssetHolder).file) + } + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ) = false + + }).attachToRecyclerView(binding.recyclerView) + } + + override fun snackbarInternal(text: CharSequence): Snackbar { + return Snackbar.make(layout.coordinator, text, Snackbar.LENGTH_LONG) + } + + val assetNames = arrayOf("geoip.db", "geosite.db") + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.import_asset_menu, menu) + return true + } + + val importFile = registerForActivityResult(ActivityResultContracts.GetContent()) { file -> + if (file != null) { + val fileName = contentResolver.query(file, null, null, null, null)?.use { cursor -> + cursor.moveToFirst() + cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME).let(cursor::getString) + }?.takeIf { it.isNotBlank() } ?: file.pathSegments.last() + .substringAfterLast('/') + .substringAfter(':') + + if (!fileName.endsWith(".dat")) { + alert(getString(R.string.route_not_asset, fileName)).show() + return@registerForActivityResult + } + val filesDir = getExternalFilesDir(null) ?: filesDir + + runOnDefaultDispatcher { + val outFile = File(filesDir, fileName).apply { + parentFile?.mkdirs() + } + + contentResolver.openInputStream(file)?.use(outFile.outputStream()) + + File(outFile.parentFile, outFile.nameWithoutExtension + ".version.txt").apply { + if (isFile) delete() + createNewFile() + val fw = FileWriter(this) + fw.write("Custom") + fw.close() + } + + adapter.reloadAssets() + } + + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_import_file -> { + startFilesForResult(importFile, "*/*") + return true + } + } + return false + } + + inner class AssetAdapter : RecyclerView.Adapter(), + UndoSnackbarManager.Interface { + + val assets = ArrayList() + + init { + reloadAssets() + } + + fun reloadAssets() { + val filesDir = getExternalFilesDir(null) ?: filesDir + val files = filesDir.listFiles() + ?.filter { it.isFile && it.name.endsWith(".db") && it.name !in assetNames } + assets.clear() + assets.add(File(filesDir, "geoip.db")) + assets.add(File(filesDir, "geosite.db")) + if (files != null) assets.addAll(files) + + layout.refreshLayout.post { + notifyDataSetChanged() + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AssetHolder { + return AssetHolder(LayoutAssetItemBinding.inflate(layoutInflater, parent, false)) + } + + override fun onBindViewHolder(holder: AssetHolder, position: Int) { + holder.bind(assets[position]) + } + + override fun getItemCount(): Int { + return assets.size + } + + fun remove(index: Int) { + assets.removeAt(index) + notifyItemRemoved(index) + } + + override fun undo(actions: List>) { + for ((index, item) in actions) { + assets.add(index, item) + notifyItemInserted(index) + } + } + + override fun commit(actions: List>) { + val groups = actions.map { it.second }.toTypedArray() + runOnDefaultDispatcher { + groups.forEach { it.deleteRecursively() } + } + } + + } + + val updating = AtomicInteger() + + inner class AssetHolder(val binding: LayoutAssetItemBinding) : + RecyclerView.ViewHolder(binding.root) { + lateinit var file: File + + fun bind(file: File) { + this.file = file + + binding.assetName.text = file.name + val versionFile = File(file.parentFile, "${file.nameWithoutExtension}.version.txt") + + val localVersion = if (file.isFile) { + if (versionFile.isFile) { + versionFile.readText().trim() + } else { + "Unknown-" + DateFormat.getDateFormat(app).format(Date(file.lastModified())) + } + } else { + "" + } + + binding.assetStatus.text = getString(R.string.route_asset_status, localVersion) + + binding.rulesUpdate.isInvisible = file.name !in assetNames + binding.rulesUpdate.setOnClickListener { + updating.incrementAndGet() + layout.refreshLayout.isEnabled = false + binding.subscriptionUpdateProgress.isInvisible = false + binding.rulesUpdate.isInvisible = true + runOnDefaultDispatcher { + runCatching { + updateAsset(file, versionFile, localVersion) + }.onFailure { + onMainDispatcher { + alert(it.readableMessage).show() + } + } + + onMainDispatcher { + binding.rulesUpdate.isInvisible = false + binding.subscriptionUpdateProgress.isInvisible = true + if (updating.decrementAndGet() == 0) { + layout.refreshLayout.isEnabled = true + } + } + } + } + + } + + } + + suspend fun updateAsset(file: File, versionFile: File, localVersion: String) { + val repo: String + var fileName = file.name + + if (DataStore.rulesProvider == 0) { + if (file.name == assetNames[0]) { + repo = "SagerNet/sing-geoip" + } else { + repo = "SagerNet/sing-geosite" + } + } else { + if (file.name == assetNames[0]) { + repo = "soffchen/sing-geoip" + } else { + repo = "soffchen/sing-geosite" + } + } + + val client = Libcore.newHttpClient().apply { + modernTLS() + keepAlive() + trySocks5(DataStore.mixedPort) + } + + try { + var response = client.newRequest().apply { + setURL("https://api.github.com/repos/$repo/releases/latest") + }.execute() + + val release = JSONObject(response.contentString) + val tagName = release.optString("tag_name") + + if (tagName == localVersion) { + onMainDispatcher { + snackbar(R.string.route_asset_no_update).show() + } + return + } + + val releaseAssets = release.getJSONArray("assets").filterIsInstance() + val assetToDownload = releaseAssets.find { it.getStr("name") == fileName } + ?: error("File $fileName not found in release ${release["url"]}") + val browserDownloadUrl = assetToDownload.getStr("browser_download_url") + + response = client.newRequest().apply { + setURL(browserDownloadUrl) + }.execute() + + val cacheFile = File(file.parentFile, file.name + ".tmp") + cacheFile.parentFile?.mkdirs() + + response.writeTo(cacheFile.canonicalPath) + + if (fileName.endsWith(".xz")) { + Libcore.unxz(cacheFile.absolutePath, file.absolutePath) + cacheFile.delete() + } else { + cacheFile.renameTo(file) + } + + versionFile.writeText(tagName) + + adapter.reloadAssets() + + onMainDispatcher { + snackbar(R.string.route_asset_updated).show() + } + } finally { + client.close() + } + } + + override fun onSupportNavigateUp(): Boolean { + finish() + return true + } + + override fun onBackPressed() { + finish() + } + + override fun onResume() { + super.onResume() + + if (::adapter.isInitialized) { + adapter.reloadAssets() + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/BackupFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/BackupFragment.kt new file mode 100644 index 0000000..4205b69 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/BackupFragment.kt @@ -0,0 +1,313 @@ +package io.nekohasekai.sagernet.ui + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.os.Parcel +import android.os.Parcelable +import android.provider.OpenableColumns +import android.view.View +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog +import androidx.core.content.FileProvider +import androidx.core.view.isVisible +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.jakewharton.processphoenix.ProcessPhoenix +import io.nekohasekai.sagernet.BuildConfig +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.SagerNet +import io.nekohasekai.sagernet.database.* +import io.nekohasekai.sagernet.database.preference.KeyValuePair +import io.nekohasekai.sagernet.database.preference.PublicDatabase +import io.nekohasekai.sagernet.databinding.LayoutBackupBinding +import io.nekohasekai.sagernet.databinding.LayoutImportBinding +import io.nekohasekai.sagernet.databinding.LayoutProgressBinding +import io.nekohasekai.sagernet.ktx.* +import moe.matsuri.nb4a.utils.Util +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.util.* + +class BackupFragment : NamedFragment(R.layout.layout_backup) { + + override fun name0() = app.getString(R.string.backup) + + var content = "" + private val exportSettings = registerForActivityResult(ActivityResultContracts.CreateDocument()) { data -> + if (data != null) { + runOnDefaultDispatcher { + try { + requireActivity().contentResolver.openOutputStream( + data + )!!.bufferedWriter().use { + it.write(content) + } + onMainDispatcher { + snackbar(getString(R.string.action_export_msg)).show() + } + } catch (e: Exception) { + Logs.w(e) + onMainDispatcher { + snackbar(e.readableMessage).show() + } + } + + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val binding = LayoutBackupBinding.bind(view) + binding.actionExport.setOnClickListener { + runOnDefaultDispatcher { + content = doBackup( + binding.backupConfigurations.isChecked, + binding.backupRules.isChecked, + binding.backupSettings.isChecked + ) + onMainDispatcher { + startFilesForResult( + exportSettings, "matsuri_backup_${Date().toLocaleString()}.json" + ) + } + } + } + + binding.actionShare.setOnClickListener { + runOnDefaultDispatcher { + content = doBackup( + binding.backupConfigurations.isChecked, + binding.backupRules.isChecked, + binding.backupSettings.isChecked + ) + app.cacheDir.mkdirs() + val cacheFile = File( + app.cacheDir, "matsuri_backup_${Date().toLocaleString()}.json" + ) + cacheFile.writeText(content) + onMainDispatcher { + startActivity( + Intent.createChooser( + Intent(Intent.ACTION_SEND).setType("application/json") + .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .putExtra( + Intent.EXTRA_STREAM, FileProvider.getUriForFile( + app, BuildConfig.APPLICATION_ID + ".cache", cacheFile + ) + ), app.getString(R.string.abc_shareactionprovider_share_with) + ) + ) + } + + } + } + + binding.actionImportFile.setOnClickListener { + startFilesForResult(importFile, "*/*") + } + } + + fun Parcelable.toBase64Str(): String { + val parcel = Parcel.obtain() + writeToParcel(parcel, 0) + try { + return Util.b64EncodeUrlSafe(parcel.marshall()) + } finally { + parcel.recycle() + } + } + + fun doBackup(profile: Boolean, rule: Boolean, setting: Boolean): String { + val out = JSONObject().apply { + put("version", 1) + if (profile) { + put("profiles", JSONArray().apply { + SagerDatabase.proxyDao.getAll().forEach { + put(it.toBase64Str()) + } + }) + + put("groups", JSONArray().apply { + SagerDatabase.groupDao.allGroups().forEach { + put(it.toBase64Str()) + } + }) + } + if (rule) { + put("rules", JSONArray().apply { + SagerDatabase.rulesDao.allRules().forEach { + put(it.toBase64Str()) + } + }) + } + if (setting) { + put("settings", JSONArray().apply { + PublicDatabase.kvPairDao.all().forEach { + put(it.toBase64Str()) + } + }) + } + } + return out.toStringPretty() + } + + val importFile = registerForActivityResult(ActivityResultContracts.GetContent()) { file -> + if (file != null) { + runOnDefaultDispatcher { + startImport(file) + } + } + } + + suspend fun startImport(file: Uri) { + val fileName = requireContext().contentResolver.query(file, null, null, null, null) + ?.use { cursor -> + cursor.moveToFirst() + cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME).let(cursor::getString) + } + ?.takeIf { it.isNotBlank() } ?: file.pathSegments.last() + .substringAfterLast('/') + .substringAfter(':') + + if (!fileName.endsWith(".json")) { + onMainDispatcher { + snackbar(getString(R.string.backup_not_file, fileName)).show() + } + return + } + + suspend fun invalid() = onMainDispatcher { + onMainDispatcher { + snackbar(getString(R.string.invalid_backup_file)).show() + } + } + + val content = try { + JSONObject((requireContext().contentResolver.openInputStream(file) ?: return).use { + it.bufferedReader().readText() + }) + } catch (e: Exception) { + Logs.w(e) + invalid() + return + } + val version = content.optInt("version", 0) + if (version < 1 || version > 1) { + invalid() + return + } + + onMainDispatcher { + val import = LayoutImportBinding.inflate(layoutInflater) + if (!content.has("profiles")) { + import.backupConfigurations.isVisible = false + } + if (!content.has("rules")) { + import.backupRules.isVisible = false + } + if (!content.has("settings")) { + import.backupSettings.isVisible = false + } + MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.backup_import) + .setView(import.root) + .setPositiveButton(R.string.backup_import) { _, _ -> + SagerNet.stopService() + + val binding = LayoutProgressBinding.inflate(layoutInflater) + binding.content.text = getString(R.string.backup_importing) + val dialog = AlertDialog.Builder(requireContext()) + .setView(binding.root) + .setCancelable(false) + .show() + runOnDefaultDispatcher { + runCatching { + finishImport( + content, + import.backupConfigurations.isChecked, + import.backupRules.isChecked, + import.backupSettings.isChecked + ) + ProcessPhoenix.triggerRebirth( + requireContext(), Intent(requireContext(), MainActivity::class.java) + ) + }.onFailure { + Logs.w(it) + onMainDispatcher { + alert(it.readableMessage).show() + } + } + + onMainDispatcher { + dialog.dismiss() + } + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + } + + fun finishImport( + content: JSONObject, profile: Boolean, rule: Boolean, setting: Boolean + ) { + if (profile && content.has("profiles")) { + val profiles = mutableListOf() + val jsonProfiles = content.getJSONArray("profiles") + for (i in 0 until jsonProfiles.length()) { + val data = Util.b64Decode(jsonProfiles[i] as String) + val parcel = Parcel.obtain() + parcel.unmarshall(data, 0, data.size) + parcel.setDataPosition(0) + profiles.add(ProxyEntity.CREATOR.createFromParcel(parcel)) + parcel.recycle() + } + SagerDatabase.proxyDao.reset() + SagerDatabase.proxyDao.insert(profiles) + + val groups = mutableListOf() + val jsonGroups = content.getJSONArray("groups") + for (i in 0 until jsonGroups.length()) { + val data = Util.b64Decode(jsonGroups[i] as String) + val parcel = Parcel.obtain() + parcel.unmarshall(data, 0, data.size) + parcel.setDataPosition(0) + groups.add(ProxyGroup.CREATOR.createFromParcel(parcel)) + parcel.recycle() + } + SagerDatabase.groupDao.reset() + SagerDatabase.groupDao.insert(groups) + } + if (rule && content.has("rules")) { + val rules = mutableListOf() + val jsonRules = content.getJSONArray("rules") + for (i in 0 until jsonRules.length()) { + val data = Util.b64Decode(jsonRules[i] as String) + val parcel = Parcel.obtain() + parcel.unmarshall(data, 0, data.size) + parcel.setDataPosition(0) + rules.add(ParcelizeBridge.createRule(parcel)) + parcel.recycle() + } + SagerDatabase.rulesDao.reset() + SagerDatabase.rulesDao.insert(rules) + } + if (setting && content.has("settings")) { + val settings = mutableListOf() + val jsonSettings = content.getJSONArray("settings") + for (i in 0 until jsonSettings.length()) { + val data = Util.b64Decode(jsonSettings[i] as String) + val parcel = Parcel.obtain() + parcel.unmarshall(data, 0, data.size) + parcel.setDataPosition(0) + settings.add(KeyValuePair.CREATOR.createFromParcel(parcel)) + parcel.recycle() + } + PublicDatabase.kvPairDao.reset() + PublicDatabase.kvPairDao.insert(settings) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/BlankActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/BlankActivity.kt new file mode 100644 index 0000000..f77dc8c --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/BlankActivity.kt @@ -0,0 +1,20 @@ +package io.nekohasekai.sagernet.ui + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import moe.matsuri.nb4a.utils.SendLog + +class BlankActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // process crash log + intent?.getStringExtra("sendLog")?.apply { + SendLog.sendLog(this@BlankActivity, this) + } + + finish() + } + +} \ 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 0000000..0f0618a --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -0,0 +1,1574 @@ +package io.nekohasekai.sagernet.ui + +import android.content.Intent +import android.graphics.Color +import android.net.Uri +import android.os.Bundle +import android.os.SystemClock +import android.provider.OpenableColumns +import android.text.SpannableStringBuilder +import android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE +import android.text.format.Formatter +import android.text.style.ForegroundColorSpan +import android.view.* +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.PopupMenu +import androidx.appcompat.widget.SearchView +import androidx.appcompat.widget.Toolbar +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.core.view.size +import androidx.fragment.app.Fragment +import androidx.preference.PreferenceDataStore +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.adapter.FragmentStateAdapter +import androidx.viewpager2.widget.ViewPager2 +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator +import io.nekohasekai.sagernet.* +import io.nekohasekai.sagernet.aidl.TrafficData +import io.nekohasekai.sagernet.bg.BaseService +import io.nekohasekai.sagernet.bg.proto.UrlTest +import io.nekohasekai.sagernet.database.* +import io.nekohasekai.sagernet.database.preference.OnPreferenceDataStoreChangeListener +import io.nekohasekai.sagernet.databinding.LayoutAppsItemBinding +import io.nekohasekai.sagernet.databinding.LayoutProfileListBinding +import io.nekohasekai.sagernet.databinding.LayoutProgressListBinding +import io.nekohasekai.sagernet.fmt.AbstractBean +import io.nekohasekai.sagernet.fmt.toUniversalLink +import io.nekohasekai.sagernet.fmt.v2ray.toV2rayN +import io.nekohasekai.sagernet.group.RawUpdater +import io.nekohasekai.sagernet.ktx.* +import io.nekohasekai.sagernet.plugin.PluginManager +import io.nekohasekai.sagernet.ui.profile.* +import io.nekohasekai.sagernet.utils.PackageCache +import io.nekohasekai.sagernet.widget.QRCodeDialog +import io.nekohasekai.sagernet.widget.UndoSnackbarManager +import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import moe.matsuri.nb4a.Protocols +import moe.matsuri.nb4a.Protocols.getProtocolColor +import moe.matsuri.nb4a.proxy.config.ConfigSettingActivity +import moe.matsuri.nb4a.proxy.neko.NekoJSInterface +import moe.matsuri.nb4a.plugin.NekoPluginManager +import moe.matsuri.nb4a.proxy.neko.NekoSettingActivity +import moe.matsuri.nb4a.proxy.neko.canShare +import okhttp3.internal.closeQuietly +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Socket +import java.net.UnknownHostException +import java.util.* +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicInteger +import java.util.zip.ZipInputStream +import kotlin.collections.set + +class ConfigurationFragment @JvmOverloads constructor( + val select: Boolean = false, val selectedItem: ProxyEntity? = null, val titleRes: Int = 0 +) : ToolbarFragment(R.layout.layout_group_list), + PopupMenu.OnMenuItemClickListener, + Toolbar.OnMenuItemClickListener, + SearchView.OnQueryTextListener, + OnPreferenceDataStoreChangeListener { + + interface SelectCallback { + fun returnProfile(profileId: Long) + } + + lateinit var adapter: GroupPagerAdapter + lateinit var tabLayout: TabLayout + lateinit var groupPager: ViewPager2 + + val alwaysShowAddress by lazy { DataStore.alwaysShowAddress } + + fun getCurrentGroupFragment(): GroupFragment? { + return childFragmentManager.findFragmentByTag("f" + DataStore.selectedGroup) as GroupFragment? + } + + val updateSelectedCallback = object : ViewPager2.OnPageChangeCallback() { + override fun onPageScrolled( + position: Int, positionOffset: Float, positionOffsetPixels: Int + ) { + if (adapter.groupList.size > position) { + DataStore.selectedGroup = adapter.groupList[position].id + } + } + } + + override fun onQueryTextChange(query: String): Boolean { + getCurrentGroupFragment()?.adapter?.filter(query) + return false + } + + override fun onQueryTextSubmit(query: String): Boolean = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (savedInstanceState != null) { + parentFragmentManager.beginTransaction() + .setReorderingAllowed(false) + .detach(this) + .attach(this) + .commit() + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + if (!select) { + toolbar.inflateMenu(R.menu.add_profile_menu) + toolbar.setOnMenuItemClickListener(this) + } else { + toolbar.setTitle(titleRes) + toolbar.setNavigationIcon(R.drawable.ic_navigation_close) + toolbar.setNavigationOnClickListener { + requireActivity().finish() + } + } + + val searchView = toolbar.findViewById(R.id.action_search) + if (searchView != null) { + searchView.setOnQueryTextListener(this) + searchView.maxWidth = Int.MAX_VALUE + } + + groupPager = view.findViewById(R.id.group_pager) + tabLayout = view.findViewById(R.id.group_tab) + adapter = GroupPagerAdapter() + ProfileManager.addListener(adapter) + GroupManager.addListener(adapter) + + groupPager.adapter = adapter + groupPager.offscreenPageLimit = 2 + + TabLayoutMediator(tabLayout, groupPager) { tab, position -> + if (adapter.groupList.size > position) { + tab.text = adapter.groupList[position].displayName() + } + tab.view.setOnLongClickListener { // clear toast + true + } + }.attach() + + toolbar.setOnClickListener { + val fragment = getCurrentGroupFragment() + + if (fragment != null) { + val selectedProxy = selectedItem?.id ?: DataStore.selectedProxy + val selectedProfileIndex = + fragment.adapter!!.configurationIdList.indexOf(selectedProxy) + if (selectedProfileIndex != -1) { + val layoutManager = fragment.layoutManager + val first = layoutManager.findFirstVisibleItemPosition() + val last = layoutManager.findLastVisibleItemPosition() + + if (selectedProfileIndex !in first..last) { + fragment.configurationListView.scrollTo(selectedProfileIndex, true) + return@setOnClickListener + } + + } + + fragment.configurationListView.scrollTo(0) + } + + } + + DataStore.profileCacheStore.registerChangeListener(this) + } + + override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) { + runOnMainDispatcher { + // editingGroup + if (key == Key.PROFILE_GROUP) { + val targetId = DataStore.editingGroup + if (targetId > 0 && targetId != DataStore.selectedGroup) { + DataStore.selectedGroup = targetId + val targetIndex = adapter.groupList.indexOfFirst { it.id == targetId } + if (targetIndex >= 0) { + groupPager.setCurrentItem(targetIndex, false) + } else { + adapter.reload() + } + } + } + } + } + + override fun onDestroy() { + DataStore.profileCacheStore.unregisterChangeListener(this) + + if (::adapter.isInitialized) { + GroupManager.removeListener(adapter) + ProfileManager.removeListener(adapter) + } + + super.onDestroy() + } + + override fun onKeyDown(ketCode: Int, event: KeyEvent): Boolean { + val fragment = getCurrentGroupFragment() + fragment?.configurationListView?.apply { + if (!hasFocus()) requestFocus() + } + return super.onKeyDown(ketCode, event) + } + + val importFile = registerForActivityResult(ActivityResultContracts.GetContent()) { file -> + if (file != null) runOnDefaultDispatcher { + try { + val fileName = requireContext().contentResolver.query(file, null, null, null, null) + ?.use { cursor -> + cursor.moveToFirst() + cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME) + .let(cursor::getString) + } + + val proxies = mutableListOf() + if (fileName != null && fileName.endsWith(".zip")) { + // try parse wireguard zip + + val zip = + ZipInputStream(requireContext().contentResolver.openInputStream(file)!!) + while (true) { + val entry = zip.nextEntry ?: break + if (entry.isDirectory) continue + val fileText = zip.bufferedReader().readText() + RawUpdater.parseRaw(fileText)?.let { pl -> proxies.addAll(pl) } + zip.closeEntry() + } + zip.closeQuietly() + } else { + val fileText = requireContext().contentResolver.openInputStream(file)!!.use { + it.bufferedReader().readText() + } + RawUpdater.parseRaw(fileText)?.let { pl -> proxies.addAll(pl) } + } + + if (proxies.isEmpty()) onMainDispatcher { + snackbar(getString(R.string.no_proxies_found_in_file)).show() + } else import(proxies) + } catch (e: SubscriptionFoundException) { + (requireActivity() as MainActivity).importSubscription(Uri.parse(e.link)) + } catch (e: Exception) { + Logs.w(e) + + onMainDispatcher { + snackbar(e.readableMessage).show() + } + } + } + } + + suspend fun import(proxies: List) { + val targetId = DataStore.selectedGroupForImport() + for (proxy in proxies) { + ProfileManager.createProfile(targetId, proxy) + } + onMainDispatcher { + DataStore.editingGroup = targetId + snackbar( + requireContext().resources.getQuantityString( + R.plurals.added, proxies.size, proxies.size + ) + ).show() + } + + } + + override fun onMenuItemClick(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_scan_qr_code -> { + startActivity(Intent(context, ScannerActivity::class.java)) + } + R.id.action_import_clipboard -> { + val text = SagerNet.getClipboardText() + if (text.isBlank()) { + snackbar(getString(R.string.clipboard_empty)).show() + } else runOnDefaultDispatcher { + try { + val proxies = RawUpdater.parseRaw(text) + if (proxies.isNullOrEmpty()) onMainDispatcher { + snackbar(getString(R.string.no_proxies_found_in_clipboard)).show() + } else import(proxies) + } catch (e: SubscriptionFoundException) { + (requireActivity() as MainActivity).importSubscription(Uri.parse(e.link)) + } catch (e: Exception) { + Logs.w(e) + + onMainDispatcher { + snackbar(e.readableMessage).show() + } + } + } + } + R.id.action_import_file -> { + startFilesForResult(importFile, "*/*") + } + R.id.action_new_socks -> { + startActivity(Intent(requireActivity(), SocksSettingsActivity::class.java)) + } + R.id.action_new_http -> { + startActivity(Intent(requireActivity(), HttpSettingsActivity::class.java)) + } + R.id.action_new_ss -> { + startActivity(Intent(requireActivity(), ShadowsocksSettingsActivity::class.java)) + } + R.id.action_new_vmess -> { + startActivity(Intent(requireActivity(), VMessSettingsActivity::class.java)) + } + R.id.action_new_vless -> { + startActivity(Intent(requireActivity(), VMessSettingsActivity::class.java).apply { + putExtra("vless", true) + }) + } + R.id.action_new_trojan -> { + startActivity(Intent(requireActivity(), TrojanSettingsActivity::class.java)) + } + R.id.action_new_trojan_go -> { + startActivity(Intent(requireActivity(), TrojanGoSettingsActivity::class.java)) + } + R.id.action_new_naive -> { + startActivity(Intent(requireActivity(), NaiveSettingsActivity::class.java)) + } + R.id.action_new_hysteria -> { + startActivity(Intent(requireActivity(), HysteriaSettingsActivity::class.java)) + } + R.id.action_new_tuic -> { + startActivity(Intent(requireActivity(), TuicSettingsActivity::class.java)) + } + R.id.action_new_ssh -> { + startActivity(Intent(requireActivity(), SSHSettingsActivity::class.java)) + } + R.id.action_new_wg -> { + startActivity(Intent(requireActivity(), WireGuardSettingsActivity::class.java)) + } + R.id.action_new_config -> { + startActivity(Intent(requireActivity(), ConfigSettingActivity::class.java)) + } + R.id.action_new_chain -> { + startActivity(Intent(requireActivity(), ChainSettingsActivity::class.java)) + } + R.id.action_new_neko -> { + val context = requireContext() + lateinit var dialog: AlertDialog + val linearLayout = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + + NekoPluginManager.getProtocols().forEach { obj -> + LayoutAppsItemBinding.inflate(layoutInflater, this, true).apply { + itemcheck.isGone = true + button.isGone = false + itemicon.setImageDrawable( + PackageCache.installedApps[obj.plgId]?.loadIcon( + context.packageManager + ) + ) + title.text = obj.protocolId + desc.text = obj.plgId + button.setOnClickListener { + dialog.dismiss() + val intent = Intent(context, NekoSettingActivity::class.java) + intent.putExtra("plgId", obj.plgId) + intent.putExtra("protocolId", obj.protocolId) + startActivity(intent) + } + } + } + } + dialog = MaterialAlertDialogBuilder(context).setTitle(R.string.neko_plugin) + .setView(linearLayout) + .show() + } + R.id.action_clear_traffic_statistics -> { + runOnDefaultDispatcher { + val profiles = SagerDatabase.proxyDao.getByGroup(DataStore.currentGroupId()) + val toClear = mutableListOf() + if (profiles.isNotEmpty()) for (profile in profiles) { + if (profile.tx != 0L || profile.rx != 0L) { + profile.tx = 0 + profile.rx = 0 + toClear.add(profile) + } + } + if (toClear.isNotEmpty()) { + ProfileManager.updateProfile(toClear) + } + } + } + R.id.action_connection_test_clear_results -> { + runOnDefaultDispatcher { + val profiles = SagerDatabase.proxyDao.getByGroup(DataStore.currentGroupId()) + val toClear = mutableListOf() + if (profiles.isNotEmpty()) for (profile in profiles) { + if (profile.status != 0) { + profile.status = 0 + profile.ping = 0 + profile.error = null + toClear.add(profile) + } + } + if (toClear.isNotEmpty()) { + ProfileManager.updateProfile(toClear) + } + } + } + R.id.action_connection_test_delete_unavailable -> { + runOnDefaultDispatcher { + val profiles = SagerDatabase.proxyDao.getByGroup(DataStore.currentGroupId()) + val toClear = mutableListOf() + if (profiles.isNotEmpty()) for (profile in profiles) { + if (profile.status != 0 && profile.status != 1) { + toClear.add(profile) + } + } + if (toClear.isNotEmpty()) { + onMainDispatcher { + MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.confirm) + .setMessage(R.string.delete_confirm_prompt) + .setPositiveButton(R.string.yes) { _, _ -> + for (profile in toClear) { + adapter.groupFragments[DataStore.selectedGroup]?.adapter?.apply { + val index = configurationIdList.indexOf(profile.id) + if (index >= 0) { + configurationIdList.removeAt(index) + configurationList.remove(profile.id) + notifyItemRemoved(index) + } + } + } + runOnDefaultDispatcher { + for (profile in toClear) { + ProfileManager.deleteProfile2( + profile.groupId, profile.id + ) + } + } + } + .setNegativeButton(R.string.no, null) + .show() + } + } + } + } + R.id.action_connection_icmp_ping -> { + pingTest(true) + } + R.id.action_connection_tcp_ping -> { + pingTest(false) + } + R.id.action_connection_url_test -> { + urlTest() + } + } + return true + } + + inner class TestDialog { + val binding = LayoutProgressListBinding.inflate(layoutInflater) + val builder = MaterialAlertDialogBuilder(requireContext()).setView(binding.root) + .setNegativeButton(android.R.string.cancel) { _, _ -> + cancel() + } + .setOnDismissListener { + cancel() + } + .setCancelable(false) + + lateinit var cancel: () -> Unit + val fragment by lazy { getCurrentGroupFragment() } + val results = Collections.synchronizedList(mutableListOf()) + var proxyN = 0 + val finishedN = AtomicInteger(0) + + suspend fun insert(profile: ProxyEntity?) { + results.add(profile) + } + + suspend fun update(profile: ProxyEntity) { + fragment?.configurationListView?.post { + var profileStatusText: String? = null + var profileStatusColor = 0 + + when (profile.status) { + -1 -> { + profileStatusText = profile.error + profileStatusColor = + requireContext().getColorAttr(android.R.attr.textColorSecondary) + } + 0 -> { + profileStatusText = getString(R.string.connection_test_testing) + profileStatusColor = + requireContext().getColorAttr(android.R.attr.textColorSecondary) + } + 1 -> { + profileStatusText = getString(R.string.available, profile.ping) + profileStatusColor = requireContext().getColour(R.color.material_green_500) + } + 2 -> { + profileStatusText = profile.error + profileStatusColor = requireContext().getColour(R.color.material_red_500) + } + 3 -> { + val err = profile.error ?: "" + val msg = Protocols.genFriendlyMsg(err) + profileStatusText = if (msg != err) msg else getString(R.string.unavailable) + profileStatusColor = requireContext().getColour(R.color.material_red_500) + } + } + + val text = SpannableStringBuilder().apply { + append("\n" + profile.displayName()) + append("\n") + append( + profile.displayType(), + ForegroundColorSpan(requireContext().getProtocolColor(profile.type)), + SPAN_EXCLUSIVE_EXCLUSIVE + ) + append(" ") + append( + profileStatusText, + ForegroundColorSpan(profileStatusColor), + SPAN_EXCLUSIVE_EXCLUSIVE + ) + append("\n") + } + + binding.nowTesting.text = text + binding.progress.text = "${finishedN.addAndGet(1)} / $proxyN" + } + } + + } + + fun stopService() { + if (DataStore.serviceState.started) SagerNet.stopService() + } + + @Suppress("EXPERIMENTAL_API_USAGE") + fun pingTest(icmpPing: Boolean) { + stopService() + + val test = TestDialog() + val testJobs = mutableListOf() + val dialog = test.builder.show() + val mainJob = runOnDefaultDispatcher { + val group = DataStore.currentGroup() + var profilesUnfiltered = SagerDatabase.proxyDao.getByGroup(group.id) + test.proxyN = profilesUnfiltered.size + val profiles = ConcurrentLinkedQueue(profilesUnfiltered) + val testPool = newFixedThreadPoolContext(5, "Connection test pool") + repeat(5) { + testJobs.add(launch(testPool) { + while (isActive) { + val profile = profiles.poll() ?: break + + if (icmpPing) { + if (!profile.requireBean().canICMPing()) { + profile.status = -1 + profile.error = + app.getString(R.string.connection_test_icmp_ping_unavailable) + test.insert(profile) + continue + } + } else { + if (!profile.requireBean().canTCPing()) { + profile.status = -1 + profile.error = + app.getString(R.string.connection_test_tcp_ping_unavailable) + test.insert(profile) + continue + } + } + + profile.status = 0 + test.insert(profile) + var address = profile.requireBean().serverAddress + if (!address.isIpAddress()) { + try { + InetAddress.getAllByName(address).apply { + if (isNotEmpty()) { + address = this[0].hostAddress + } + } + } catch (ignored: UnknownHostException) { + } + } + if (!isActive) break + if (!address.isIpAddress()) { + profile.status = 2 + profile.error = app.getString(R.string.connection_test_domain_not_found) + test.update(profile) + continue + } + try { + if (icmpPing) { + // removed + } else { + val socket = Socket() + try { + socket.soTimeout = 3000 + socket.bind(InetSocketAddress(0)) + val start = SystemClock.elapsedRealtime() + socket.connect( + InetSocketAddress( + address, profile.requireBean().serverPort + ), 3000 + ) + if (!isActive) break + profile.status = 1 + profile.ping = (SystemClock.elapsedRealtime() - start).toInt() + test.update(profile) + } finally { + socket.closeQuietly() + } + } + } catch (e: Exception) { + if (!isActive) break + val message = e.readableMessage + + if (icmpPing) { + profile.status = 2 + profile.error = getString(R.string.connection_test_unreachable) + } else { + profile.status = 2 + when { + !message.contains("failed:") -> profile.error = + getString(R.string.connection_test_timeout) + else -> when { + message.contains("ECONNREFUSED") -> { + profile.error = + getString(R.string.connection_test_refused) + } + message.contains("ENETUNREACH") -> { + profile.error = + getString(R.string.connection_test_unreachable) + } + else -> { + profile.status = 3 + profile.error = message + } + } + } + } + test.update(profile) + } + } + }) + } + + testJobs.joinAll() + testPool.close() + + onMainDispatcher { + dialog.dismiss() + } + } + test.cancel = { + runOnDefaultDispatcher { + test.results.filterNotNull().forEach { + try { + ProfileManager.updateProfile(it) + } catch (e: Exception) { + Logs.w(e) + } + } + GroupManager.postReload(DataStore.currentGroupId()) + mainJob.cancel() + testJobs.forEach { it.cancel() } + } + } + } + + fun urlTest() { + stopService() + + val test = TestDialog() + val dialog = test.builder.show() + val testJobs = mutableListOf() + + val mainJob = runOnDefaultDispatcher { + val group = DataStore.currentGroup() + val profilesUnfiltered = SagerDatabase.proxyDao.getByGroup(group.id) + test.proxyN = profilesUnfiltered.size + val profiles = ConcurrentLinkedQueue(profilesUnfiltered) + val urlTest = UrlTest() // note: this is NOT in bg process + + repeat(5) { + testJobs.add(launch { + while (isActive) { + val profile = profiles.poll() ?: break + profile.status = 0 + test.insert(profile) + + try { + val result = urlTest.doTest(profile) + profile.status = 1 + profile.ping = result + } catch (e: PluginManager.PluginNotFoundException) { + profile.status = 2 + profile.error = e.readableMessage + } catch (e: Exception) { + profile.status = 3 + profile.error = e.readableMessage + } + + test.update(profile) + } + }) + } + + testJobs.joinAll() + + onMainDispatcher { + dialog.dismiss() + } + } + test.cancel = { + runOnDefaultDispatcher { + test.results.filterNotNull().forEach { + try { + ProfileManager.updateProfile(it) + } catch (e: Exception) { + Logs.w(e) + } + } + GroupManager.postReload(DataStore.currentGroupId()) + NekoJSInterface.Default.destroyAllJsi() + mainJob.cancel() + testJobs.forEach { it.cancel() } + } + } + } + + inner class GroupPagerAdapter : FragmentStateAdapter(this), + ProfileManager.Listener, + GroupManager.Listener { + + var selectedGroupIndex = 0 + var groupList: ArrayList = ArrayList() + var groupFragments: HashMap = HashMap() + + fun reload(now: Boolean = false) { + + if (!select) { + groupPager.unregisterOnPageChangeCallback(updateSelectedCallback) + } + + runOnDefaultDispatcher { + var newGroupList = ArrayList(SagerDatabase.groupDao.allGroups()) + if (newGroupList.isEmpty()) { + SagerDatabase.groupDao.createGroup(ProxyGroup(ungrouped = true)) + newGroupList = ArrayList(SagerDatabase.groupDao.allGroups()) + } + newGroupList.find { it.ungrouped }?.let { + if (SagerDatabase.proxyDao.countByGroup(it.id) == 0L) { + newGroupList.remove(it) + } + } + + var selectedGroup = selectedItem?.groupId ?: DataStore.currentGroupId() + var set = false + if (selectedGroup > 0L) { + selectedGroupIndex = newGroupList.indexOfFirst { it.id == selectedGroup } + set = true + } else if (groupList.size == 1) { + selectedGroup = groupList[0].id + if (DataStore.selectedGroup != selectedGroup) { + DataStore.selectedGroup = selectedGroup + } + } + + val runFunc = if (now) requireActivity()::runOnUiThread else groupPager::post + runFunc { + groupList = newGroupList + notifyDataSetChanged() + if (set) groupPager.setCurrentItem(selectedGroupIndex, false) + val hideTab = groupList.size < 2 + tabLayout.isGone = hideTab + toolbar.elevation = if (hideTab) 0F else dp2px(4).toFloat() + if (!select) { + groupPager.registerOnPageChangeCallback(updateSelectedCallback) + } + } + } + } + + init { + reload(true) + } + + override fun getItemCount(): Int { + return groupList.size + } + + override fun createFragment(position: Int): Fragment { + return GroupFragment().apply { + proxyGroup = groupList[position] + groupFragments[proxyGroup.id] = this + if (position == selectedGroupIndex) { + selected = true + } + } + } + + override fun getItemId(position: Int): Long { + return groupList[position].id + } + + override fun containsItem(itemId: Long): Boolean { + return groupList.any { it.id == itemId } + } + + override suspend fun groupAdd(group: ProxyGroup) { + tabLayout.post { + groupList.add(group) + + if (groupList.any { !it.ungrouped }) tabLayout.post { + tabLayout.visibility = View.VISIBLE + } + + notifyItemInserted(groupList.size - 1) + tabLayout.getTabAt(groupList.size - 1)?.select() + } + } + + override suspend fun groupRemoved(groupId: Long) { + val index = groupList.indexOfFirst { it.id == groupId } + if (index == -1) return + + tabLayout.post { + groupList.removeAt(index) + notifyItemRemoved(index) + } + } + + override suspend fun groupUpdated(group: ProxyGroup) { + val index = groupList.indexOfFirst { it.id == group.id } + if (index == -1) return + + tabLayout.post { + tabLayout.getTabAt(index)?.text = group.displayName() + } + } + + override suspend fun groupUpdated(groupId: Long) = Unit + + override suspend fun onAdd(profile: ProxyEntity) { + if (groupList.find { it.id == profile.groupId } == null) { + DataStore.selectedGroup = profile.groupId + reload() + } + } + + override suspend fun onUpdated(data: TrafficData) = Unit + + override suspend fun onUpdated(profile: ProxyEntity) = Unit + + override suspend fun onRemoved(groupId: Long, profileId: Long) { + val group = groupList.find { it.id == groupId } ?: return + if (group.ungrouped && SagerDatabase.proxyDao.countByGroup(groupId) == 0L) { + reload() + } + } + } + + class GroupFragment : Fragment() { + + lateinit var proxyGroup: ProxyGroup + var selected = false + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + return LayoutProfileListBinding.inflate(inflater).root + } + + lateinit var undoManager: UndoSnackbarManager + var adapter: ConfigurationAdapter? = null + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + + if (::proxyGroup.isInitialized) { + outState.putParcelable("proxyGroup", proxyGroup) + } + } + + override fun onViewStateRestored(savedInstanceState: Bundle?) { + super.onViewStateRestored(savedInstanceState) + + savedInstanceState?.getParcelable("proxyGroup")?.also { + proxyGroup = it + onViewCreated(requireView(), null) + } + } + + private val isEnabled: Boolean + get() { + return DataStore.serviceState.let { it.canStop || it == BaseService.State.Stopped } + } + + lateinit var layoutManager: LinearLayoutManager + lateinit var configurationListView: RecyclerView + + val select by lazy { + try { + (parentFragment as ConfigurationFragment).select + } catch (e: Exception) { + Logs.e(e) + false + } + } + val selectedItem by lazy { + try { + (parentFragment as ConfigurationFragment).selectedItem + } catch (e: Exception) { + Logs.e(e) + null + } + } + + override fun onResume() { + super.onResume() + + if (::configurationListView.isInitialized && configurationListView.size == 0) { + configurationListView.adapter = adapter + runOnDefaultDispatcher { + adapter?.reloadProfiles() + } + } else if (!::configurationListView.isInitialized) { + onViewCreated(requireView(), null) + } + checkOrderMenu() + configurationListView.requestFocus() + } + + fun checkOrderMenu() { + if (select) return + + val pf = requireParentFragment() as? ToolbarFragment ?: return + val menu = pf.toolbar.menu + val origin = menu.findItem(R.id.action_order_origin) + val byName = menu.findItem(R.id.action_order_by_name) + val byDelay = menu.findItem(R.id.action_order_by_delay) + when (proxyGroup.order) { + GroupOrder.ORIGIN -> { + origin.isChecked = true + } + GroupOrder.BY_NAME -> { + byName.isChecked = true + } + GroupOrder.BY_DELAY -> { + byDelay.isChecked = true + } + } + + fun updateTo(order: Int) { + if (proxyGroup.order == order) return + runOnDefaultDispatcher { + proxyGroup.order = order + GroupManager.updateGroup(proxyGroup) + } + } + + origin.setOnMenuItemClickListener { + it.isChecked = true + updateTo(GroupOrder.ORIGIN) + true + } + byName.setOnMenuItemClickListener { + it.isChecked = true + updateTo(GroupOrder.BY_NAME) + true + } + byDelay.setOnMenuItemClickListener { + it.isChecked = true + updateTo(GroupOrder.BY_DELAY) + true + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + if (!::proxyGroup.isInitialized) return + + configurationListView = view.findViewById(R.id.configuration_list) + layoutManager = FixedLinearLayoutManager(configurationListView) + configurationListView.layoutManager = layoutManager + adapter = ConfigurationAdapter() + ProfileManager.addListener(adapter!!) + GroupManager.addListener(adapter!!) + configurationListView.adapter = adapter + configurationListView.setItemViewCacheSize(20) + + if (!select) { + + undoManager = UndoSnackbarManager(activity as MainActivity, adapter!!) + + ItemTouchHelper(object : ItemTouchHelper.SimpleCallback( + ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.START + ) { + override fun getSwipeDirs( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + ): Int { + return 0 + } + + override fun getDragDirs( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + ) = if (isEnabled) super.getDragDirs(recyclerView, viewHolder) else 0 + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + } + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder, + ): Boolean { + adapter?.move( + viewHolder.bindingAdapterPosition, target.bindingAdapterPosition + ) + return true + } + + override fun clearView( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + ) { + super.clearView(recyclerView, viewHolder) + adapter?.commitMove() + } + }).attachToRecyclerView(configurationListView) + + } + + } + + override fun onDestroy() { + adapter?.let { + ProfileManager.removeListener(it) + GroupManager.removeListener(it) + } + + super.onDestroy() + + if (!::undoManager.isInitialized) return + undoManager.flush() + } + + inner class ConfigurationAdapter : RecyclerView.Adapter(), + ProfileManager.Listener, + GroupManager.Listener, + UndoSnackbarManager.Interface { + + init { + setHasStableIds(true) + } + + var configurationIdList: MutableList = mutableListOf() + val configurationList = HashMap() + + private fun getItem(profileId: Long): ProxyEntity { + var profile = configurationList[profileId] + if (profile == null) { + profile = ProfileManager.getProfile(profileId) + if (profile != null) { + configurationList[profileId] = profile + } + } + return profile!! + } + + private fun getItemAt(index: Int) = getItem(configurationIdList[index]) + + 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 configurationIdList[position] + } + + override fun onBindViewHolder(holder: ConfigurationHolder, position: Int) { + try { + holder.bind(getItemAt(position)) + } catch (ignored: NullPointerException) { // when group deleted + } + } + + override fun getItemCount(): Int { + return configurationIdList.size + } + + private val updated = HashSet() + + fun filter(name: String) { + if (name.isEmpty()) { + reloadProfiles() + return + } + configurationIdList.clear() + val lower = name.lowercase() + configurationIdList.addAll(configurationList.filter { + it.value.displayName().lowercase().contains(lower) || + it.value.displayType().lowercase().contains(lower) || + it.value.displayAddress().lowercase().contains(lower) + }.keys) + notifyDataSetChanged() + } + + fun move(from: Int, to: Int) { + val first = getItemAt(from) + var previousOrder = first.userOrder + val (step, range) = if (from < to) Pair(1, from until to) else Pair( + -1, to + 1 downTo from + ) + for (i in range) { + val next = getItemAt(i + step) + val order = next.userOrder + next.userOrder = previousOrder + previousOrder = order + configurationIdList[i] = next.id + updated.add(next) + } + first.userOrder = previousOrder + configurationIdList[to] = first.id + updated.add(first) + notifyItemMoved(from, to) + } + + fun commitMove() = runOnDefaultDispatcher { + updated.forEach { SagerDatabase.proxyDao.updateProxy(it) } + updated.clear() + } + + fun remove(pos: Int) { + if (pos < 0) return + configurationIdList.removeAt(pos) + notifyItemRemoved(pos) + } + + override fun undo(actions: List>) { + for ((index, item) in actions) { + configurationListView.post { + configurationList[item.id] = item + configurationIdList.add(index, item.id) + notifyItemInserted(index) + } + } + } + + override fun commit(actions: List>) { + val profiles = actions.map { it.second } + runOnDefaultDispatcher { + for (entity in profiles) { + ProfileManager.deleteProfile(entity.groupId, entity.id) + } + } + } + + override suspend fun onAdd(profile: ProxyEntity) { + if (profile.groupId != proxyGroup.id) return + + configurationListView.post { + if (::undoManager.isInitialized) { + undoManager.flush() + } + val pos = itemCount + configurationList[profile.id] = profile + configurationIdList.add(profile.id) + notifyItemInserted(pos) + } + } + + override suspend fun onUpdated(profile: ProxyEntity) { + if (profile.groupId != proxyGroup.id) return + val index = configurationIdList.indexOf(profile.id) + if (index < 0) return + configurationListView.post { + if (::undoManager.isInitialized) { + undoManager.flush() + } + val oldProfile = configurationList[profile.id] + configurationList[profile.id] = profile + notifyItemChanged(index) + } + } + + override suspend fun onUpdated(data: TrafficData) { + val index = configurationIdList.indexOf(data.id) + if (index != -1) { + val holder = layoutManager.findViewByPosition(index) + ?.let { configurationListView.getChildViewHolder(it) } as ConfigurationHolder? + if (holder != null) { + onMainDispatcher { + holder.bind(holder.entity, data) + } + } + } + } + + override suspend fun onRemoved(groupId: Long, profileId: Long) { + if (groupId != proxyGroup.id) return + val index = configurationIdList.indexOf(profileId) + if (index < 0) return + + configurationListView.post { + configurationIdList.removeAt(index) + configurationList.remove(profileId) + notifyItemRemoved(index) + } + } + + override suspend fun groupAdd(group: ProxyGroup) = Unit + override suspend fun groupRemoved(groupId: Long) = Unit + + override suspend fun groupUpdated(group: ProxyGroup) { + if (group.id != proxyGroup.id) return + proxyGroup = group + reloadProfiles() + } + + override suspend fun groupUpdated(groupId: Long) { + if (groupId != proxyGroup.id) return + proxyGroup = SagerDatabase.groupDao.getById(groupId)!! + reloadProfiles() + } + + fun reloadProfiles() { + var newProfiles = SagerDatabase.proxyDao.getByGroup(proxyGroup.id) + val subscription = proxyGroup.subscription + when (proxyGroup.order) { + GroupOrder.BY_NAME -> { + newProfiles = newProfiles.sortedBy { it.displayName() } + + } + GroupOrder.BY_DELAY -> { + newProfiles = + newProfiles.sortedBy { if (it.status == 1) it.ping else 114514 } + } + } + + configurationList.clear() + configurationList.putAll(newProfiles.associateBy { it.id }) + val newProfileIds = newProfiles.map { it.id } + + var selectedProfileIndex = -1 + + if (selected) { + val selectedProxy = selectedItem?.id ?: DataStore.selectedProxy + selectedProfileIndex = newProfileIds.indexOf(selectedProxy) + } + + configurationListView.post { + configurationIdList.clear() + configurationIdList.addAll(newProfileIds) + notifyDataSetChanged() + + if (selectedProfileIndex != -1) { + configurationListView.scrollTo(selectedProfileIndex, true) + } else if (newProfiles.isNotEmpty()) { + configurationListView.scrollTo(0, true) + } + + } + } + + } + + val profileAccess = Mutex() + val reloadAccess = Mutex() + + inner class ConfigurationHolder(val view: View) : RecyclerView.ViewHolder(view), + PopupMenu.OnMenuItemClickListener { + + lateinit var entity: ProxyEntity + + val profileName: TextView = view.findViewById(R.id.profile_name) + val profileType: TextView = view.findViewById(R.id.profile_type) + val profileAddress: TextView = view.findViewById(R.id.profile_address) + val profileStatus: TextView = view.findViewById(R.id.profile_status) + + 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) + val shareLayout: LinearLayout = view.findViewById(R.id.share) + val shareLayer: LinearLayout = view.findViewById(R.id.share_layer) + val shareButton: ImageView = view.findViewById(R.id.shareIcon) + val removeButton: ImageView = view.findViewById(R.id.remove) + + fun bind(proxyEntity: ProxyEntity, trafficData: TrafficData? = null) { + val pf = parentFragment as? ConfigurationFragment ?: return + + entity = proxyEntity + + if (select) { + view.setOnClickListener { + (requireActivity() as SelectCallback).returnProfile(proxyEntity.id) + } + } else { + view.setOnClickListener { + runOnDefaultDispatcher { + var update: Boolean + var lastSelected: Long + profileAccess.withLock { + update = DataStore.selectedProxy != proxyEntity.id + lastSelected = DataStore.selectedProxy + DataStore.selectedProxy = proxyEntity.id + onMainDispatcher { + selectedView.visibility = View.VISIBLE + } + } + + if (update) { + ProfileManager.postUpdate(lastSelected) + if (DataStore.serviceState.canStop && reloadAccess.tryLock()) { + SagerNet.reloadService() + reloadAccess.unlock() + } + } else if (SagerNet.isTv) { + if (DataStore.serviceState.started) { + SagerNet.stopService() + } else { + SagerNet.startService() + } + } + } + + } + } + + profileName.text = proxyEntity.displayName() + profileType.text = proxyEntity.displayType() + profileType.setTextColor(requireContext().getProtocolColor(proxyEntity.type)) + + var rx = proxyEntity.rx + var tx = proxyEntity.tx + if (trafficData != null) { + // use new data + tx = trafficData.tx + rx = trafficData.rx + } + + val showTraffic = rx + tx != 0L + trafficText.isVisible = showTraffic + if (showTraffic) { + trafficText.text = view.context.getString( + R.string.traffic, + Formatter.formatFileSize(view.context, tx), + Formatter.formatFileSize(view.context, rx) + ) + } + + var address = proxyEntity.displayAddress() + if (showTraffic && address.length >= 30) { + address = address.substring(0, 27) + "..." + } + + if (proxyEntity.requireBean().name.isBlank() || !pf.alwaysShowAddress) { + address = "" + } + + profileAddress.text = address + (trafficText.parent as View).isGone = + (!showTraffic || proxyEntity.status <= 0) && address.isBlank() + + if (proxyEntity.status <= 0) { + if (showTraffic) { + profileStatus.text = trafficText.text + profileStatus.setTextColor(requireContext().getColorAttr(android.R.attr.textColorSecondary)) + trafficText.text = "" + } else { + profileStatus.text = "" + } + } else if (proxyEntity.status == 1) { + profileStatus.text = getString(R.string.available, proxyEntity.ping) + profileStatus.setTextColor(requireContext().getColour(R.color.material_green_500)) + } else { + profileStatus.setTextColor(requireContext().getColour(R.color.material_red_500)) + if (proxyEntity.status == 2) { + profileStatus.text = proxyEntity.error + } + } + + if (proxyEntity.status == 3) { + val err = proxyEntity.error ?: "" + val msg = Protocols.genFriendlyMsg(err) + profileStatus.text = if (msg != err) msg else getString(R.string.unavailable) + profileStatus.setOnClickListener { + alert(err).show() + } + } else { + profileStatus.setOnClickListener(null) + } + + editButton.setOnClickListener { + it.context.startActivity( + proxyEntity.settingIntent( + it.context, proxyGroup.type == GroupType.SUBSCRIPTION + ) + ) + } + + removeButton.setOnClickListener { + adapter?.let { + val index = it.configurationIdList.indexOf(proxyEntity.id) + it.remove(index) + undoManager.remove(index to proxyEntity) + } + } + + val selectOrChain = select || proxyEntity.type == ProxyEntity.TYPE_CHAIN + shareLayout.isGone = selectOrChain + editButton.isGone = select + removeButton.isGone = select + + proxyEntity.nekoBean?.apply { + shareLayout.isGone = !canShare() + } + + runOnDefaultDispatcher { + val selected = (selectedItem?.id ?: DataStore.selectedProxy) == proxyEntity.id + val started = + selected && DataStore.serviceState.started && DataStore.currentProfile == proxyEntity.id + onMainDispatcher { + editButton.isEnabled = !started + removeButton.isEnabled = !started + selectedView.visibility = if (selected) View.VISIBLE else View.INVISIBLE + } + + fun showShare(anchor: View) { + val popup = PopupMenu(requireContext(), anchor) + popup.menuInflater.inflate(R.menu.profile_share_menu, popup.menu) + + if (proxyEntity.vmessBean == null || proxyEntity.vmessBean!!.isVLESS) { + popup.menu.findItem(R.id.action_group_qr).subMenu.removeItem(R.id.action_v2rayn_qr) + popup.menu.findItem(R.id.action_group_clipboard).subMenu.removeItem(R.id.action_v2rayn_clipboard) + } + + when { + !proxyEntity.haveStandardLink() -> { + popup.menu.findItem(R.id.action_group_qr).subMenu.removeItem(R.id.action_standard_qr) + popup.menu.findItem(R.id.action_group_clipboard).subMenu.removeItem( + R.id.action_standard_clipboard + ) + } + !proxyEntity.haveLink() -> { + popup.menu.removeItem(R.id.action_group_qr) + popup.menu.removeItem(R.id.action_group_clipboard) + } + } + + if (proxyEntity.nekoBean != null) { + popup.menu.removeItem(R.id.action_group_configuration) + } + + popup.setOnMenuItemClickListener(this@ConfigurationHolder) + popup.show() + } + + if (!(select || proxyEntity.type == ProxyEntity.TYPE_CHAIN)) { + onMainDispatcher { + shareLayer.setBackgroundColor(Color.TRANSPARENT) + shareButton.setImageResource(R.drawable.ic_social_share) + shareButton.setColorFilter(Color.GRAY) + shareButton.isVisible = true + + shareLayout.setOnClickListener { + showShare(it) + } + } + } + } + + } + + var currentName = "" + fun showCode(link: String) { + QRCodeDialog(link, currentName).showAllowingStateLoss(parentFragmentManager) + } + + fun export(link: String) { + val success = SagerNet.trySetPrimaryClip(link) + (activity as MainActivity).snackbar(if (success) R.string.action_export_msg else R.string.action_export_err) + .show() + } + + override fun onMenuItemClick(item: MenuItem): Boolean { + try { + currentName = entity.displayName()!! + when (item.itemId) { + R.id.action_standard_qr -> showCode(entity.toLink()!!) + R.id.action_standard_clipboard -> export(entity.toLink()!!) + R.id.action_universal_qr -> showCode(entity.requireBean().toUniversalLink()) + R.id.action_universal_clipboard -> export( + entity.requireBean().toUniversalLink() + ) + R.id.action_v2rayn_qr -> showCode(entity.vmessBean!!.toV2rayN()) + R.id.action_v2rayn_clipboard -> export(entity.vmessBean!!.toV2rayN()) + R.id.action_config_export_clipboard -> export(entity.exportConfig().first) + R.id.action_config_export_file -> { + val cfg = entity.exportConfig() + DataStore.serverConfig = cfg.first + startFilesForResult( + (parentFragment as ConfigurationFragment).exportConfig, cfg.second + ) + } + } + } catch (e: Exception) { + Logs.w(e) + (activity as MainActivity).snackbar(e.readableMessage).show() + return true + } + return true + } + } + + } + + private val exportConfig = + registerForActivityResult(ActivityResultContracts.CreateDocument()) { data -> + if (data != null) { + runOnDefaultDispatcher { + try { + (requireActivity() as MainActivity).contentResolver.openOutputStream(data)!! + .bufferedWriter() + .use { + it.write(DataStore.serverConfig) + } + onMainDispatcher { + snackbar(getString(R.string.action_export_msg)).show() + } + } catch (e: Exception) { + Logs.w(e) + onMainDispatcher { + snackbar(e.readableMessage).show() + } + } + + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/DebugFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/DebugFragment.kt new file mode 100644 index 0000000..a5d33a3 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/DebugFragment.kt @@ -0,0 +1,28 @@ +package io.nekohasekai.sagernet.ui + +import android.os.Bundle +import android.view.View +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.databinding.LayoutDebugBinding +import io.nekohasekai.sagernet.ktx.snackbar + +class DebugFragment : NamedFragment(R.layout.layout_debug) { + + override fun name0() = "Debug" + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val binding = LayoutDebugBinding.bind(view) + + binding.debugCrash.setOnClickListener { + error("test crash") + } + binding.resetSettings.setOnClickListener { + DataStore.configurationStore.reset() + snackbar("Cleared").show() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/GroupFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/GroupFragment.kt new file mode 100644 index 0000000..355e87f --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/GroupFragment.kt @@ -0,0 +1,529 @@ +package io.nekohasekai.sagernet.ui + +import android.content.Intent +import android.os.Bundle +import android.text.format.Formatter +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.widget.PopupMenu +import androidx.appcompat.widget.Toolbar +import androidx.core.view.* +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import io.nekohasekai.sagernet.GroupType +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.SagerNet +import io.nekohasekai.sagernet.database.* +import io.nekohasekai.sagernet.databinding.LayoutGroupItemBinding +import io.nekohasekai.sagernet.fmt.toUniversalLink +import io.nekohasekai.sagernet.group.GroupUpdater +import io.nekohasekai.sagernet.ktx.* +import io.nekohasekai.sagernet.widget.ListHolderListener +import io.nekohasekai.sagernet.widget.QRCodeDialog +import io.nekohasekai.sagernet.widget.UndoSnackbarManager +import kotlinx.coroutines.delay +import moe.matsuri.nb4a.utils.Util +import moe.matsuri.nb4a.utils.toBytesString +import java.util.* + +class GroupFragment : ToolbarFragment(R.layout.layout_group), + Toolbar.OnMenuItemClickListener { + + lateinit var activity: MainActivity + lateinit var groupListView: RecyclerView + lateinit var layoutManager: LinearLayoutManager + lateinit var groupAdapter: GroupAdapter + lateinit var undoManager: UndoSnackbarManager + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + activity = requireActivity() as MainActivity + + ViewCompat.setOnApplyWindowInsetsListener(view, ListHolderListener) + toolbar.setTitle(R.string.menu_group) + toolbar.inflateMenu(R.menu.add_group_menu) + toolbar.setOnMenuItemClickListener(this) + + groupListView = view.findViewById(R.id.group_list) + layoutManager = FixedLinearLayoutManager(groupListView) + groupListView.layoutManager = layoutManager + groupAdapter = GroupAdapter() + GroupManager.addListener(groupAdapter) + groupListView.adapter = groupAdapter + + undoManager = UndoSnackbarManager(activity, groupAdapter) + + ItemTouchHelper(object : ItemTouchHelper.SimpleCallback( + ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.START + ) { + override fun getSwipeDirs( + recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder + ): Int { + val proxyGroup = (viewHolder as GroupHolder).proxyGroup + if (proxyGroup.ungrouped || proxyGroup.id in GroupUpdater.updating) { + return 0 + } + return super.getSwipeDirs(recyclerView, viewHolder) + } + + override fun getDragDirs( + recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder + ): Int { + val proxyGroup = (viewHolder as GroupHolder).proxyGroup + if (proxyGroup.ungrouped || proxyGroup.id in GroupUpdater.updating) { + return 0 + } + return super.getDragDirs(recyclerView, viewHolder) + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + val index = viewHolder.bindingAdapterPosition + groupAdapter.remove(index) + undoManager.remove(index to (viewHolder as GroupHolder).proxyGroup) + } + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder, + ): Boolean { + groupAdapter.move(viewHolder.bindingAdapterPosition, target.bindingAdapterPosition) + return true + } + + override fun clearView( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + ) { + super.clearView(recyclerView, viewHolder) + groupAdapter.commitMove() + } + }).attachToRecyclerView(groupListView) + + } + + override fun onMenuItemClick(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_new_group -> { + startActivity(Intent(context, GroupSettingsActivity::class.java)) + } + R.id.action_update_all -> { + MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.confirm) + .setMessage(R.string.update_all_subscription) + .setPositiveButton(R.string.yes) { _, _ -> + SagerDatabase.groupDao.allGroups() + .filter { it.type == GroupType.SUBSCRIPTION } + .forEach { + GroupUpdater.startUpdate(it, true) + } + } + .setNegativeButton(R.string.no, null) + .show() + } + } + return true + } + + private lateinit var selectedGroup: ProxyGroup + + private val exportProfiles = registerForActivityResult(ActivityResultContracts.CreateDocument()) { data -> + if (data != null) { + runOnDefaultDispatcher { + val profiles = SagerDatabase.proxyDao.getByGroup(selectedGroup.id) + val links = profiles.mapNotNull { it.toLink(compact = true) }.joinToString("\n") + try { + (requireActivity() as MainActivity).contentResolver.openOutputStream( + data + )!!.bufferedWriter().use { + it.write(links) + } + onMainDispatcher { + snackbar(getString(R.string.action_export_msg)).show() + } + } catch (e: Exception) { + Logs.w(e) + onMainDispatcher { + snackbar(e.readableMessage).show() + } + } + + } + } + } + + inner class GroupAdapter : RecyclerView.Adapter(), + GroupManager.Listener, + UndoSnackbarManager.Interface { + + val groupList = ArrayList() + + suspend fun reload() { + val groups = SagerDatabase.groupDao.allGroups().toMutableList() + if (groups.size > 1 && SagerDatabase.proxyDao.countByGroup(groups.find { it.ungrouped }!!.id) == 0L) groups.removeAll { it.ungrouped } + groupList.clear() + groupList.addAll(groups) + groupListView.post { + notifyDataSetChanged() + } + } + + init { + setHasStableIds(true) + + runOnDefaultDispatcher { + reload() + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GroupHolder { + return GroupHolder(LayoutGroupItemBinding.inflate(layoutInflater, parent, false)) + } + + override fun onBindViewHolder(holder: GroupHolder, position: Int) { + holder.bind(groupList[position]) + } + + override fun getItemCount(): Int { + return groupList.size + } + + override fun getItemId(position: Int): Long { + return groupList[position].id + } + + private val updated = HashSet() + + fun move(from: Int, to: Int) { + val first = groupList[from] + var previousOrder = first.userOrder + val (step, range) = if (from < to) Pair(1, from until to) else Pair( + -1, to + 1 downTo from + ) + for (i in range) { + val next = groupList[i + step] + val order = next.userOrder + next.userOrder = previousOrder + previousOrder = order + groupList[i] = next + updated.add(next) + } + first.userOrder = previousOrder + groupList[to] = first + updated.add(first) + notifyItemMoved(from, to) + } + + fun commitMove() = runOnDefaultDispatcher { + updated.forEach { SagerDatabase.groupDao.updateGroup(it) } + updated.clear() + } + + fun remove(index: Int) { + groupList.removeAt(index) + notifyItemRemoved(index) + } + + override fun undo(actions: List>) { + for ((index, item) in actions) { + groupList.add(index, item) + notifyItemInserted(index) + } + } + + override fun commit(actions: List>) { + val groups = actions.map { it.second } + runOnDefaultDispatcher { + GroupManager.deleteGroup(groups) + reload() + } + } + + override suspend fun groupAdd(group: ProxyGroup) { + groupList.add(group) + delay(300L) + + onMainDispatcher { + undoManager.flush() + notifyItemInserted(groupList.size - 1) + + if (group.type == GroupType.SUBSCRIPTION) { + GroupUpdater.startUpdate(group, true) + } + } + } + + override suspend fun groupRemoved(groupId: Long) { + val index = groupList.indexOfFirst { it.id == groupId } + if (index == -1) return + onMainDispatcher { + undoManager.flush() + if (SagerDatabase.groupDao.allGroups().size <= 2) { + runOnDefaultDispatcher { + reload() + } + } else { + groupList.removeAt(index) + notifyItemRemoved(index) + } + } + } + + override suspend fun groupUpdated(group: ProxyGroup) { + val index = groupList.indexOfFirst { it.id == group.id } + if (index == -1) { + reload() + return + } + groupList[index] = group + onMainDispatcher { + undoManager.flush() + + notifyItemChanged(index) + } + } + + override suspend fun groupUpdated(groupId: Long) { + val index = groupList.indexOfFirst { it.id == groupId } + if (index == -1) { + reload() + return + } + onMainDispatcher { + notifyItemChanged(index) + } + } + + } + + override fun onDestroy() { + if (::groupAdapter.isInitialized) { + GroupManager.removeListener(groupAdapter) + } + + super.onDestroy() + + if (!::undoManager.isInitialized) return + undoManager.flush() + } + + inner class GroupHolder(binding: LayoutGroupItemBinding) : RecyclerView.ViewHolder(binding.root), + PopupMenu.OnMenuItemClickListener { + + lateinit var proxyGroup: ProxyGroup + val groupName = binding.groupName + val groupStatus = binding.groupStatus + val groupTraffic = binding.groupTraffic + val groupUser = binding.groupUser + val editButton = binding.edit + val optionsButton = binding.options + val updateButton = binding.groupUpdate + val subscriptionUpdateProgress = binding.subscriptionUpdateProgress + + override fun onMenuItemClick(item: MenuItem): Boolean { + + fun export(link: String) { + val success = SagerNet.trySetPrimaryClip(link) + activity.snackbar(if (success) R.string.action_export_msg else R.string.action_export_err) + .show() + } + + when (item.itemId) { + R.id.action_universal_qr -> { + QRCodeDialog( + proxyGroup.toUniversalLink(), proxyGroup.displayName() + ).showAllowingStateLoss(parentFragmentManager) + } + R.id.action_universal_clipboard -> { + export(proxyGroup.toUniversalLink()) + } + R.id.action_export_clipboard -> { + runOnDefaultDispatcher { + val profiles = SagerDatabase.proxyDao.getByGroup(selectedGroup.id) + val links = profiles.mapNotNull { it.toLink(compact = true) } + .joinToString("\n") + onMainDispatcher { + SagerNet.trySetPrimaryClip(links) + snackbar(getString(R.string.copy_toast_msg)).show() + } + } + } + R.id.action_export_file -> { + startFilesForResult(exportProfiles, "profiles_${proxyGroup.displayName()}.txt") + } + R.id.action_clear -> { + MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.confirm) + .setMessage(R.string.clear_profiles_message) + .setPositiveButton(R.string.yes) { _, _ -> + runOnDefaultDispatcher { + GroupManager.clearGroup(proxyGroup.id) + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + } + + return true + } + + + fun bind(group: ProxyGroup) { + proxyGroup = group + + itemView.setOnClickListener { } + + editButton.isGone = proxyGroup.ungrouped + updateButton.isInvisible = proxyGroup.type != GroupType.SUBSCRIPTION + groupName.text = proxyGroup.displayName() + + editButton.setOnClickListener { + startActivity(Intent(it.context, GroupSettingsActivity::class.java).apply { + putExtra(GroupSettingsActivity.EXTRA_GROUP_ID, group.id) + }) + } + + updateButton.setOnClickListener { + GroupUpdater.startUpdate(proxyGroup, true) + } + + optionsButton.setOnClickListener { + selectedGroup = proxyGroup + + val popup = PopupMenu(requireContext(), it) + popup.menuInflater.inflate(R.menu.group_action_menu, popup.menu) + + if (proxyGroup.type != GroupType.SUBSCRIPTION) { + popup.menu.removeItem(R.id.action_share) + } + popup.setOnMenuItemClickListener(this) + popup.show() + } + + if (proxyGroup.id in GroupUpdater.updating) { + (groupName.parent as LinearLayout).apply { + setPadding(paddingLeft, dp2px(11), paddingRight, paddingBottom) + } + + subscriptionUpdateProgress.isVisible = true + + if (!GroupUpdater.progress.containsKey(proxyGroup.id)) { + subscriptionUpdateProgress.isIndeterminate = true + } else { + subscriptionUpdateProgress.isIndeterminate = false + val progress = GroupUpdater.progress[proxyGroup.id]!! + subscriptionUpdateProgress.max = progress.max + subscriptionUpdateProgress.progress = progress.progress + } + + updateButton.isInvisible = true + editButton.isGone = true + } else { + (groupName.parent as LinearLayout).apply { + setPadding(paddingLeft, dp2px(15), paddingRight, paddingBottom) + } + + subscriptionUpdateProgress.isVisible = false + updateButton.isInvisible = proxyGroup.type != GroupType.SUBSCRIPTION + editButton.isGone = proxyGroup.ungrouped + } + + val subscription = proxyGroup.subscription + if (subscription != null && subscription.bytesUsed > 0L) { // SIP008 & Open Online Config + groupTraffic.isVisible = true + groupTraffic.text = if (subscription.bytesRemaining > 0L) { + getString( + R.string.subscription_traffic, Formatter.formatFileSize( + context, subscription.bytesUsed + ), Formatter.formatFileSize( + context, subscription.bytesRemaining + ) + ) + } else { + getString( + R.string.subscription_used, Formatter.formatFileSize( + context, subscription.bytesUsed + ) + ) + } + groupStatus.setPadding(0) + } else if (subscription != null && !subscription.subscriptionUserinfo.isNullOrBlank()) { // Raw + var text = ""; + + fun get(regex: String): String? { + return regex.toRegex().findAll(subscription.subscriptionUserinfo).mapNotNull { + if (it.groupValues.size > 1) it.groupValues[1] else null + }.firstOrNull(); + } + + var used: Long = 0 + get("upload=([0-9]+)")?.apply { + used += toLong() + } + get("download=([0-9]+)")?.apply { + used += toLong() + } + val total = get("total=([0-9]+)")?.toLong() ?: 0 + if (used > 0 || total > 0) { + text += getString( + R.string.subscription_traffic, + used.toBytesString(), + (total - used).toBytesString() + ) + } + get("expire=([0-9]+)")?.apply { + text += "\n" + text += getString( + R.string.subscription_expire, + Util.timeStamp2Text(this.toLong() * 1000) + ) + } + + if (text.isNotEmpty()) { + groupTraffic.isVisible = true + groupTraffic.text = text; + groupStatus.setPadding(0) + } + } else { + groupTraffic.isVisible = false + groupStatus.setPadding(0, 0, 0, dp2px(4)) + } + + groupUser.text = subscription?.username ?: "" + + runOnDefaultDispatcher { + val size = SagerDatabase.proxyDao.countByGroup(group.id) + onMainDispatcher { + @Suppress("DEPRECATION") when (group.type) { + GroupType.BASIC -> { + if (size == 0L) { + groupStatus.setText(R.string.group_status_empty) + } else { + groupStatus.text = getString(R.string.group_status_proxies, size) + } + } + GroupType.SUBSCRIPTION -> { + groupStatus.text = if (size == 0L) { + getString(R.string.group_status_empty_subscription) + } else { + val date = Date(group.subscription!!.lastUpdated * 1000L) + getString( + R.string.group_status_proxies_subscription, + size, + "${date.month + 1} - ${date.date}" + ) + } + + } + } + } + + } + + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/GroupSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/GroupSettingsActivity.kt new file mode 100644 index 0000000..7773abc --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/GroupSettingsActivity.kt @@ -0,0 +1,315 @@ +package io.nekohasekai.sagernet.ui + +import android.annotation.SuppressLint +import android.content.DialogInterface +import android.os.Bundle +import android.os.Parcelable +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.annotation.LayoutRes +import androidx.appcompat.app.AlertDialog +import androidx.core.view.ViewCompat +import androidx.preference.* +import com.github.shadowsocks.plugin.Empty +import com.github.shadowsocks.plugin.fragment.AlertDialogFragment +import com.takisoft.preferencex.PreferenceFragmentCompat +import com.takisoft.preferencex.SimpleMenuPreference +import io.nekohasekai.sagernet.GroupType +import io.nekohasekai.sagernet.Key +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.* +import io.nekohasekai.sagernet.database.preference.OnPreferenceDataStoreChangeListener +import io.nekohasekai.sagernet.ktx.applyDefaultValues +import io.nekohasekai.sagernet.ktx.onMainDispatcher +import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher +import io.nekohasekai.sagernet.widget.ListListener +import io.nekohasekai.sagernet.widget.UserAgentPreference +import kotlinx.parcelize.Parcelize + +@Suppress("UNCHECKED_CAST") +class GroupSettingsActivity( + @LayoutRes resId: Int = R.layout.layout_config_settings, +) : ThemedActivity(resId), + OnPreferenceDataStoreChangeListener { + + fun ProxyGroup.init() { + DataStore.groupName = name ?: "" + DataStore.groupType = type + DataStore.groupOrder = order + val subscription = subscription ?: SubscriptionBean().applyDefaultValues() + DataStore.subscriptionLink = subscription.link + DataStore.subscriptionForceResolve = subscription.forceResolve + DataStore.subscriptionDeduplication = subscription.deduplication + DataStore.subscriptionUpdateWhenConnectedOnly = subscription.updateWhenConnectedOnly + DataStore.subscriptionUserAgent = subscription.customUserAgent + DataStore.subscriptionAutoUpdate = subscription.autoUpdate + DataStore.subscriptionAutoUpdateDelay = subscription.autoUpdateDelay + } + + fun ProxyGroup.serialize() { + name = DataStore.groupName.takeIf { it.isNotBlank() } ?: "My group" + type = DataStore.groupType + order = DataStore.groupOrder + + val isSubscription = type == GroupType.SUBSCRIPTION + if (isSubscription) { + subscription = (subscription ?: SubscriptionBean().applyDefaultValues()).apply { + link = DataStore.subscriptionLink + forceResolve = DataStore.subscriptionForceResolve + deduplication = DataStore.subscriptionDeduplication + updateWhenConnectedOnly = DataStore.subscriptionUpdateWhenConnectedOnly + customUserAgent = DataStore.subscriptionUserAgent + autoUpdate = DataStore.subscriptionAutoUpdate + autoUpdateDelay = DataStore.subscriptionAutoUpdateDelay + } + } + } + + fun needSave(): Boolean { + if (!DataStore.dirty) return false + return true + } + + fun PreferenceFragmentCompat.createPreferences( + savedInstanceState: Bundle?, + rootKey: String?, + ) { + addPreferencesFromResource(R.xml.group_preferences) + + val groupType = findPreference(Key.GROUP_TYPE)!! + val groupSubscription = findPreference(Key.GROUP_SUBSCRIPTION)!! + val subscriptionUpdate = findPreference(Key.SUBSCRIPTION_UPDATE)!! + + fun updateGroupType(groupType: Int = DataStore.groupType) { + val isSubscription = groupType == GroupType.SUBSCRIPTION + groupSubscription.isVisible = isSubscription + subscriptionUpdate.isVisible = isSubscription + } + updateGroupType() + groupType.setOnPreferenceChangeListener { _, newValue -> + updateGroupType((newValue as String).toInt()) + true + } + + val subscriptionUserAgent = + findPreference(Key.SUBSCRIPTION_USER_AGENT)!! + val subscriptionAutoUpdate = + findPreference(Key.SUBSCRIPTION_AUTO_UPDATE)!! + val subscriptionAutoUpdateDelay = + findPreference(Key.SUBSCRIPTION_AUTO_UPDATE_DELAY)!! + + subscriptionAutoUpdateDelay.isEnabled = subscriptionAutoUpdate.isChecked + subscriptionAutoUpdateDelay.setOnPreferenceChangeListener { _, newValue -> + val delay = (newValue as String).toIntOrNull() + if (delay == null) { + false + } else { + delay >= 15 + } + } + subscriptionAutoUpdate.setOnPreferenceChangeListener { _, newValue -> + subscriptionAutoUpdateDelay.isEnabled = (newValue as Boolean) + true + } + } + + fun PreferenceFragmentCompat.viewCreated(view: View, savedInstanceState: Bundle?) { + } + + fun PreferenceFragmentCompat.displayPreferenceDialog(preference: Preference): Boolean { + return false + } + + class UnsavedChangesDialogFragment : AlertDialogFragment() { + override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) { + setTitle(R.string.unsaved_changes_prompt) + setPositiveButton(R.string.yes) { _, _ -> + runOnDefaultDispatcher { + (requireActivity() as GroupSettingsActivity).saveAndExit() + } + } + setNegativeButton(R.string.no) { _, _ -> + requireActivity().finish() + } + setNeutralButton(android.R.string.cancel, null) + } + } + + @Parcelize + data class GroupIdArg(val groupId: Long) : Parcelable + class DeleteConfirmationDialogFragment : AlertDialogFragment() { + override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) { + setTitle(R.string.delete_group_prompt) + setPositiveButton(R.string.yes) { _, _ -> + runOnDefaultDispatcher { + GroupManager.deleteGroup(arg.groupId) + } + requireActivity().finish() + } + setNegativeButton(R.string.no, null) + } + } + + companion object { + const val EXTRA_GROUP_ID = "id" + } + + @SuppressLint("CommitTransaction") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setSupportActionBar(findViewById(R.id.toolbar)) + supportActionBar?.apply { + setTitle(R.string.group_settings) + setDisplayHomeAsUpEnabled(true) + setHomeAsUpIndicator(R.drawable.ic_navigation_close) + } + + if (savedInstanceState == null) { + val editingId = intent.getLongExtra(EXTRA_GROUP_ID, 0L) + DataStore.editingId = editingId + runOnDefaultDispatcher { + if (editingId == 0L) { + ProxyGroup().init() + } else { + val entity = SagerDatabase.groupDao.getById(editingId) + if (entity == null) { + onMainDispatcher { + finish() + } + return@runOnDefaultDispatcher + } + entity.init() + } + + onMainDispatcher { + supportFragmentManager.beginTransaction() + .replace(R.id.settings, MyPreferenceFragmentCompat().apply { + activity = this@GroupSettingsActivity + }) + .commit() + + DataStore.dirty = false + DataStore.profileCacheStore.registerChangeListener(this@GroupSettingsActivity) + } + } + + } + + } + + suspend fun saveAndExit() { + + val editingId = DataStore.editingId + if (editingId == 0L) { + GroupManager.createGroup(ProxyGroup().apply { serialize() }) + } else if (needSave()) { + val entity = SagerDatabase.groupDao.getById(DataStore.editingId) + if (entity == null) { + finish() + return + } + entity.subscription?.subscriptionUserinfo = ""; + GroupManager.updateGroup(entity.apply { serialize() }) + } + + finish() + + } + + val child by lazy { supportFragmentManager.findFragmentById(R.id.settings) as MyPreferenceFragmentCompat } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.profile_config_menu, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem) = child.onOptionsItemSelected(item) + + override fun onBackPressed() { + if (needSave()) { + UnsavedChangesDialogFragment().apply { key() }.show(supportFragmentManager, null) + } else super.onBackPressed() + } + + override fun onSupportNavigateUp(): Boolean { + if (!super.onSupportNavigateUp()) finish() + return true + } + + override fun onDestroy() { + DataStore.profileCacheStore.unregisterChangeListener(this) + super.onDestroy() + } + + override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) { + if (key != Key.PROFILE_DIRTY) { + DataStore.dirty = true + } + } + + class MyPreferenceFragmentCompat : PreferenceFragmentCompat() { + + lateinit var activity: GroupSettingsActivity + + override fun onCreatePreferencesFix(savedInstanceState: Bundle?, rootKey: String?) { + preferenceManager.preferenceDataStore = DataStore.profileCacheStore + activity.apply { + createPreferences(savedInstanceState, rootKey) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + ViewCompat.setOnApplyWindowInsetsListener(listView, ListListener) + + activity.apply { + viewCreated(view, savedInstanceState) + } + } + + override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { + R.id.action_delete -> { + if (DataStore.editingId == 0L) { + requireActivity().finish() + } else { + DeleteConfirmationDialogFragment().apply { + arg(GroupIdArg(DataStore.editingId)) + key() + }.show(parentFragmentManager, null) + } + true + } + R.id.action_apply -> { + runOnDefaultDispatcher { + activity.saveAndExit() + } + true + } + else -> false + } + + override fun onDisplayPreferenceDialog(preference: Preference) { + activity.apply { + if (displayPreferenceDialog(preference)) return + } + super.onDisplayPreferenceDialog(preference) + } + + } + + object PasswordSummaryProvider : Preference.SummaryProvider { + + override fun provideSummary(preference: EditTextPreference): CharSequence { + val text = preference.text + return if (text.isNullOrBlank()) { + preference.context.getString(androidx.preference.R.string.not_set) + } else { + "\u2022".repeat(text.length) + } + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/LogcatFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/LogcatFragment.kt new file mode 100644 index 0000000..18b1583 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/LogcatFragment.kt @@ -0,0 +1,127 @@ +package io.nekohasekai.sagernet.ui + +import android.annotation.SuppressLint +import android.graphics.Color +import android.os.Build +import android.os.Bundle +import android.text.SpannableString +import android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE +import android.text.style.ForegroundColorSpan +import android.view.MenuItem +import android.view.View +import android.widget.ScrollView +import androidx.appcompat.widget.Toolbar +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.databinding.LayoutLogcatBinding +import io.nekohasekai.sagernet.ktx.* +import libcore.Libcore +import moe.matsuri.nb4a.utils.SendLog + +class LogcatFragment : ToolbarFragment(R.layout.layout_logcat), + Toolbar.OnMenuItemClickListener { + + lateinit var binding: LayoutLogcatBinding + + @SuppressLint("RestrictedApi", "WrongConstant") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + toolbar.setTitle(R.string.menu_log) + + toolbar.inflateMenu(R.menu.logcat_menu) + toolbar.setOnMenuItemClickListener(this) + + binding = LayoutLogcatBinding.bind(view) + + if (Build.VERSION.SDK_INT >= 23) { + binding.textview.breakStrategy = 0 // simple + } + + reloadSession() + + DataStore.postLogListener = { + runOnMainDispatcher { + val color = getColorForLine(it) + val span = SpannableString(it) + span.setSpan(color, 0, it.length, SPAN_EXCLUSIVE_EXCLUSIVE) + binding.textview.append(span) + binding.scroolview.post { + binding.scroolview.fullScroll(ScrollView.FOCUS_DOWN) + } + } + } + } + + override fun onDestroy() { + DataStore.postLogListener = null + super.onDestroy() + } + + private fun getColorForLine(line: String): ForegroundColorSpan { + var color = ForegroundColorSpan(Color.GRAY) + when { + line.contains(" INFO[") || line.contains(" [Info]") -> { + color = ForegroundColorSpan((0xFF86C166).toInt()) + } + line.contains(" ERROR[") || line.contains(" [Error]") -> { + color = ForegroundColorSpan(Color.RED) + } + line.contains(" WARN[") || line.contains(" [Warning]") -> { + color = ForegroundColorSpan(Color.RED) + } + } + return color + } + + private fun reloadSession() { + val span = SpannableString( + String(SendLog.getNekoLog(50 * 1024)) + ) + var offset = 0 + for (line in span.lines()) { + val color = getColorForLine(line) + span.setSpan( + color, offset, offset + line.length, SPAN_EXCLUSIVE_EXCLUSIVE + ) + offset += line.length + 1 + } + binding.textview.text = span + + binding.scroolview.post { + binding.scroolview.fullScroll(ScrollView.FOCUS_DOWN) + } + } + + override fun onMenuItemClick(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_clear_logcat -> { + runOnDefaultDispatcher { + try { + Libcore.nekoLogClear() + Runtime.getRuntime().exec("/system/bin/logcat -c") + } catch (e: Exception) { + onMainDispatcher { + snackbar(e.readableMessage).show() + } + return@runOnDefaultDispatcher + } + onMainDispatcher { + binding.textview.text = "" + } + } + + } + R.id.action_send_logcat -> { + val context = requireContext() + runOnDefaultDispatcher { + SendLog.sendLog(context, "NB4A") + } + } + R.id.action_refresh -> { + reloadSession() + } + } + return true + } + +} \ 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 0000000..1358d0d --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt @@ -0,0 +1,490 @@ +package io.nekohasekai.sagernet.ui + +import android.annotation.SuppressLint +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.os.RemoteException +import android.view.KeyEvent +import android.view.MenuItem +import android.widget.Toast +import androidx.annotation.IdRes +import androidx.core.view.ViewCompat +import androidx.preference.PreferenceDataStore +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.navigation.NavigationView +import com.google.android.material.snackbar.Snackbar +import io.nekohasekai.sagernet.* +import io.nekohasekai.sagernet.aidl.ISagerNetService +import io.nekohasekai.sagernet.aidl.SpeedDisplayData +import io.nekohasekai.sagernet.aidl.TrafficData +import io.nekohasekai.sagernet.bg.BaseService +import io.nekohasekai.sagernet.bg.SagerConnection +import io.nekohasekai.sagernet.database.* +import io.nekohasekai.sagernet.database.preference.OnPreferenceDataStoreChangeListener +import io.nekohasekai.sagernet.databinding.LayoutMainBinding +import io.nekohasekai.sagernet.fmt.AbstractBean +import io.nekohasekai.sagernet.fmt.KryoConverters +import io.nekohasekai.sagernet.fmt.PluginEntry +import io.nekohasekai.sagernet.group.GroupInterfaceAdapter +import io.nekohasekai.sagernet.group.GroupUpdater +import io.nekohasekai.sagernet.ktx.* +import io.nekohasekai.sagernet.widget.ListHolderListener +import kotlinx.coroutines.launch +import moe.matsuri.nb4a.utils.Util +import java.util.* + +class MainActivity : ThemedActivity(), + SagerConnection.Callback, + OnPreferenceDataStoreChangeListener, + NavigationView.OnNavigationItemSelectedListener { + + lateinit var binding: LayoutMainBinding + lateinit var navigation: NavigationView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = LayoutMainBinding.inflate(layoutInflater) + binding.fab.initProgress(binding.fabProgress) + if (themeResId !in intArrayOf( + R.style.Theme_SagerNet_Black + ) + ) { + navigation = binding.navView + binding.drawerLayout.removeView(binding.navViewBlack) + } else { + navigation = binding.navViewBlack + binding.drawerLayout.removeView(binding.navView) + } + navigation.setNavigationItemSelectedListener(this) + + if (savedInstanceState == null) { + displayFragmentWithId(R.id.nav_configuration) + } + + binding.fab.setOnClickListener { + if (DataStore.serviceState.canStop) SagerNet.stopService() else connect.launch( + null + ) + } + binding.stats.setOnClickListener { if (DataStore.serviceState.connected) binding.stats.testConnection() } + + setContentView(binding.root) + ViewCompat.setOnApplyWindowInsetsListener(binding.coordinator, ListHolderListener) + changeState(BaseService.State.Idle) + connection.connect(this, this) + DataStore.configurationStore.registerChangeListener(this) + GroupManager.userInterface = GroupInterfaceAdapter(this) + + if (intent?.action == Intent.ACTION_VIEW) { + onNewIntent(intent) + } + + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + + val uri = intent.data ?: return + + runOnDefaultDispatcher { + if (uri.scheme == "sn" && uri.host == "subscription" || uri.scheme == "clash") { + importSubscription(uri) + } else { + importProfile(uri) + } + } + } + + fun urlTest(): Int { + if (!DataStore.serviceState.connected || connection.service == null) { + error("not started") + } + return connection.service!!.urlTest() + } + + suspend fun importSubscription(uri: Uri) { + val group: ProxyGroup + + val url = uri.getQueryParameter("url") + if (!url.isNullOrBlank()) { + group = ProxyGroup(type = GroupType.SUBSCRIPTION) + val subscription = SubscriptionBean() + group.subscription = subscription + + // cleartext format + subscription.link = url + group.name = uri.getQueryParameter("name") + } else { + val data = uri.encodedQuery.takeIf { !it.isNullOrBlank() } ?: return + try { + group = KryoConverters.deserialize( + ProxyGroup().apply { export = true }, Util.zlibDecompress(Util.b64Decode(data)) + ).apply { + export = false + } + } catch (e: Exception) { + onMainDispatcher { + alert(e.readableMessage).show() + } + return + } + } + + val name = group.name.takeIf { !it.isNullOrBlank() } ?: group.subscription?.link + ?: group.subscription?.token + if (name.isNullOrBlank()) return + + group.name = group.name.takeIf { !it.isNullOrBlank() } + ?: ("Subscription #" + System.currentTimeMillis()) + + onMainDispatcher { + + displayFragmentWithId(R.id.nav_group) + + MaterialAlertDialogBuilder(this@MainActivity).setTitle(R.string.subscription_import) + .setMessage(getString(R.string.subscription_import_message, name)) + .setPositiveButton(R.string.yes) { _, _ -> + runOnDefaultDispatcher { + finishImportSubscription(group) + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + + } + + } + + private suspend fun finishImportSubscription(subscription: ProxyGroup) { + GroupManager.createGroup(subscription) + GroupUpdater.startUpdate(subscription, true) + } + + suspend fun importProfile(uri: Uri) { + val profile = try { + parseProxies(uri.toString()).getOrNull(0) ?: error(getString(R.string.no_proxies_found)) + } catch (e: Exception) { + onMainDispatcher { + alert(e.readableMessage).show() + } + return + } + + onMainDispatcher { + MaterialAlertDialogBuilder(this@MainActivity).setTitle(R.string.profile_import) + .setMessage(getString(R.string.profile_import_message, profile.displayName())) + .setPositiveButton(R.string.yes) { _, _ -> + runOnDefaultDispatcher { + finishImportProfile(profile) + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + } + + private suspend fun finishImportProfile(profile: AbstractBean) { + val targetId = DataStore.selectedGroupForImport() + + ProfileManager.createProfile(targetId, profile) + + onMainDispatcher { + displayFragmentWithId(R.id.nav_configuration) + + snackbar(resources.getQuantityString(R.plurals.added, 1, 1)).show() + } + } + + override fun missingPlugin(profileName: String, pluginName: String) { + val pluginEntity = PluginEntry.find(pluginName) + + // unknown exe or neko plugin + if (pluginEntity == null) { + snackbar(getString(R.string.plugin_unknown, pluginName)).show() + return + } + + // official exe + + MaterialAlertDialogBuilder(this).setTitle(R.string.missing_plugin) + .setMessage( + getString( + R.string.profile_requiring_plugin, profileName, pluginEntity.displayName + ) + ) + .setPositiveButton(R.string.action_download) { _, _ -> + showDownloadDialog(pluginEntity) + } + .setNeutralButton(android.R.string.cancel, null) + .setNeutralButton(R.string.action_learn_more) { _, _ -> + launchCustomTab("https://matsuridayo.github.io/m-plugin/") + } + .show() + } + + private fun showDownloadDialog(pluginEntry: PluginEntry) { + var index = 0 + var playIndex = -1 + var fdroidIndex = -1 + + val items = mutableListOf() + if (pluginEntry.downloadSource.playStore) { + items.add(getString(R.string.install_from_play_store)) + playIndex = index++ + } + if (pluginEntry.downloadSource.fdroid) { + items.add(getString(R.string.install_from_fdroid)) + fdroidIndex = index++ + } + + items.add(getString(R.string.download)) + val downloadIndex = index + + MaterialAlertDialogBuilder(this).setTitle(pluginEntry.name) + .setItems(items.toTypedArray()) { _, which -> + when (which) { + playIndex -> launchCustomTab("https://play.google.com/store/apps/details?id=${pluginEntry.packageName}") + fdroidIndex -> launchCustomTab("https://f-droid.org/packages/${pluginEntry.packageName}/") + downloadIndex -> launchCustomTab(pluginEntry.downloadSource.downloadLink) + } + } + .show() + } + + override fun onNavigationItemSelected(item: MenuItem): Boolean { + if (item.isChecked) binding.drawerLayout.closeDrawers() else { + return displayFragmentWithId(item.itemId) + } + return true + } + + + @SuppressLint("CommitTransaction") + fun displayFragment(fragment: ToolbarFragment) { + if (fragment is ConfigurationFragment) { + binding.stats.allowShow = true + binding.fab.show() + } else if (!DataStore.showBottomBar) { + binding.stats.allowShow = false + binding.stats.performHide() + binding.fab.hide() + } + supportFragmentManager.beginTransaction() + .replace(R.id.fragment_holder, fragment) + .commitAllowingStateLoss() + binding.drawerLayout.closeDrawers() + } + + fun displayFragmentWithId(@IdRes id: Int): Boolean { + when (id) { + R.id.nav_configuration -> { + displayFragment(ConfigurationFragment()) + } + R.id.nav_group -> displayFragment(GroupFragment()) + R.id.nav_route -> displayFragment(RouteFragment()) + R.id.nav_settings -> displayFragment(SettingsFragment()) + R.id.nav_traffic -> displayFragment(WebviewFragment()) + R.id.nav_tools -> displayFragment(ToolsFragment()) + R.id.nav_logcat -> displayFragment(LogcatFragment()) + R.id.nav_faq -> { + launchCustomTab("https://matsuridayo.github.io/") + return false + } + R.id.nav_about -> displayFragment(AboutFragment()) + R.id.nav_tuiguang -> { + launchCustomTab("https://matsuricom.github.io/") + return false + } + else -> return false + } + navigation.menu.findItem(id).isChecked = true + return true + } + + @SuppressLint("CommitTransaction") + fun ruleCreated() { + navigation.menu.findItem(R.id.nav_route).isChecked = true + supportFragmentManager.beginTransaction() + .replace(R.id.fragment_holder, RouteFragment()) + .commitAllowingStateLoss() + if (DataStore.serviceState.started) { + snackbar(getString(R.string.restart)).setAction(R.string.apply) { + SagerNet.reloadService() + }.show() + } + } + + private fun changeState( + state: BaseService.State, + msg: String? = null, + animate: Boolean = false, + ) { + DataStore.serviceState = state + + binding.fab.changeState(state, DataStore.serviceState, animate) + binding.stats.changeState(state) + if (msg != null) snackbar(getString(R.string.vpn_error, msg)).show() + + when (state) { + BaseService.State.Stopped -> { + runOnDefaultDispatcher { + // refresh view + ProfileManager.postUpdate(DataStore.currentProfile) + } + } + else -> {} + } + } + + override fun snackbarInternal(text: CharSequence): Snackbar { + return Snackbar.make(binding.coordinator, text, Snackbar.LENGTH_LONG).apply { + if (binding.fab.isShown) { + anchorView = binding.fab + } + // TODO + } + } + + override fun stateChanged(state: BaseService.State, profileName: String?, msg: String?) { + changeState(state, msg, true) + } + + override fun routeAlert(type: Int, routeName: String) { + when (type) { + 0 -> { + // need vpn + + Toast.makeText( + this, getString(R.string.route_need_vpn, routeName), Toast.LENGTH_SHORT + ).show() + } + } + } + + val connection = SagerConnection(true) + override fun onServiceConnected(service: ISagerNetService) = 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(R.string.vpn_permission_denied).show() + } + + // may NOT called when app is in background + // ONLY do UI update here, write DB in bg process + override fun cbSpeedUpdate(stats: SpeedDisplayData) { + binding.stats.updateSpeed(stats.txRateProxy, stats.rxRateProxy) + } + + override fun cbTrafficUpdate(data: TrafficData) { + runOnDefaultDispatcher { + ProfileManager.postUpdate(data) + } + } + + override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) { + when (key) { + Key.SERVICE_MODE -> onBinderDied() + Key.PROXY_APPS, Key.BYPASS_MODE, Key.INDIVIDUAL -> { + if (DataStore.serviceState.canStop) { + snackbar(getString(R.string.restart)).setAction(R.string.apply) { + SagerNet.reloadService() + }.show() + } + } + } + } + + override fun onStart() { + super.onStart() + } + + override fun onStop() { + super.onStop() + } + + override fun onDestroy() { + super.onDestroy() + GroupManager.userInterface = null + DataStore.configurationStore.unregisterChangeListener(this) + connection.disconnect(this) + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + when (keyCode) { + KeyEvent.KEYCODE_DPAD_LEFT -> { + if (super.onKeyDown(keyCode, event)) return true + binding.drawerLayout.open() + navigation.requestFocus() + } + KeyEvent.KEYCODE_DPAD_RIGHT -> { + if (binding.drawerLayout.isOpen) { + binding.drawerLayout.close() + return true + } + } + } + + if (super.onKeyDown(keyCode, event)) return true + if (binding.drawerLayout.isOpen) return false + + val fragment = + supportFragmentManager.findFragmentById(R.id.fragment_holder) as? ToolbarFragment + return fragment != null && fragment.onKeyDown(keyCode, event) + } + + @SuppressLint("SimpleDateFormat") + override fun onResume() { + super.onResume() + + // TODO nb4a release + /* + val sdf = SimpleDateFormat("yyyy-MM-dd") + val now = System.currentTimeMillis() + val expire = Libcore.getExpireTime() * 1000 + val dateExpire = Date(expire) + val build = Libcore.getBuildTime() * 1000 + val dateBuild = Date(build) + + var text: String? = null + if (now > expire) { + text = getString( + R.string.please_update_force, sdf.format(dateBuild), sdf.format(dateExpire) + ) + } else if (now > (expire - 2592000000)) { + // 30 days remind :D + text = getString( + R.string.please_update, sdf.format(dateBuild), sdf.format(dateExpire) + ) + } + + + if (text != null) { + MaterialAlertDialogBuilder(this@MainActivity).setTitle(R.string.insecure) + .setMessage(text) + .setPositiveButton(R.string.action_download) { _, _ -> + launchCustomTab( + "https://github.com/MatsuriDayo/NekoBoxForAndroid/releases" + ) + } + .setNegativeButton(android.R.string.cancel, null) + .setCancelable(false) + .show() + } + */ + } + +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/NamedFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/NamedFragment.kt new file mode 100644 index 0000000..cecf547 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/NamedFragment.kt @@ -0,0 +1,14 @@ +package io.nekohasekai.sagernet.ui + +import androidx.fragment.app.Fragment + +abstract class NamedFragment : Fragment { + + constructor() : super() + constructor(contentLayoutId: Int) : super(contentLayoutId) + + private val name by lazy { name0() } + fun name() = name + protected abstract fun name0(): String + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/NetworkFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/NetworkFragment.kt new file mode 100644 index 0000000..6ef29c1 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/NetworkFragment.kt @@ -0,0 +1,84 @@ +package io.nekohasekai.sagernet.ui + +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.appcompat.app.AlertDialog +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.ProfileManager +import io.nekohasekai.sagernet.databinding.LayoutNetworkBinding +import io.nekohasekai.sagernet.databinding.LayoutProgressBinding +import io.nekohasekai.sagernet.ktx.* +import io.nekohasekai.sagernet.utils.Cloudflare +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.runBlocking + +class NetworkFragment : NamedFragment(R.layout.layout_network) { + + override fun name0() = app.getString(R.string.tools_network) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val binding = LayoutNetworkBinding.bind(view) + binding.stunTest.setOnClickListener { + startActivity(Intent(requireContext(), StunActivity::class.java)) + } + + //Markwon.create(requireContext()) + // .setMarkdown(binding.wrapLicense, getString(R.string.warp_license)) + + binding.warpGenerate.setOnClickListener { + runBlocking { + generateWarpConfiguration() + } + } + } + + suspend fun generateWarpConfiguration() { + val activity = requireActivity() as MainActivity + val binding = LayoutProgressBinding.inflate(layoutInflater).apply { + content.setText(R.string.generating) + } + var job: Job? = null + val dialog = AlertDialog.Builder(requireContext()) + .setView(binding.root) + .setCancelable(false) + .setNegativeButton(android.R.string.cancel) { _, _ -> + job?.cancel() + } + .show() + job = runOnDefaultDispatcher { + try { + val bean = Cloudflare.makeWireGuardConfiguration() + if (isActive) { + val groupId = DataStore.selectedGroupForImport() + if (DataStore.selectedGroup != groupId) { + DataStore.selectedGroup = groupId + } + onMainDispatcher { + activity.displayFragmentWithId(R.id.nav_configuration) + } + delay(1000L) + onMainDispatcher { + dialog.dismiss() + } + ProfileManager.createProfile(groupId, bean) + } + } catch (e: Exception) { + Logs.w(e) + onMainDispatcher { + if (isActive) { + dialog.dismiss() + activity.snackbar(e.readableMessage).show() + } + } + } + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ProfileSelectActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ProfileSelectActivity.kt new file mode 100644 index 0000000..20edf1a --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ProfileSelectActivity.kt @@ -0,0 +1,36 @@ +package io.nekohasekai.sagernet.ui + +import android.content.Intent +import android.os.Bundle +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.ProxyEntity + +class ProfileSelectActivity : ThemedActivity(R.layout.layout_empty), + ConfigurationFragment.SelectCallback { + + companion object { + const val EXTRA_SELECTED = "selected" + const val EXTRA_PROFILE_ID = "id" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val selected = intent.getParcelableExtra(EXTRA_SELECTED) + + supportFragmentManager.beginTransaction() + .replace( + R.id.fragment_holder, + ConfigurationFragment(true, selected, R.string.select_profile) + ) + .commitAllowingStateLoss() + } + + override fun returnProfile(profileId: Long) { + setResult(RESULT_OK, Intent().apply { + putExtra(EXTRA_PROFILE_ID, profileId) + }) + finish() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/RouteFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/RouteFragment.kt new file mode 100644 index 0000000..6fce4b0 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/RouteFragment.kt @@ -0,0 +1,310 @@ +package io.nekohasekai.sagernet.ui + +import android.content.Intent +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.Toolbar +import androidx.core.view.ViewCompat +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.ProfileManager +import io.nekohasekai.sagernet.database.RuleEntity +import io.nekohasekai.sagernet.database.SagerDatabase +import io.nekohasekai.sagernet.databinding.LayoutEmptyRouteBinding +import io.nekohasekai.sagernet.databinding.LayoutRouteItemBinding +import io.nekohasekai.sagernet.ktx.* +import io.nekohasekai.sagernet.widget.ListHolderListener +import io.nekohasekai.sagernet.widget.UndoSnackbarManager + +class RouteFragment : ToolbarFragment(R.layout.layout_route), Toolbar.OnMenuItemClickListener { + + lateinit var activity: MainActivity + lateinit var ruleListView: RecyclerView + lateinit var ruleAdapter: RuleAdapter + lateinit var undoManager: UndoSnackbarManager + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + activity = requireActivity() as MainActivity + + ViewCompat.setOnApplyWindowInsetsListener(view, ListHolderListener) + toolbar.setTitle(R.string.menu_route) + toolbar.inflateMenu(R.menu.add_route_menu) + toolbar.setOnMenuItemClickListener(this) + + ruleListView = view.findViewById(R.id.route_list) + ruleListView.layoutManager = FixedLinearLayoutManager(ruleListView) + ruleAdapter = RuleAdapter() + ProfileManager.addListener(ruleAdapter) + ruleListView.adapter = ruleAdapter + undoManager = UndoSnackbarManager(activity, ruleAdapter) + + ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.START) { + + override fun getSwipeDirs( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + ) = if (viewHolder is RuleAdapter.DocumentHolder) { + 0 + } else { + super.getSwipeDirs(recyclerView, viewHolder) + } + + override fun getDragDirs( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + ) = if (viewHolder is RuleAdapter.DocumentHolder) { + 0 + } else { + super.getDragDirs(recyclerView, viewHolder) + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + val index = viewHolder.bindingAdapterPosition + ruleAdapter.remove(index) + undoManager.remove(index to (viewHolder as RuleAdapter.RuleHolder).rule) + } + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder, + ): Boolean { + return if (target is RuleAdapter.DocumentHolder) { + false + } else { + ruleAdapter.move(viewHolder.bindingAdapterPosition, target.bindingAdapterPosition) + true + } + } + + override fun clearView( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + ) { + super.clearView(recyclerView, viewHolder) + ruleAdapter.commitMove() + } + }).attachToRecyclerView(ruleListView) + } + + override fun onDestroy() { + if (::ruleAdapter.isInitialized) { + ProfileManager.removeListener(ruleAdapter) + } + super.onDestroy() + } + + override fun onMenuItemClick(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_new_route -> { + startActivity(Intent(context, RouteSettingsActivity::class.java)) + } + R.id.action_reset_route -> { + MaterialAlertDialogBuilder(activity).setTitle(R.string.confirm) + .setMessage(R.string.clear_profiles_message) + .setPositiveButton(R.string.yes) { _, _ -> + runOnDefaultDispatcher { + SagerDatabase.rulesDao.reset() + DataStore.rulesFirstCreate = false + ruleAdapter.reload() + } + } + .setNegativeButton(R.string.no, null) + .show() + } + R.id.action_manage_assets -> { + startActivity(Intent(requireContext(), AssetsActivity::class.java)) + } + } + return true + } + + inner class RuleAdapter : RecyclerView.Adapter(), ProfileManager.RuleListener, UndoSnackbarManager.Interface { + + val ruleList = ArrayList() + suspend fun reload() { + val rules = ProfileManager.getRules() + ruleListView.post { + ruleList.clear() + ruleList.addAll(rules) + ruleAdapter.notifyDataSetChanged() + } + } + + init { + runOnDefaultDispatcher { + reload() + } + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): RecyclerView.ViewHolder { + return if (viewType == 0) { + DocumentHolder(LayoutEmptyRouteBinding.inflate(layoutInflater, parent, false)) + } else { + RuleHolder(LayoutRouteItemBinding.inflate(layoutInflater, parent, false)) + } + } + + override fun getItemViewType(position: Int): Int { + if (position == 0) return 0 + return 1 + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + if (holder is DocumentHolder) { + holder.bind() + } else if (holder is RuleHolder) { + holder.bind(ruleList[position - 1]) + } + } + + override fun getItemCount(): Int { + return ruleList.size + 1 + } + + override fun getItemId(position: Int): Long { + if (position == 0) return 0L + return ruleList[position - 1].id + } + + private val updated = HashSet() + fun move(from: Int, to: Int) { + val first = ruleList[from - 1] + var previousOrder = first.userOrder + val (step, range) = if (from < to) Pair(1, from - 1 until to - 1) else Pair(-1, to downTo from - 1) + for (i in range) { + val next = ruleList[i + step] + val order = next.userOrder + next.userOrder = previousOrder + previousOrder = order + ruleList[i] = next + updated.add(next) + } + first.userOrder = previousOrder + ruleList[to - 1] = first + updated.add(first) + notifyItemMoved(from, to) + } + + fun commitMove() = runOnDefaultDispatcher { + if (updated.isNotEmpty()) { + SagerDatabase.rulesDao.updateRules(updated.toList()) + updated.clear() + needReload() + } + } + + fun remove(index: Int) { + ruleList.removeAt(index - 1) + notifyItemRemoved(index) + } + + override fun undo(actions: List>) { + for ((index, item) in actions) { + ruleList.add(index - 1, item) + notifyItemInserted(index) + } + } + + override fun commit(actions: List>) { + val rules = actions.map { it.second } + runOnDefaultDispatcher { + ProfileManager.deleteRules(rules) + } + } + + override suspend fun onAdd(rule: RuleEntity) { + ruleListView.post { + ruleList.add(rule) + ruleAdapter.notifyItemInserted(ruleList.size) + needReload() + } + } + + override suspend fun onUpdated(rule: RuleEntity) { + val index = ruleList.indexOfFirst { it.id == rule.id } + if (index == -1) return + ruleListView.post { + ruleList[index] = rule + ruleAdapter.notifyItemChanged(index + 1) + needReload() + } + } + + override suspend fun onRemoved(ruleId: Long) { + val index = ruleList.indexOfFirst { it.id == ruleId } + if (index == -1) { + onMainDispatcher { + needReload() + } + } else ruleListView.post { + ruleList.removeAt(index) + ruleAdapter.notifyItemRemoved(index + 1) + needReload() + } + } + + override suspend fun onCleared() { + ruleListView.post { + ruleList.clear() + ruleAdapter.notifyDataSetChanged() + needReload() + } + } + + inner class DocumentHolder(binding: LayoutEmptyRouteBinding) : RecyclerView.ViewHolder(binding.root) { + fun bind() { + itemView.setOnClickListener { + it.context.launchCustomTab("https://sing-box.sagernet.org/configuration/route/rule/") + } + } + } + + inner class RuleHolder(binding: LayoutRouteItemBinding) : RecyclerView.ViewHolder(binding.root) { + + lateinit var rule: RuleEntity + val profileName = binding.profileName + val profileType = binding.profileType + val routeOutbound = binding.routeOutbound + val editButton = binding.edit + val shareLayout = binding.share + val enableSwitch = binding.enable + + fun bind(ruleEntity: RuleEntity) { + rule = ruleEntity + profileName.text = rule.displayName() + profileType.text = rule.mkSummary() + routeOutbound.text = rule.displayOutbound() + itemView.setOnClickListener { + enableSwitch.performClick() + } + enableSwitch.isChecked = rule.enabled + enableSwitch.setOnCheckedChangeListener { _, isChecked -> + runOnDefaultDispatcher { + rule.enabled = isChecked + SagerDatabase.rulesDao.updateRule(rule) + onMainDispatcher { + needReload() + } + } + } + editButton.setOnClickListener { + startActivity(Intent(it.context, RouteSettingsActivity::class.java).apply { + putExtra(RouteSettingsActivity.EXTRA_ROUTE_ID, rule.id) + }) + } + } + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/RouteSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/RouteSettingsActivity.kt new file mode 100644 index 0000000..433a65c --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/RouteSettingsActivity.kt @@ -0,0 +1,371 @@ +package io.nekohasekai.sagernet.ui + +import android.app.Activity +import android.content.DialogInterface +import android.content.Intent +import android.os.Bundle +import android.os.Parcelable +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.activity.result.component1 +import androidx.activity.result.component2 +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.LayoutRes +import androidx.appcompat.app.AlertDialog +import androidx.core.view.ViewCompat +import androidx.preference.EditTextPreference +import androidx.preference.Preference +import androidx.preference.PreferenceDataStore +import com.github.shadowsocks.plugin.Empty +import com.github.shadowsocks.plugin.fragment.AlertDialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.takisoft.preferencex.PreferenceFragmentCompat +import io.nekohasekai.sagernet.Key +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.ProfileManager +import io.nekohasekai.sagernet.database.RuleEntity +import io.nekohasekai.sagernet.database.SagerDatabase +import io.nekohasekai.sagernet.database.preference.OnPreferenceDataStoreChangeListener +import io.nekohasekai.sagernet.ktx.app +import io.nekohasekai.sagernet.ktx.onMainDispatcher +import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher +import io.nekohasekai.sagernet.utils.PackageCache +import io.nekohasekai.sagernet.widget.AppListPreference +import io.nekohasekai.sagernet.widget.ListListener +import io.nekohasekai.sagernet.widget.OutboundPreference +import kotlinx.parcelize.Parcelize + +@Suppress("UNCHECKED_CAST") +class RouteSettingsActivity( + @LayoutRes resId: Int = R.layout.layout_settings_activity, +) : ThemedActivity(resId), + OnPreferenceDataStoreChangeListener { + + fun init(packageName: String?) { + RuleEntity().apply { + if (!packageName.isNullOrBlank()) { + packages = listOf(packageName) + name = app.getString(R.string.route_for, PackageCache.loadLabel(packageName)) + } + }.init() + } + + fun RuleEntity.init() { + DataStore.routeName = name + DataStore.routeDomain = domains + DataStore.routeIP = ip + DataStore.routePort = port + DataStore.routeSourcePort = sourcePort + DataStore.routeNetwork = network + DataStore.routeSource = source + DataStore.routeProtocol = protocol + DataStore.routeOutboundRule = outbound + DataStore.routeOutbound = when (outbound) { + 0L -> 0 + -1L -> 1 + -2L -> 2 + else -> 3 + } + DataStore.routePackages = packages.joinToString("\n") + } + + fun RuleEntity.serialize() { + name = DataStore.routeName + domains = DataStore.routeDomain + ip = DataStore.routeIP + port = DataStore.routePort + sourcePort = DataStore.routeSourcePort + network = DataStore.routeNetwork + source = DataStore.routeSource + protocol = DataStore.routeProtocol + outbound = when (DataStore.routeOutbound) { + 0 -> 0L + 1 -> -1L + 2 -> -2L + else -> DataStore.routeOutboundRule + } + packages = DataStore.routePackages.split("\n").filter { it.isNotBlank() } + + if (DataStore.editingId == 0L) { + enabled = true + } + } + + fun needSave(): Boolean { + if (!DataStore.dirty) return false + if (DataStore.routePackages.isBlank() && DataStore.routeDomain.isBlank() && DataStore.routeIP.isBlank() && DataStore.routePort.isBlank() && DataStore.routeSourcePort.isBlank() && DataStore.routeNetwork.isBlank() && DataStore.routeSource.isBlank() && DataStore.routeProtocol.isBlank()) { + return false + } + return true + } + + fun PreferenceFragmentCompat.createPreferences( + savedInstanceState: Bundle?, + rootKey: String?, + ) { + addPreferencesFromResource(R.xml.route_preferences) + } + + val selectProfileForAdd = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { (resultCode, data) -> + if (resultCode == Activity.RESULT_OK) runOnDefaultDispatcher { + val profile = ProfileManager.getProfile( + data!!.getLongExtra( + ProfileSelectActivity.EXTRA_PROFILE_ID, 0 + ) + ) ?: return@runOnDefaultDispatcher + DataStore.routeOutboundRule = profile.id + onMainDispatcher { + outbound.value = "3" + } + } + } + + val selectAppList = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { (_, _) -> + apps.postUpdate() + } + + lateinit var outbound: OutboundPreference + lateinit var apps: AppListPreference + + fun PreferenceFragmentCompat.viewCreated(view: View, savedInstanceState: Bundle?) { + outbound = findPreference(Key.ROUTE_OUTBOUND)!! + apps = findPreference(Key.ROUTE_PACKAGES)!! + + outbound.setOnPreferenceChangeListener { _, newValue -> + if (newValue.toString() == "3") { + selectProfileForAdd.launch( + Intent( + this@RouteSettingsActivity, ProfileSelectActivity::class.java + ) + ) + false + } else { + true + } + } + + apps.setOnPreferenceClickListener { + selectAppList.launch( + Intent( + this@RouteSettingsActivity, AppListActivity::class.java + ) + ) + true + } + } + + fun PreferenceFragmentCompat.displayPreferenceDialog(preference: Preference): Boolean { + return false + } + + class UnsavedChangesDialogFragment : AlertDialogFragment() { + override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) { + setTitle(R.string.unsaved_changes_prompt) + setPositiveButton(R.string.yes) { _, _ -> + runOnDefaultDispatcher { + (requireActivity() as RouteSettingsActivity).saveAndExit() + } + } + setNegativeButton(R.string.no) { _, _ -> + requireActivity().finish() + } + setNeutralButton(android.R.string.cancel, null) + } + } + + @Parcelize + data class ProfileIdArg(val ruleId: Long) : Parcelable + class DeleteConfirmationDialogFragment : AlertDialogFragment() { + override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) { + setTitle(R.string.delete_route_prompt) + setPositiveButton(R.string.yes) { _, _ -> + runOnDefaultDispatcher { + ProfileManager.deleteRule(arg.ruleId) + } + requireActivity().finish() + } + setNegativeButton(R.string.no, null) + } + } + + companion object { + const val EXTRA_ROUTE_ID = "id" + const val EXTRA_PACKAGE_NAME = "pkg" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setSupportActionBar(findViewById(R.id.toolbar)) + supportActionBar?.apply { + setTitle(R.string.cag_route) + setDisplayHomeAsUpEnabled(true) + setHomeAsUpIndicator(R.drawable.ic_navigation_close) + } + + if (savedInstanceState == null) { + val editingId = intent.getLongExtra(EXTRA_ROUTE_ID, 0L) + DataStore.editingId = editingId + runOnDefaultDispatcher { + if (editingId == 0L) { + init(intent.getStringExtra(EXTRA_PACKAGE_NAME)) + } else { + val ruleEntity = SagerDatabase.rulesDao.getById(editingId) + if (ruleEntity == null) { + onMainDispatcher { + finish() + } + return@runOnDefaultDispatcher + } + ruleEntity.init() + } + + onMainDispatcher { + supportFragmentManager.beginTransaction() + .replace(R.id.settings, MyPreferenceFragmentCompat().apply { + activity = this@RouteSettingsActivity + }) + .commit() + + DataStore.dirty = false + DataStore.profileCacheStore.registerChangeListener(this@RouteSettingsActivity) + } + } + + + } + + } + + suspend fun saveAndExit() { + + if (!needSave()) { + onMainDispatcher { + MaterialAlertDialogBuilder(this@RouteSettingsActivity).setTitle(R.string.empty_route) + .setMessage(R.string.empty_route_notice) + .setPositiveButton(android.R.string.ok, null) + .show() + } + return + } + + val editingId = DataStore.editingId + if (editingId == 0L) { + if (intent.hasExtra(EXTRA_PACKAGE_NAME)) { + setResult(RESULT_OK, Intent()) + } + + ProfileManager.createRule(RuleEntity().apply { serialize() }) + } else { + val entity = SagerDatabase.rulesDao.getById(DataStore.editingId) + if (entity == null) { + finish() + return + } + ProfileManager.updateRule(entity.apply { serialize() }) + } + finish() + + } + + val child by lazy { supportFragmentManager.findFragmentById(R.id.settings) as MyPreferenceFragmentCompat } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.profile_config_menu, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem) = child.onOptionsItemSelected(item) + + override fun onBackPressed() { + if (needSave()) { + UnsavedChangesDialogFragment().apply { key() }.show(supportFragmentManager, null) + } else super.onBackPressed() + } + + override fun onSupportNavigateUp(): Boolean { + if (!super.onSupportNavigateUp()) finish() + return true + } + + override fun onDestroy() { + DataStore.profileCacheStore.unregisterChangeListener(this) + super.onDestroy() + } + + override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) { + if (key != Key.PROFILE_DIRTY) { + DataStore.dirty = true + } + } + + class MyPreferenceFragmentCompat : PreferenceFragmentCompat() { + + lateinit var activity: RouteSettingsActivity + + override fun onCreatePreferencesFix(savedInstanceState: Bundle?, rootKey: String?) { + preferenceManager.preferenceDataStore = DataStore.profileCacheStore + activity.apply { + createPreferences(savedInstanceState, rootKey) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + ViewCompat.setOnApplyWindowInsetsListener(listView, ListListener) + + activity.apply { + viewCreated(view, savedInstanceState) + } + } + + override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { + R.id.action_delete -> { + if (DataStore.editingId == 0L) { + requireActivity().finish() + } else { + DeleteConfirmationDialogFragment().apply { + arg(ProfileIdArg(DataStore.editingId)) + key() + }.show(parentFragmentManager, null) + } + true + } + R.id.action_apply -> { + runOnDefaultDispatcher { + activity.saveAndExit() + } + true + } + else -> false + } + + override fun onDisplayPreferenceDialog(preference: Preference) { + activity.apply { + if (displayPreferenceDialog(preference)) return + } + super.onDisplayPreferenceDialog(preference) + } + + } + + object PasswordSummaryProvider : Preference.SummaryProvider { + + override fun provideSummary(preference: EditTextPreference): CharSequence { + val text = preference.text + return if (text.isNullOrBlank()) { + preference.context.getString(androidx.preference.R.string.not_set) + } else { + "\u2022".repeat(text.length) + } + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt new file mode 100644 index 0000000..c8477f5 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt @@ -0,0 +1,238 @@ +package io.nekohasekai.sagernet.ui + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.pm.ShortcutManager +import android.graphics.ImageDecoder +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.MediaStore +import android.view.Menu +import android.view.MenuItem +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.getSystemService +import com.google.zxing.Result +import com.king.zxing.CameraScan +import com.king.zxing.DefaultCameraScan +import com.king.zxing.analyze.QRCodeAnalyzer +import com.king.zxing.util.CodeUtils +import com.king.zxing.util.LogUtils +import com.king.zxing.util.PermissionUtils +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.ProfileManager +import io.nekohasekai.sagernet.databinding.LayoutScannerBinding +import io.nekohasekai.sagernet.group.RawUpdater +import io.nekohasekai.sagernet.ktx.* +import io.nekohasekai.sagernet.widget.ListHolderListener +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + + +class ScannerActivity : ThemedActivity(), + CameraScan.OnScanResultCallback { + + lateinit var binding: LayoutScannerBinding + lateinit var cameraScan: CameraScan + + @SuppressLint("SourceLockedOrientationActivity") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (Build.VERSION.SDK_INT != Build.VERSION_CODES.O) { + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + if (Build.VERSION.SDK_INT >= 25) getSystemService()!!.reportShortcutUsed("scan") + binding = LayoutScannerBinding.inflate(layoutInflater) + setContentView(binding.root) + ListHolderListener.setup(this) + setSupportActionBar(findViewById(R.id.toolbar)) + supportActionBar?.apply { + setDisplayHomeAsUpEnabled(true) + setHomeAsUpIndicator(R.drawable.ic_navigation_close) + } + + // 二维码库 + initCameraScan() + startCamera() + binding.ivFlashlight.setOnClickListener { toggleTorchState() } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.scanner_menu, menu) + return true + } + + val importCodeFile = registerForActivityResult(ActivityResultContracts.GetMultipleContents()) { + runOnDefaultDispatcher { + try { + it.forEachTry { uri -> + val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + ImageDecoder.decodeBitmap( + ImageDecoder.createSource( + contentResolver, uri + ) + ) { decoder, _, _ -> + decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + decoder.isMutableRequired = true + } + } else { + @Suppress("DEPRECATION") MediaStore.Images.Media.getBitmap( + contentResolver, uri + ) + } + val result = CodeUtils.parseCodeResult(bitmap) + onMainDispatcher { + onScanResultCallback(result, true) + } + } + finish() + } catch (e: Exception) { + Logs.w(e) + onMainDispatcher { + Toast.makeText(app, e.readableMessage, Toast.LENGTH_LONG).show() + } + } + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return if (item.itemId == R.id.action_import_file) { + startFilesForResult(importCodeFile, "image/*") + true + } else { + super.onOptionsItemSelected(item) + } + } + + var finished = AtomicBoolean(false) + var importedN = AtomicInteger(0) + + /** + * 接收扫码结果回调 + * @param result 扫码结果 + * @return 返回true表示拦截,将不自动执行后续逻辑,为false表示不拦截,默认不拦截 + */ + override fun onScanResultCallback(result: Result?): Boolean { + return onScanResultCallback(result, false) + } + + fun onScanResultCallback(result: Result?, multi: Boolean): Boolean { + if (!multi && finished.getAndSet(true)) return true + if (!multi) finish() + runOnDefaultDispatcher { + try { + val text = result?.text ?: throw Exception("QR code not found") + val results = RawUpdater.parseRaw(text) + if (!results.isNullOrEmpty()) { + val currentGroupId = DataStore.selectedGroupForImport() + if (DataStore.selectedGroup != currentGroupId) { + DataStore.selectedGroup = currentGroupId + } + + for (profile in results) { + ProfileManager.createProfile(currentGroupId, profile) + importedN.addAndGet(1) + } + } else { + onMainDispatcher { + Toast.makeText(app, R.string.action_import_err, Toast.LENGTH_SHORT).show() + } + } + } catch (e: SubscriptionFoundException) { + startActivity(Intent(this@ScannerActivity, MainActivity::class.java).apply { + action = Intent.ACTION_VIEW + data = Uri.parse(e.link) + }) + } catch (e: Throwable) { + Logs.w(e) + onMainDispatcher { + var text = getString(R.string.action_import_err) + text += "\n" + e.readableMessage + Toast.makeText(app, text, Toast.LENGTH_SHORT).show() + } + } + } + return true + } + + /** + * 初始化CameraScan + */ + fun initCameraScan() { + cameraScan = DefaultCameraScan(this, binding.previewView) + cameraScan.setAnalyzer(QRCodeAnalyzer()) + cameraScan.setOnScanResultCallback(this) + cameraScan.setNeedAutoZoom(true) + } + + /** + * 启动相机预览 + */ + fun startCamera() { + if (PermissionUtils.checkPermission(this, Manifest.permission.CAMERA)) { + cameraScan.startCamera() + } else { + LogUtils.d("checkPermissionResult != PERMISSION_GRANTED") + PermissionUtils.requestPermission( + this, Manifest.permission.CAMERA, CAMERA_PERMISSION_REQUEST_CODE + ) + } + } + + /** + * 释放相机 + */ + private fun releaseCamera() { + cameraScan.release() + } + + /** + * 切换闪光灯状态(开启/关闭) + */ + protected fun toggleTorchState() { + val isTorch = cameraScan.isTorchEnabled + cameraScan.enableTorch(!isTorch) + binding.ivFlashlight.isSelected = !isTorch + } + + val CAMERA_PERMISSION_REQUEST_CODE = 0X86 + + override fun onRequestPermissionsResult( + requestCode: Int, permissions: Array, grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == CAMERA_PERMISSION_REQUEST_CODE) { + requestCameraPermissionResult(permissions, grantResults) + } + } + + /** + * 请求Camera权限回调结果 + * @param permissions + * @param grantResults + */ + fun requestCameraPermissionResult(permissions: Array, grantResults: IntArray) { + if (PermissionUtils.requestPermissionsResult( + Manifest.permission.CAMERA, permissions, grantResults + ) + ) { + startCamera() + } else { + finish() + } + } + + override fun onDestroy() { + releaseCamera() + super.onDestroy() + if (importedN.get() > 0) { + var text = getString(R.string.action_import_msg) + text += "\n" + importedN.get() + " profile(s)" + Toast.makeText(app, text, Toast.LENGTH_LONG).show() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsFragment.kt new file mode 100644 index 0000000..0bc105a --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsFragment.kt @@ -0,0 +1,22 @@ +package io.nekohasekai.sagernet.ui + +import android.os.Bundle +import android.view.View +import androidx.core.view.ViewCompat +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.widget.ListHolderListener + +class SettingsFragment : ToolbarFragment(R.layout.layout_config_settings) { + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + ViewCompat.setOnApplyWindowInsetsListener(view, ListHolderListener) + toolbar.setTitle(R.string.settings) + + parentFragmentManager.beginTransaction() + .replace(R.id.settings, SettingsPreferenceFragment()) + .commitAllowingStateLoss() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt new file mode 100644 index 0000000..d075438 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/SettingsPreferenceFragment.kt @@ -0,0 +1,254 @@ +package io.nekohasekai.sagernet.ui + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.view.View +import android.view.inputmethod.EditorInfo +import android.widget.EditText +import androidx.core.app.ActivityCompat +import androidx.preference.EditTextPreference +import androidx.preference.MultiSelectListPreference +import androidx.preference.Preference +import androidx.preference.SwitchPreference +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.takisoft.preferencex.PreferenceFragmentCompat +import com.takisoft.preferencex.SimpleMenuPreference +import io.nekohasekai.sagernet.Key +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.SagerNet +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.preference.EditTextPreferenceModifiers +import io.nekohasekai.sagernet.ktx.* +import io.nekohasekai.sagernet.utils.Theme +import io.nekohasekai.sagernet.widget.AppListPreference +import libcore.Libcore +import moe.matsuri.nb4a.Protocols +import moe.matsuri.nb4a.ui.ColorPickerPreference +import moe.matsuri.nb4a.ui.LongClickSwitchPreference +import moe.matsuri.nb4a.ui.MTUPreference + +class SettingsPreferenceFragment : PreferenceFragmentCompat() { + + private lateinit var isProxyApps: SwitchPreference + private lateinit var nekoPlugins: AppListPreference + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + listView.layoutManager = FixedLinearLayoutManager(listView) + } + + val reloadListener = Preference.OnPreferenceChangeListener { _, _ -> + needReload() + true + } + + override fun onCreatePreferencesFix(savedInstanceState: Bundle?, rootKey: String?) { + preferenceManager.preferenceDataStore = DataStore.configurationStore + DataStore.initGlobal() + addPreferencesFromResource(R.xml.global_preferences) + + DataStore.routePackages = DataStore.nekoPlugins + nekoPlugins = findPreference(Key.NEKO_PLUGIN_MANAGED)!! + nekoPlugins.setOnPreferenceClickListener { + // borrow from route app settings + startActivity(Intent( + context, AppListActivity::class.java + ).apply { putExtra(Key.NEKO_PLUGIN_MANAGED, true) }) + true + } + + val appTheme = findPreference(Key.APP_THEME)!! + appTheme.setOnPreferenceChangeListener { _, newTheme -> + if (DataStore.serviceState.started) { + SagerNet.reloadService() + } + val theme = Theme.getTheme(newTheme as Int) + app.setTheme(theme) + requireActivity().apply { + setTheme(theme) + ActivityCompat.recreate(this) + } + true + } + + val nightTheme = findPreference(Key.NIGHT_THEME)!! + nightTheme.setOnPreferenceChangeListener { _, newTheme -> + Theme.currentNightMode = (newTheme as String).toInt() + Theme.applyNightTheme() + true + } + val mixedPort = findPreference(Key.MIXED_PORT)!! + val speedInterval = findPreference(Key.SPEED_INTERVAL)!! + val serviceMode = findPreference(Key.SERVICE_MODE)!! + val allowAccess = findPreference(Key.ALLOW_ACCESS)!! + val appendHttpProxy = findPreference(Key.APPEND_HTTP_PROXY)!! + + val portLocalDns = findPreference(Key.LOCAL_DNS_PORT)!! + val showDirectSpeed = findPreference(Key.SHOW_DIRECT_SPEED)!! + val ipv6Mode = findPreference(Key.IPV6_MODE)!! +// val domainStrategy = findPreference(Key.DOMAIN_STRATEGY)!! + val trafficSniffing = findPreference(Key.TRAFFIC_SNIFFING)!! + + val muxConcurrency = findPreference(Key.MUX_CONCURRENCY)!! + val tcpKeepAliveInterval = findPreference(Key.TCP_KEEP_ALIVE_INTERVAL)!! + tcpKeepAliveInterval.isVisible = false + + val bypassLan = findPreference(Key.BYPASS_LAN)!! + val bypassLanInCoreOnly = findPreference(Key.BYPASS_LAN_IN_CORE_ONLY)!! + + bypassLanInCoreOnly.isEnabled = bypassLan.isChecked + bypassLan.setOnPreferenceChangeListener { _, newValue -> + bypassLanInCoreOnly.isEnabled = newValue as Boolean + needReload() + true + } + + val remoteDns = findPreference(Key.REMOTE_DNS)!! + val directDns = findPreference(Key.DIRECT_DNS)!! + val directDnsUseSystem = findPreference(Key.DIRECT_DNS_USE_SYSTEM)!! + val enableDnsRouting = findPreference(Key.ENABLE_DNS_ROUTING)!! + val enableFakeDns = findPreference(Key.ENABLE_FAKEDNS)!! + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + DataStore.directDnsUseSystem = false + directDnsUseSystem.remove() + } else { + directDns.isEnabled = !directDnsUseSystem.isChecked + directDnsUseSystem.setOnPreferenceChangeListener { _, newValue -> + directDns.isEnabled = !(newValue as Boolean) + needReload() + true + } + } + + val requireTransproxy = findPreference(Key.REQUIRE_TRANSPROXY)!! + val transproxyPort = findPreference(Key.TRANSPROXY_PORT)!! + val transproxyMode = findPreference(Key.TRANSPROXY_MODE)!! + val enableLog = findPreference(Key.ENABLE_LOG)!! + val mtu = findPreference(Key.MTU)!! + + enableLog.setOnPreferenceChangeListener { _, _ -> + needRestart() + true + } + enableLog.setOnLongClickListener { + if (context == null) return@setOnLongClickListener true + + val view = EditText(context).apply { + inputType = EditorInfo.TYPE_CLASS_NUMBER + var size = DataStore.logBufSize + if (size == 0) size = 50 + setText(size.toString()) + } + + MaterialAlertDialogBuilder(requireContext()).setTitle("Log buffer size (kb)") + .setView(view) + .setPositiveButton(android.R.string.ok) { _, _ -> + DataStore.logBufSize = view.text.toString().toInt() + if (DataStore.logBufSize <= 0) DataStore.logBufSize = 50 + needRestart() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + true + } + + transproxyPort.isEnabled = requireTransproxy.isChecked + transproxyMode.isEnabled = requireTransproxy.isChecked + + requireTransproxy.setOnPreferenceChangeListener { _, newValue -> + transproxyPort.isEnabled = newValue as Boolean + transproxyMode.isEnabled = newValue + needReload() + true + } + + val muxProtocols = findPreference(Key.MUX_PROTOCOLS)!! + + muxProtocols.apply { + val e = Protocols.getCanMuxList().toTypedArray() + entries = e + entryValues = e + } + + val dnsNetwork = findPreference(Key.DNS_NETWORK)!! + + portLocalDns.setOnBindEditTextListener(EditTextPreferenceModifiers.Port) + muxConcurrency.setOnBindEditTextListener(EditTextPreferenceModifiers.Port) + mixedPort.setOnBindEditTextListener(EditTextPreferenceModifiers.Port) + + val metedNetwork = findPreference(Key.METERED_NETWORK)!! + if (Build.VERSION.SDK_INT < 28) { + metedNetwork.remove() + } + isProxyApps = findPreference(Key.PROXY_APPS)!! + isProxyApps.setOnPreferenceChangeListener { _, newValue -> + startActivity(Intent(activity, AppManagerActivity::class.java)) + if (newValue as Boolean) DataStore.dirty = true + newValue + } + + val profileTrafficStatistics = + findPreference(Key.PROFILE_TRAFFIC_STATISTICS)!! + speedInterval.isEnabled = profileTrafficStatistics.isChecked + profileTrafficStatistics.setOnPreferenceChangeListener { _, newValue -> + speedInterval.isEnabled = newValue as Boolean + needReload() + true + } + + serviceMode.setOnPreferenceChangeListener { _, _ -> + if (DataStore.serviceState.started) SagerNet.stopService() + true + } + + val tunImplementation = findPreference(Key.TUN_IMPLEMENTATION)!! + val resolveDestination = findPreference(Key.RESOLVE_DESTINATION)!! + val acquireWakeLock = findPreference(Key.ACQUIRE_WAKE_LOCK)!! + val enableClashAPI = findPreference(Key.ENABLE_CLASH_API)!! + + speedInterval.onPreferenceChangeListener = reloadListener + mixedPort.onPreferenceChangeListener = reloadListener + appendHttpProxy.onPreferenceChangeListener = reloadListener + showDirectSpeed.onPreferenceChangeListener = reloadListener +// domainStrategy.onPreferenceChangeListener = reloadListener + trafficSniffing.onPreferenceChangeListener = reloadListener + muxConcurrency.onPreferenceChangeListener = reloadListener + tcpKeepAliveInterval.onPreferenceChangeListener = reloadListener + bypassLanInCoreOnly.onPreferenceChangeListener = reloadListener + mtu.onPreferenceChangeListener = reloadListener + + enableFakeDns.onPreferenceChangeListener = reloadListener + remoteDns.onPreferenceChangeListener = reloadListener + directDns.onPreferenceChangeListener = reloadListener + enableDnsRouting.onPreferenceChangeListener = reloadListener + dnsNetwork.onPreferenceChangeListener = reloadListener + + portLocalDns.onPreferenceChangeListener = reloadListener + ipv6Mode.onPreferenceChangeListener = reloadListener + allowAccess.onPreferenceChangeListener = reloadListener + + transproxyPort.onPreferenceChangeListener = reloadListener + transproxyMode.onPreferenceChangeListener = reloadListener + + resolveDestination.onPreferenceChangeListener = reloadListener + tunImplementation.onPreferenceChangeListener = reloadListener + acquireWakeLock.onPreferenceChangeListener = reloadListener + enableClashAPI.onPreferenceChangeListener = reloadListener + + } + + override fun onResume() { + super.onResume() + + if (::isProxyApps.isInitialized) { + isProxyApps.isChecked = DataStore.proxyApps + } + if (::nekoPlugins.isInitialized) { + nekoPlugins.postUpdate() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/StunActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/StunActivity.kt new file mode 100644 index 0000000..c910e71 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/StunActivity.kt @@ -0,0 +1,65 @@ +package io.nekohasekai.sagernet.ui + +import android.os.Bundle +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isVisible +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.databinding.LayoutStunBinding +import io.nekohasekai.sagernet.ktx.onMainDispatcher +import io.nekohasekai.sagernet.ktx.readableMessage +import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher +import libcore.Libcore + +class StunActivity : ThemedActivity() { + + private lateinit var binding: LayoutStunBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = LayoutStunBinding.inflate(layoutInflater) + setContentView(binding.root) + setSupportActionBar(findViewById(R.id.toolbar)) + supportActionBar?.apply { + setTitle(R.string.stun_test) + setDisplayHomeAsUpEnabled(true) + setHomeAsUpIndicator(R.drawable.baseline_arrow_back_24) + } + binding.stunTest.setOnClickListener { + doTest() + } + } + + fun doTest() { + binding.waitLayout.isVisible = true + runOnDefaultDispatcher { + val result = try { + val _result = Libcore.stunTest(binding.natStunServer.text.toString()) + if (_result!!.success) { + _result.text + } else { + throw Exception(_result.text) + } + } catch (e: Exception) { + onMainDispatcher { + AlertDialog.Builder(this@StunActivity) + .setTitle(R.string.error_title) + .setMessage(e.readableMessage) + .setPositiveButton(android.R.string.ok) { _, _ -> + finish() + } + .setOnCancelListener { + finish() + } + .runCatching { show() } + } + return@runOnDefaultDispatcher + } + onMainDispatcher { + binding.waitLayout.isVisible = false + binding.natResult.text = result + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/SwitchActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/SwitchActivity.kt new file mode 100644 index 0000000..44dddb3 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/SwitchActivity.kt @@ -0,0 +1,33 @@ +package io.nekohasekai.sagernet.ui + +import android.os.Bundle +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.SagerNet +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.ProfileManager +import io.nekohasekai.sagernet.ktx.runOnMainDispatcher + +class SwitchActivity : ThemedActivity(R.layout.layout_empty), + ConfigurationFragment.SelectCallback { + + override val isDialog = true + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + supportFragmentManager.beginTransaction() + .replace(R.id.fragment_holder, ConfigurationFragment(true, null, R.string.action_switch)) + .commitAllowingStateLoss() + } + + override fun returnProfile(profileId: Long) { + val old = DataStore.selectedProxy + DataStore.selectedProxy = profileId + runOnMainDispatcher { + ProfileManager.postUpdate(old) + ProfileManager.postUpdate(profileId) + } + SagerNet.reloadService() + finish() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ThemedActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ThemedActivity.kt new file mode 100644 index 0000000..4c57f20 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ThemedActivity.kt @@ -0,0 +1,56 @@ +package io.nekohasekai.sagernet.ui + +import android.content.res.Configuration +import android.os.Bundle +import android.widget.TextView +import androidx.annotation.StringRes +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import com.google.android.material.snackbar.Snackbar +import io.nekohasekai.sagernet.utils.Theme + +abstract class ThemedActivity : AppCompatActivity { + constructor() : super() + constructor(contentLayoutId: Int) : super(contentLayoutId) + + var themeResId = 0 + var uiMode = 0 + open val isDialog = false + + override fun onCreate(savedInstanceState: Bundle?) { + if (!isDialog) { + Theme.apply(this) + } else { + Theme.applyDialog(this) + } + Theme.applyNightTheme() + + super.onCreate(savedInstanceState) + + uiMode = resources.configuration.uiMode + } + + override fun setTheme(resId: Int) { + super.setTheme(resId) + + themeResId = resId + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + + if (newConfig.uiMode != uiMode) { + uiMode = newConfig.uiMode + ActivityCompat.recreate(this) + } + } + + fun snackbar(@StringRes resId: Int): Snackbar = snackbar("").setText(resId) + fun snackbar(text: CharSequence): Snackbar = snackbarInternal(text).apply { + view.findViewById(com.google.android.material.R.id.snackbar_text).apply { + maxLines = 10 + } + } + internal open fun snackbarInternal(text: CharSequence): Snackbar = throw NotImplementedError() + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ToolbarFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ToolbarFragment.kt new file mode 100644 index 0000000..563a222 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ToolbarFragment.kt @@ -0,0 +1,29 @@ +package io.nekohasekai.sagernet.ui + +import android.os.Bundle +import android.view.KeyEvent +import android.view.View +import androidx.appcompat.widget.Toolbar +import androidx.core.view.GravityCompat +import androidx.fragment.app.Fragment +import io.nekohasekai.sagernet.R + +open class ToolbarFragment : Fragment { + + constructor() : super() + constructor(contentLayoutId: Int) : super(contentLayoutId) + + lateinit var toolbar: Toolbar + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + toolbar = view.findViewById(R.id.toolbar) + toolbar.setNavigationIcon(R.drawable.ic_navigation_menu) + toolbar.setNavigationOnClickListener { + (activity as MainActivity).binding.drawerLayout.openDrawer(GravityCompat.START) + } + } + + open fun onKeyDown(ketCode: Int, event: KeyEvent) = false + open fun onBackPressed(): Boolean = false +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ToolsFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ToolsFragment.kt new file mode 100644 index 0000000..bf73ca1 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ToolsFragment.kt @@ -0,0 +1,42 @@ +package io.nekohasekai.sagernet.ui + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.viewpager2.adapter.FragmentStateAdapter +import com.google.android.material.tabs.TabLayoutMediator +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.databinding.LayoutToolsBinding +import io.nekohasekai.sagernet.ktx.isExpert + +class ToolsFragment : ToolbarFragment(R.layout.layout_tools) { + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + toolbar.setTitle(R.string.menu_tools) + + val tools = mutableListOf() + tools.add(NetworkFragment()) + tools.add(BackupFragment()) + + if (isExpert) tools.add(DebugFragment()) + + val binding = LayoutToolsBinding.bind(view) + binding.toolsPager.adapter = ToolsAdapter(tools) + + TabLayoutMediator(binding.toolsTab, binding.toolsPager) { tab, position -> + tab.text = tools[position].name() + tab.view.setOnLongClickListener { // clear toast + true + } + }.attach() + } + + inner class ToolsAdapter(val tools: List) : FragmentStateAdapter(this) { + + override fun getItemCount() = tools.size + + override fun createFragment(position: Int) = tools[position] + } + +} \ 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 0000000..55a7b33 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/VpnRequestActivity.kt @@ -0,0 +1,72 @@ +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.SagerNet +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.ktx.Logs +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 + } + SagerNet.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) { + SagerNet.startService() + false + } else { + Logs.e("Failed to start VpnService: $intent") + true + } + } + + +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/WebviewFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/WebviewFragment.kt new file mode 100644 index 0000000..c538181 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/WebviewFragment.kt @@ -0,0 +1,77 @@ +package io.nekohasekai.sagernet.ui + +import android.annotation.SuppressLint +import android.os.Bundle +import android.text.InputType +import android.view.MenuItem +import android.view.View +import android.webkit.* +import androidx.appcompat.widget.Toolbar +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.input.input +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.databinding.LayoutWebviewBinding +import moe.matsuri.nb4a.utils.WebViewUtil + +// Fragment必须有一个无参public的构造函数,否则在数据恢复的时候,会报crash + +class WebviewFragment : ToolbarFragment(R.layout.layout_webview), Toolbar.OnMenuItemClickListener { + + lateinit var mWebView: WebView + + @SuppressLint("SetJavaScriptEnabled") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // layout + toolbar.setTitle(R.string.menu_dashboard) + toolbar.inflateMenu(R.menu.yacd_menu) + toolbar.setOnMenuItemClickListener(this) + + val binding = LayoutWebviewBinding.bind(view) + + // webview + mWebView = binding.webview + mWebView.settings.javaScriptEnabled = true + mWebView.webViewClient = object : WebViewClient() { + override fun onReceivedError( + view: WebView?, request: WebResourceRequest?, error: WebResourceError? + ) { + WebViewUtil.onReceivedError(view, request, error) + } + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + } + } + mWebView.loadUrl(DataStore.yacdURL) + } + + override fun onDestroy() { + super.onDestroy() + // mWebView.onPause() + // mWebView.removeAllViews() + // mWebView.destroy() + } + + @SuppressLint("CheckResult") + override fun onMenuItemClick(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_set_url -> { + MaterialDialog(requireContext()).show { + title(R.string.set_panel_url) + input( + prefill = DataStore.yacdURL, + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI + ) { _, str -> + DataStore.yacdURL = str.toString() + mWebView.loadUrl(DataStore.yacdURL) + } + positiveButton(R.string.save) + } + } + } + return true + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/profile/ChainSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/ChainSettingsActivity.kt new file mode 100644 index 0000000..32542e2 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/ChainSettingsActivity.kt @@ -0,0 +1,295 @@ +package io.nekohasekai.sagernet.ui.profile + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.text.format.Formatter +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.TextView +import androidx.activity.result.component1 +import androidx.activity.result.component2 +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.isVisible +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.takisoft.preferencex.PreferenceFragmentCompat +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.ProfileManager +import io.nekohasekai.sagernet.database.ProxyEntity +import io.nekohasekai.sagernet.databinding.LayoutAddEntityBinding +import io.nekohasekai.sagernet.databinding.LayoutProfileBinding +import io.nekohasekai.sagernet.fmt.internal.ChainBean +import io.nekohasekai.sagernet.ktx.* +import io.nekohasekai.sagernet.ui.ProfileSelectActivity +import moe.matsuri.nb4a.Protocols.getProtocolColor + +class ChainSettingsActivity : ProfileSettingsActivity(R.layout.layout_chain_settings) { + + override fun createEntity() = ChainBean() + + val proxyList = ArrayList() + + override fun ChainBean.init() { + DataStore.profileName = name + DataStore.serverProtocol = proxies.joinToString(",") + } + + override fun ChainBean.serialize() { + name = DataStore.profileName + proxies = proxyList.map { it.id } + initializeDefaultValues() + } + + override fun PreferenceFragmentCompat.createPreferences( + savedInstanceState: Bundle?, + rootKey: String?, + ) { + addPreferencesFromResource(R.xml.name_preferences) + } + + lateinit var configurationList: RecyclerView + lateinit var configurationAdapter: ProxiesAdapter + lateinit var layoutManager: LinearLayoutManager + + @SuppressLint("InlinedApi") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + supportActionBar!!.setTitle(R.string.chain_settings) + configurationList = findViewById(R.id.configuration_list) + layoutManager = FixedLinearLayoutManager(configurationList) + configurationList.layoutManager = layoutManager + configurationAdapter = ProxiesAdapter() + configurationList.adapter = configurationAdapter + + ItemTouchHelper(object : ItemTouchHelper.SimpleCallback( + ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.START + ) { + override fun getSwipeDirs( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + ) = if (viewHolder is ProfileHolder) { + super.getSwipeDirs(recyclerView, viewHolder) + } else 0 + + override fun getDragDirs( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + ) = if (viewHolder is ProfileHolder) { + super.getDragDirs(recyclerView, viewHolder) + } else 0 + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder, + ): Boolean { + return if (target !is ProfileHolder) false else { + configurationAdapter.move( + viewHolder.bindingAdapterPosition, target.bindingAdapterPosition + ) + true + } + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + configurationAdapter.remove(viewHolder.bindingAdapterPosition) + } + + }).attachToRecyclerView(configurationList) + } + + override fun PreferenceFragmentCompat.viewCreated(view: View, savedInstanceState: Bundle?) { + view.rootView.findViewById(R.id.recycler_view).apply { + (layoutParams ?: LinearLayout.LayoutParams(-1, -2)).apply { + height = -2 + layoutParams = this + } + } + + runOnDefaultDispatcher { + configurationAdapter.reload() + } + } + + inner class ProxiesAdapter : RecyclerView.Adapter() { + + suspend fun reload() { + val idList = DataStore.serverProtocol.split(",") + .mapNotNull { it.takeIf { it.isNotBlank() }?.toLong() } + if (idList.isNotEmpty()) { + val profiles = ProfileManager.getProfiles(idList).map { it.id to it }.toMap() + for (id in idList) { + proxyList.add(profiles[id] ?: continue) + } + } + onMainDispatcher { + notifyDataSetChanged() + } + } + + fun move(from: Int, to: Int) { + val toMove = proxyList[to - 1] + proxyList[to - 1] = proxyList[from - 1] + proxyList[from - 1] = toMove + notifyItemMoved(from, to) + DataStore.dirty = true + } + + fun remove(index: Int) { + proxyList.removeAt(index - 1) + notifyItemRemoved(index) + DataStore.dirty = true + } + + override fun getItemId(position: Int): Long { + return if (position == 0) 0 else proxyList[position - 1].id + } + + override fun getItemViewType(position: Int): Int { + return if (position == 0) 0 else 1 + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return if (viewType == 0) { + AddHolder(LayoutAddEntityBinding.inflate(layoutInflater, parent, false)) + } else { + ProfileHolder(LayoutProfileBinding.inflate(layoutInflater, parent, false)) + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + if (holder is AddHolder) { + holder.bind() + } else if (holder is ProfileHolder) { + holder.bind(proxyList[position - 1]) + } + } + + override fun getItemCount(): Int { + return proxyList.size + 1 + } + + } + + fun testProfileAllowed(profile: ProxyEntity): Boolean { + if (profile.id == DataStore.editingId) return false + + for (entity in proxyList) { + if (testProfileContains(entity, profile)) return false + } + + return true + } + + fun testProfileContains(profile: ProxyEntity, anotherProfile: ProxyEntity): Boolean { + if (profile.type != 8 || anotherProfile.type != 8) return false + if (profile.id == anotherProfile.id) return true + val proxies = profile.chainBean!!.proxies + if (proxies.contains(anotherProfile.id)) return true + if (proxies.isNotEmpty()) { + for (entity in ProfileManager.getProfiles(proxies)) { + if (testProfileContains(entity, anotherProfile)) { + return true + } + } + } + return false + } + + var replacing = 0 + + val selectProfileForAdd = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { (resultCode, data) -> + if (resultCode == Activity.RESULT_OK) runOnDefaultDispatcher { + DataStore.dirty = true + + val profile = ProfileManager.getProfile( + data!!.getLongExtra( + ProfileSelectActivity.EXTRA_PROFILE_ID, 0 + ) + )!! + + if (!testProfileAllowed(profile)) { + onMainDispatcher { + MaterialAlertDialogBuilder(this@ChainSettingsActivity).setTitle(R.string.circular_reference) + .setMessage(R.string.circular_reference_sum) + .setPositiveButton(android.R.string.ok, null).show() + } + } else { + configurationList.post { + if (replacing != 0) { + proxyList[replacing - 1] = profile + configurationAdapter.notifyItemChanged(replacing) + } else { + proxyList.add(profile) + configurationAdapter.notifyItemInserted(proxyList.size) + } + } + } + } + } + + inner class AddHolder(val binding: LayoutAddEntityBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind() { + binding.root.setOnClickListener { + replacing = 0 + selectProfileForAdd.launch( + Intent( + this@ChainSettingsActivity, ProfileSelectActivity::class.java + ) + ) + } + } + } + + inner class ProfileHolder(binding: LayoutProfileBinding) : + RecyclerView.ViewHolder(binding.root) { + + val profileName = binding.profileName + val profileType = binding.profileType + val trafficText: TextView = binding.trafficText + val editButton = binding.edit + val shareLayout = binding.share + + fun bind(proxyEntity: ProxyEntity) { + + profileName.text = proxyEntity.displayName() + profileType.text = proxyEntity.displayType() + profileType.setTextColor(getProtocolColor(proxyEntity.type)) + + val rx = proxyEntity.rx + val tx = proxyEntity.tx + + val showTraffic = rx + tx != 0L + trafficText.isVisible = showTraffic + if (showTraffic) { + trafficText.text = itemView.context.getString( + R.string.traffic, + Formatter.formatFileSize(itemView.context, tx), + Formatter.formatFileSize(itemView.context, rx) + ) + } + + editButton.setOnClickListener { + replacing = bindingAdapterPosition + selectProfileForAdd.launch(Intent( + this@ChainSettingsActivity, ProfileSelectActivity::class.java + ).apply { + putExtra(ProfileSelectActivity.EXTRA_SELECTED, proxyEntity) + }) + } + + shareLayout.isVisible = false + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/profile/HttpSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/HttpSettingsActivity.kt new file mode 100644 index 0000000..b148953 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/HttpSettingsActivity.kt @@ -0,0 +1,9 @@ +package io.nekohasekai.sagernet.ui.profile + +import io.nekohasekai.sagernet.fmt.http.HttpBean + +class HttpSettingsActivity : StandardV2RaySettingsActivity() { + + override fun createEntity() = HttpBean() + +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/profile/HysteriaSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/HysteriaSettingsActivity.kt new file mode 100644 index 0000000..6282a21 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/HysteriaSettingsActivity.kt @@ -0,0 +1,98 @@ +package io.nekohasekai.sagernet.ui.profile + +import android.os.Bundle +import androidx.preference.EditTextPreference +import com.takisoft.preferencex.PreferenceFragmentCompat +import com.takisoft.preferencex.SimpleMenuPreference +import io.nekohasekai.sagernet.Key +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.preference.EditTextPreferenceModifiers +import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean +import io.nekohasekai.sagernet.ktx.applyDefaultValues + +class HysteriaSettingsActivity : ProfileSettingsActivity() { + + override fun createEntity() = HysteriaBean().applyDefaultValues() + + override fun HysteriaBean.init() { + DataStore.profileName = name + DataStore.serverAddress = serverAddress + DataStore.serverPort = serverPort + DataStore.serverObfs = obfuscation + DataStore.serverAuthType = authPayloadType + DataStore.serverProtocolVersion = protocol + DataStore.serverPassword = authPayload + DataStore.serverSNI = sni + DataStore.serverALPN = alpn + DataStore.serverCertificates = caText + DataStore.serverAllowInsecure = allowInsecure + DataStore.serverUploadSpeed = uploadMbps + DataStore.serverDownloadSpeed = downloadMbps + DataStore.serverStreamReceiveWindow = streamReceiveWindow + DataStore.serverConnectionReceiveWindow = connectionReceiveWindow + DataStore.serverDisableMtuDiscovery = disableMtuDiscovery + DataStore.serverHopInterval = hopInterval + } + + override fun HysteriaBean.serialize() { + name = DataStore.profileName + serverAddress = DataStore.serverAddress + serverPort = DataStore.serverPort + obfuscation = DataStore.serverObfs + authPayloadType = DataStore.serverAuthType + authPayload = DataStore.serverPassword + protocol = DataStore.serverProtocolVersion + sni = DataStore.serverSNI + alpn = DataStore.serverALPN + caText = DataStore.serverCertificates + allowInsecure = DataStore.serverAllowInsecure + uploadMbps = DataStore.serverUploadSpeed + downloadMbps = DataStore.serverDownloadSpeed + streamReceiveWindow = DataStore.serverStreamReceiveWindow + connectionReceiveWindow = DataStore.serverConnectionReceiveWindow + disableMtuDiscovery = DataStore.serverDisableMtuDiscovery + hopInterval = DataStore.serverHopInterval + } + + override fun PreferenceFragmentCompat.createPreferences( + savedInstanceState: Bundle?, + rootKey: String?, + ) { + addPreferencesFromResource(R.xml.hysteria_preferences) + + val authType = findPreference(Key.SERVER_AUTH_TYPE)!! + val authPayload = findPreference(Key.SERVER_PASSWORD)!! + authPayload.isVisible = authType.value != "${HysteriaBean.TYPE_NONE}" + authType.setOnPreferenceChangeListener { _, newValue -> + authPayload.isVisible = newValue != "${HysteriaBean.TYPE_NONE}" + true + } + + findPreference(Key.SERVER_UPLOAD_SPEED)!!.apply { + setOnBindEditTextListener(EditTextPreferenceModifiers.Number) + } + findPreference(Key.SERVER_DOWNLOAD_SPEED)!!.apply { + setOnBindEditTextListener(EditTextPreferenceModifiers.Number) + } + findPreference(Key.SERVER_STREAM_RECEIVE_WINDOW)!!.apply { + setOnBindEditTextListener(EditTextPreferenceModifiers.Number) + } + findPreference(Key.SERVER_CONNECTION_RECEIVE_WINDOW)!!.apply { + setOnBindEditTextListener(EditTextPreferenceModifiers.Number) + } + + findPreference(Key.SERVER_PORT)!!.apply { + setOnBindEditTextListener(EditTextPreferenceModifiers.Port) + } + + findPreference(Key.SERVER_PASSWORD)!!.apply { + summaryProvider = PasswordSummaryProvider + } + + findPreference(Key.SERVER_HOP_INTERVAL)!!.apply { + setOnBindEditTextListener(EditTextPreferenceModifiers.Number) + } + } + +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/profile/NaiveSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/NaiveSettingsActivity.kt new file mode 100644 index 0000000..39f6c1f --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/NaiveSettingsActivity.kt @@ -0,0 +1,67 @@ +package io.nekohasekai.sagernet.ui.profile + +import android.os.Bundle +import androidx.preference.EditTextPreference +import com.takisoft.preferencex.PreferenceFragmentCompat +import io.nekohasekai.sagernet.Key +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.preference.EditTextPreferenceModifiers +import io.nekohasekai.sagernet.fmt.naive.NaiveBean + +class NaiveSettingsActivity : ProfileSettingsActivity() { + + override fun createEntity() = NaiveBean() + + override fun NaiveBean.init() { + DataStore.profileName = name + DataStore.serverAddress = serverAddress + DataStore.serverPort = serverPort + DataStore.serverUsername = username + DataStore.serverPassword = password + DataStore.serverProtocol = proto + DataStore.serverSNI = sni + DataStore.serverCertificates = certificates + DataStore.serverHeaders = extraHeaders + DataStore.serverInsecureConcurrency = insecureConcurrency + } + + override fun NaiveBean.serialize() { + name = DataStore.profileName + serverAddress = DataStore.serverAddress + serverPort = DataStore.serverPort + username = DataStore.serverUsername + password = DataStore.serverPassword + proto = DataStore.serverProtocol + sni = DataStore.serverSNI + certificates = DataStore.serverCertificates + extraHeaders = DataStore.serverHeaders.replace("\r\n", "\n") + insecureConcurrency = DataStore.serverInsecureConcurrency + } + + override fun PreferenceFragmentCompat.createPreferences( + savedInstanceState: Bundle?, + rootKey: String?, + ) { + addPreferencesFromResource(R.xml.naive_preferences) + findPreference(Key.SERVER_PORT)!!.apply { + setOnBindEditTextListener(EditTextPreferenceModifiers.Port) + } + findPreference(Key.SERVER_PASSWORD)!!.apply { + summaryProvider = PasswordSummaryProvider + } + findPreference(Key.SERVER_INSECURE_CONCURRENCY)!!.apply { + setOnBindEditTextListener(EditTextPreferenceModifiers.Number) + } + } + + override fun finish() { + if (DataStore.profileName == "喵要打开隐藏功能") { + DataStore.isExpert = true + } else if (DataStore.profileName == "喵要关闭隐藏功能") { + DataStore.isExpert = false + } + super.finish() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/profile/ProfileSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/ProfileSettingsActivity.kt new file mode 100644 index 0000000..baf9246 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/ProfileSettingsActivity.kt @@ -0,0 +1,374 @@ +package io.nekohasekai.sagernet.ui.profile + +import android.annotation.SuppressLint +import android.content.DialogInterface +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import android.text.InputType +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.widget.LinearLayout +import androidx.annotation.LayoutRes +import androidx.appcompat.app.AlertDialog +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat +import androidx.core.view.ViewCompat +import androidx.core.view.isVisible +import androidx.preference.EditTextPreference +import androidx.preference.Preference +import androidx.preference.PreferenceDataStore +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.input.input +import com.github.shadowsocks.plugin.Empty +import com.github.shadowsocks.plugin.fragment.AlertDialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.takisoft.preferencex.PreferenceFragmentCompat +import io.nekohasekai.sagernet.* +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.GroupManager +import io.nekohasekai.sagernet.database.ProfileManager +import io.nekohasekai.sagernet.database.SagerDatabase +import io.nekohasekai.sagernet.database.preference.OnPreferenceDataStoreChangeListener +import io.nekohasekai.sagernet.databinding.LayoutGroupItemBinding +import io.nekohasekai.sagernet.fmt.AbstractBean +import io.nekohasekai.sagernet.ktx.applyDefaultValues +import io.nekohasekai.sagernet.ktx.onMainDispatcher +import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher +import io.nekohasekai.sagernet.ktx.runOnMainDispatcher +import io.nekohasekai.sagernet.ui.ThemedActivity +import io.nekohasekai.sagernet.widget.ListListener +import kotlinx.parcelize.Parcelize +import kotlin.properties.Delegates + +@Suppress("UNCHECKED_CAST") +abstract class ProfileSettingsActivity( + @LayoutRes resId: Int = R.layout.layout_config_settings, +) : ThemedActivity(resId), OnPreferenceDataStoreChangeListener { + + class UnsavedChangesDialogFragment : AlertDialogFragment() { + override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) { + setTitle(R.string.unsaved_changes_prompt) + setPositiveButton(R.string.yes) { _, _ -> + runOnDefaultDispatcher { + (requireActivity() as ProfileSettingsActivity<*>).saveAndExit() + } + } + setNegativeButton(R.string.no) { _, _ -> + requireActivity().finish() + } + setNeutralButton(android.R.string.cancel, null) + } + } + + @Parcelize + data class ProfileIdArg(val profileId: Long, val groupId: Long) : Parcelable + class DeleteConfirmationDialogFragment : AlertDialogFragment() { + override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) { + setTitle(R.string.delete_confirm_prompt) + setPositiveButton(R.string.yes) { _, _ -> + runOnDefaultDispatcher { + ProfileManager.deleteProfile(arg.groupId, arg.profileId) + } + requireActivity().finish() + } + setNegativeButton(R.string.no, null) + } + } + + companion object { + const val EXTRA_PROFILE_ID = "id" + const val EXTRA_IS_SUBSCRIPTION = "sub" + } + + abstract fun createEntity(): T + abstract fun T.init() + abstract fun T.serialize() + + val proxyEntity by lazy { SagerDatabase.proxyDao.getById(DataStore.editingId) } + protected var isSubscription by Delegates.notNull() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setSupportActionBar(findViewById(R.id.toolbar)) + supportActionBar?.apply { + setTitle(R.string.profile_config) + setDisplayHomeAsUpEnabled(true) + setHomeAsUpIndicator(R.drawable.ic_navigation_close) + } + + if (savedInstanceState == null) { + val editingId = intent.getLongExtra(EXTRA_PROFILE_ID, 0L) + isSubscription = intent.getBooleanExtra(EXTRA_IS_SUBSCRIPTION, false) + DataStore.editingId = editingId + runOnDefaultDispatcher { + if (editingId == 0L) { + DataStore.editingGroup = DataStore.selectedGroupForImport() + createEntity().applyDefaultValues().init() + } else { + if (proxyEntity == null) { + onMainDispatcher { + finish() + } + return@runOnDefaultDispatcher + } + DataStore.editingGroup = proxyEntity!!.groupId + (proxyEntity!!.requireBean() as T).init() + } + + onMainDispatcher { + supportFragmentManager.beginTransaction() + .replace(R.id.settings, MyPreferenceFragmentCompat().apply { + activity = this@ProfileSettingsActivity + }).commit() + } + } + + + } + + } + + open suspend fun saveAndExit() { + + val editingId = DataStore.editingId + if (editingId == 0L) { + val editingGroup = DataStore.editingGroup + ProfileManager.createProfile(editingGroup, createEntity().apply { serialize() }) + } else { + if (proxyEntity == null) { + finish() + return + } + if (proxyEntity!!.id == DataStore.selectedProxy) { + SagerNet.stopService() + } + ProfileManager.updateProfile(proxyEntity!!.apply { (requireBean() as T).serialize() }) + } + finish() + + } + + val child by lazy { supportFragmentManager.findFragmentById(R.id.settings) as MyPreferenceFragmentCompat } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.profile_config_menu, menu) + menu.findItem(R.id.action_move)?.apply { + if (DataStore.editingId != 0L // not new profile + && SagerDatabase.groupDao.getById(DataStore.editingGroup)?.type == GroupType.BASIC // not in subscription group + && SagerDatabase.groupDao.allGroups() + .filter { it.type == GroupType.BASIC }.size > 1 // have other basic group + ) isVisible = true + } + menu.findItem(R.id.action_create_shortcut)?.apply { + if (Build.VERSION.SDK_INT >= 26 && DataStore.editingId != 0L) { + isVisible = true // not new profile + } + } + // shared menu item + menu.findItem(R.id.action_custom_outbound_json)?.isVisible = true + menu.findItem(R.id.action_custom_config_json)?.isVisible = true + return true + } + + override fun onOptionsItemSelected(item: MenuItem) = child.onOptionsItemSelected(item) + + override fun onBackPressed() { + if (DataStore.dirty) UnsavedChangesDialogFragment().apply { key() } + .show(supportFragmentManager, null) else super.onBackPressed() + } + + override fun onSupportNavigateUp(): Boolean { + if (!super.onSupportNavigateUp()) finish() + return true + } + + override fun onDestroy() { + DataStore.profileCacheStore.unregisterChangeListener(this) + super.onDestroy() + } + + override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) { + if (key != Key.PROFILE_DIRTY) { + DataStore.dirty = true + } + } + + abstract fun PreferenceFragmentCompat.createPreferences( + savedInstanceState: Bundle?, + rootKey: String?, + ) + + open fun PreferenceFragmentCompat.viewCreated(view: View, savedInstanceState: Bundle?) { + } + + open fun PreferenceFragmentCompat.displayPreferenceDialog(preference: Preference): Boolean { + return false + } + + class MyPreferenceFragmentCompat : PreferenceFragmentCompat() { + + lateinit var activity: ProfileSettingsActivity<*> + + override fun onCreatePreferencesFix(savedInstanceState: Bundle?, rootKey: String?) { + preferenceManager.preferenceDataStore = DataStore.profileCacheStore + activity.apply { + createPreferences(savedInstanceState, rootKey) + + if (isSubscription) { +// findPreference(Key.PROFILE_NAME)?.isEnabled = false + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + ViewCompat.setOnApplyWindowInsetsListener(listView, ListListener) + + activity.apply { + viewCreated(view, savedInstanceState) + } + + DataStore.dirty = false + DataStore.profileCacheStore.registerChangeListener(activity) + } + + @SuppressLint("CheckResult") + override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { + R.id.action_delete -> { + if (DataStore.editingId == 0L) { + requireActivity().finish() + } else { + DeleteConfirmationDialogFragment().apply { + arg( + ProfileIdArg( + DataStore.editingId, DataStore.editingGroup + ) + ) + key() + }.show(parentFragmentManager, null) + } + true + } + R.id.action_apply -> { + runOnDefaultDispatcher { + activity.saveAndExit() + } + true + } + R.id.action_custom_outbound_json -> { + activity.proxyEntity?.apply { + val bean = requireBean() + MaterialDialog(activity).show { + title(R.string.custom_outbound_json) + input( + prefill = bean.customOutboundJson, + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE, + allowEmpty = true + ) { _, str -> + bean.customOutboundJson = str.toString() + DataStore.dirty = true + } + positiveButton(R.string.save) + } + } + true + } + R.id.action_custom_config_json -> { + activity.proxyEntity?.apply { + val bean = requireBean() + MaterialDialog(activity).show { + title(R.string.custom_config_json) + input( + prefill = bean.customConfigJson, + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE, + allowEmpty = true + ) { _, str -> + bean.customConfigJson = str.toString() + DataStore.dirty = true + } + positiveButton(R.string.save) + } + } + true + } + R.id.action_create_shortcut -> { + val ent = activity.proxyEntity!! + val shortcut = ShortcutInfoCompat.Builder(activity, "shortcut-profile-${ent.id}") + .setShortLabel(ent.displayName()) + .setLongLabel(ent.displayName()) + .setIcon( + IconCompat.createWithResource( + activity, R.drawable.ic_qu_shadowsocks_launcher + ) + ).setIntent(Intent( + context, QuickToggleShortcut::class.java + ).apply { + action = Intent.ACTION_MAIN + putExtra("profile", ent.id) + }).build() + ShortcutManagerCompat.requestPinShortcut(activity, shortcut, null) + } + R.id.action_move -> { + val view = LinearLayout(context).apply { + val ent = activity.proxyEntity!! + orientation = LinearLayout.VERTICAL + + SagerDatabase.groupDao.allGroups() + .filter { it.type == GroupType.BASIC && it.id != ent.groupId } + .forEach { group -> + LayoutGroupItemBinding.inflate(layoutInflater, this, true).apply { + edit.isVisible = false + options.isVisible = false + groupName.text = group.displayName() + groupUpdate.text = getString(R.string.move) + groupUpdate.setOnClickListener { + runOnDefaultDispatcher { + val oldGroupId = ent.groupId + val newGroupId = group.id + ent.groupId = newGroupId + ProfileManager.updateProfile(ent) + GroupManager.postUpdate(oldGroupId) // reload + GroupManager.postUpdate(newGroupId) + DataStore.editingGroup = newGroupId // post switch animation + runOnMainDispatcher { + activity.finish() + } + } + } + } + } + } + MaterialAlertDialogBuilder(activity).setView(view).show() + true + } + else -> false + } + + override fun onDisplayPreferenceDialog(preference: Preference) { + activity.apply { + if (displayPreferenceDialog(preference)) return + } + super.onDisplayPreferenceDialog(preference) + } + + } + + object PasswordSummaryProvider : Preference.SummaryProvider { + + override fun provideSummary(preference: EditTextPreference): CharSequence { + val text = preference.text + return if (text.isNullOrBlank()) { + preference.context.getString(androidx.preference.R.string.not_set) + } else { + "\u2022".repeat(text.length) + } + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/profile/SSHSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/SSHSettingsActivity.kt new file mode 100644 index 0000000..dcead13 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/SSHSettingsActivity.kt @@ -0,0 +1,77 @@ +package io.nekohasekai.sagernet.ui.profile + +import android.os.Bundle +import androidx.preference.EditTextPreference +import com.takisoft.preferencex.PreferenceFragmentCompat +import com.takisoft.preferencex.SimpleMenuPreference +import io.nekohasekai.sagernet.Key +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.preference.EditTextPreferenceModifiers +import io.nekohasekai.sagernet.fmt.ssh.SSHBean + +class SSHSettingsActivity : ProfileSettingsActivity() { + + override fun createEntity() = SSHBean() + + override fun SSHBean.init() { + DataStore.profileName = name + DataStore.serverAddress = serverAddress + DataStore.serverPort = serverPort + DataStore.serverUsername = username + DataStore.serverAuthType = authType + DataStore.serverPassword = password + DataStore.serverPrivateKey = privateKey + DataStore.serverPassword1 = privateKeyPassphrase + DataStore.serverCertificates = publicKey + } + + override fun SSHBean.serialize() { + name = DataStore.profileName + serverAddress = DataStore.serverAddress + serverPort = DataStore.serverPort + username = DataStore.serverUsername + authType = DataStore.serverAuthType + when (authType) { + SSHBean.AUTH_TYPE_NONE -> { + } + SSHBean.AUTH_TYPE_PASSWORD -> { + password = DataStore.serverPassword + } + SSHBean.AUTH_TYPE_PRIVATE_KEY -> { + privateKey = DataStore.serverPrivateKey + privateKeyPassphrase = DataStore.serverPassword1 + } + } + publicKey = DataStore.serverCertificates + } + + override fun PreferenceFragmentCompat.createPreferences( + savedInstanceState: Bundle?, + rootKey: String?, + ) { + addPreferencesFromResource(R.xml.ssh_preferences) + findPreference(Key.SERVER_PORT)!!.apply { + setOnBindEditTextListener(EditTextPreferenceModifiers.Port) + } + val password = findPreference(Key.SERVER_PASSWORD)!!.apply { + summaryProvider = PasswordSummaryProvider + } + val privateKey = findPreference(Key.SERVER_PRIVATE_KEY)!! + val privateKeyPassphrase = findPreference(Key.SERVER_PASSWORD1)!!.apply { + summaryProvider = PasswordSummaryProvider + } + val authType = findPreference(Key.SERVER_AUTH_TYPE)!! + fun updateAuthType(type: Int = DataStore.serverAuthType) { + password.isVisible = type == SSHBean.AUTH_TYPE_PASSWORD + privateKey.isVisible = type == SSHBean.AUTH_TYPE_PRIVATE_KEY + privateKeyPassphrase.isVisible = type == SSHBean.AUTH_TYPE_PRIVATE_KEY + } + updateAuthType() + authType.setOnPreferenceChangeListener { _, newValue -> + updateAuthType((newValue as String).toInt()) + true + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/profile/ShadowsocksSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/ShadowsocksSettingsActivity.kt new file mode 100644 index 0000000..bb6de7e --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/ShadowsocksSettingsActivity.kt @@ -0,0 +1,62 @@ +package io.nekohasekai.sagernet.ui.profile + +import android.os.Bundle +import androidx.preference.EditTextPreference +import com.takisoft.preferencex.PreferenceFragmentCompat +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.preference.EditTextPreferenceModifiers +import io.nekohasekai.sagernet.fmt.shadowsocks.ShadowsocksBean +import moe.matsuri.nb4a.proxy.PreferenceBinding +import moe.matsuri.nb4a.proxy.PreferenceBindingManager +import moe.matsuri.nb4a.proxy.Type + +class ShadowsocksSettingsActivity : ProfileSettingsActivity() { + + override fun createEntity() = ShadowsocksBean() + + private val pbm = PreferenceBindingManager() + private val name = pbm.add(PreferenceBinding(Type.Text, "name")) + private val serverAddress = pbm.add(PreferenceBinding(Type.Text, "serverAddress")) + private val serverPort = pbm.add(PreferenceBinding(Type.TextToInt, "serverPort")) + private val password = pbm.add(PreferenceBinding(Type.Text, "password")) + private val method = pbm.add(PreferenceBinding(Type.Text, "method")) + private val pluginName = + pbm.add(PreferenceBinding(Type.Text, "pluginName").apply { disable = true }) + private val pluginConfig = + pbm.add(PreferenceBinding(Type.Text, "pluginConfig").apply { disable = true }) + private val sUoT = pbm.add(PreferenceBinding(Type.Bool, "sUoT")) + + override fun ShadowsocksBean.init() { + pbm.writeToCacheAll(this) + + DataStore.profileCacheStore.putString("pluginName", plugin.substringBefore(";")) + DataStore.profileCacheStore.putString("pluginConfig", plugin.substringAfter(";")) + } + + override fun ShadowsocksBean.serialize() { + pbm.fromCacheAll(this) + + val pn = pluginName.readStringFromCache() + val pc = pluginConfig.readStringFromCache() + plugin = if (pn.isNotBlank() && pc.isNotBlank()) "$pn;$pc" else "" + } + + override fun PreferenceFragmentCompat.createPreferences( + savedInstanceState: Bundle?, + rootKey: String?, + ) { + addPreferencesFromResource(R.xml.shadowsocks_preferences) + pbm.setPreferenceFragment(this) + + serverPort.preference.apply { + this as EditTextPreference + setOnBindEditTextListener(EditTextPreferenceModifiers.Port) + } + password.preference.apply { + this as EditTextPreference + summaryProvider = PasswordSummaryProvider + } + } + +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/profile/SocksSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/SocksSettingsActivity.kt new file mode 100644 index 0000000..286ecad --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/SocksSettingsActivity.kt @@ -0,0 +1,60 @@ +package io.nekohasekai.sagernet.ui.profile + +import android.os.Bundle +import androidx.preference.EditTextPreference +import androidx.preference.PreferenceCategory +import com.takisoft.preferencex.PreferenceFragmentCompat +import com.takisoft.preferencex.SimpleMenuPreference +import io.nekohasekai.sagernet.Key +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.preference.EditTextPreferenceModifiers +import io.nekohasekai.sagernet.fmt.socks.SOCKSBean + +class SocksSettingsActivity : ProfileSettingsActivity() { + override fun createEntity() = SOCKSBean() + + override fun SOCKSBean.init() { + DataStore.profileName = name + DataStore.serverAddress = serverAddress + DataStore.serverPort = serverPort + + DataStore.serverProtocolVersion = protocol + DataStore.serverUsername = username + DataStore.serverPassword = password + } + + override fun SOCKSBean.serialize() { + name = DataStore.profileName + serverAddress = DataStore.serverAddress + serverPort = DataStore.serverPort + + protocol = DataStore.serverProtocolVersion + username = DataStore.serverUsername + password = DataStore.serverPassword + } + + override fun PreferenceFragmentCompat.createPreferences( + savedInstanceState: Bundle?, + rootKey: String?, + ) { + addPreferencesFromResource(R.xml.socks_preferences) + findPreference(Key.SERVER_PORT)!!.apply { + setOnBindEditTextListener(EditTextPreferenceModifiers.Port) + } + val password = findPreference(Key.SERVER_PASSWORD)!!.apply { + summaryProvider = PasswordSummaryProvider + } + val protocol = findPreference(Key.SERVER_PROTOCOL)!! + + fun updateProtocol(version: Int) { + password.isVisible = version == SOCKSBean.PROTOCOL_SOCKS5 + } + + updateProtocol(DataStore.serverProtocolVersion) + protocol.setOnPreferenceChangeListener { _, newValue -> + updateProtocol((newValue as String).toInt()) + true + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/profile/StandardV2RaySettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/StandardV2RaySettingsActivity.kt new file mode 100644 index 0000000..bd3d560 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/StandardV2RaySettingsActivity.kt @@ -0,0 +1,166 @@ +package io.nekohasekai.sagernet.ui.profile + +import android.os.Bundle +import androidx.preference.EditTextPreference +import androidx.preference.PreferenceCategory +import com.takisoft.preferencex.PreferenceFragmentCompat +import com.takisoft.preferencex.SimpleMenuPreference +import io.nekohasekai.sagernet.Key +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.preference.EditTextPreferenceModifiers +import io.nekohasekai.sagernet.fmt.http.HttpBean +import io.nekohasekai.sagernet.fmt.trojan.TrojanBean +import io.nekohasekai.sagernet.fmt.v2ray.StandardV2RayBean +import io.nekohasekai.sagernet.fmt.v2ray.VMessBean +import io.nekohasekai.sagernet.ktx.Logs +import moe.matsuri.nb4a.proxy.PreferenceBinding +import moe.matsuri.nb4a.proxy.PreferenceBindingManager +import moe.matsuri.nb4a.proxy.Type + +abstract class StandardV2RaySettingsActivity : ProfileSettingsActivity() { + + var tmpBean: StandardV2RayBean? = null + + private val pbm = PreferenceBindingManager() + private val name = pbm.add(PreferenceBinding(Type.Text, "name")) + private val serverAddress = pbm.add(PreferenceBinding(Type.Text, "serverAddress")) + private val serverPort = pbm.add(PreferenceBinding(Type.TextToInt, "serverPort")) + private val uuid = pbm.add(PreferenceBinding(Type.Text, "uuid")) + private val username = pbm.add(PreferenceBinding(Type.Text, "username")) + private val password = pbm.add(PreferenceBinding(Type.Text, "password")) + private val alterId = pbm.add(PreferenceBinding(Type.TextToInt, "alterId")) + private val encryption = pbm.add(PreferenceBinding(Type.Text, "encryption")) + private val type = pbm.add(PreferenceBinding(Type.Text, "type")) + private val host = pbm.add(PreferenceBinding(Type.Text, "host")) + private val path = pbm.add(PreferenceBinding(Type.Text, "path")) + private val packetEncoding = pbm.add(PreferenceBinding(Type.TextToInt, "packetEncoding")) + private val security = pbm.add(PreferenceBinding(Type.Text, "security")) + private val sni = pbm.add(PreferenceBinding(Type.Text, "sni")) + private val alpn = pbm.add(PreferenceBinding(Type.Text, "alpn")) + private val certificates = pbm.add(PreferenceBinding(Type.Text, "certificates")) + private val allowInsecure = pbm.add(PreferenceBinding(Type.Bool, "allowInsecure")) + private val utlsFingerprint = pbm.add(PreferenceBinding(Type.Text, "utlsFingerprint")) + private val realityPubKey = pbm.add(PreferenceBinding(Type.Text, "realityPubKey")) + private val realityShortId = pbm.add(PreferenceBinding(Type.Text, "realityShortId")) + + override fun StandardV2RayBean.init() { + if (this is VMessBean) { + if (intent?.getBooleanExtra("vless", false) == true) { + alterId = -1 + } + } else if (this is TrojanBean) { + this@StandardV2RaySettingsActivity.uuid.fieldName = "password" + this@StandardV2RaySettingsActivity.password.disable = true + } + + tmpBean = this // copy bean + pbm.writeToCacheAll(this) + } + + override fun StandardV2RayBean.serialize() { + pbm.fromCacheAll(this) + } + + lateinit var securityCategory: PreferenceCategory + + override fun PreferenceFragmentCompat.createPreferences( + savedInstanceState: Bundle?, + rootKey: String?, + ) { + addPreferencesFromResource(R.xml.standard_v2ray_preferences) + pbm.setPreferenceFragment(this) + securityCategory = findPreference(Key.SERVER_SECURITY_CATEGORY)!! + + serverPort.preference.apply { + this as EditTextPreference + setOnBindEditTextListener(EditTextPreferenceModifiers.Port) + } + + alterId.preference.apply { + this as EditTextPreference + setOnBindEditTextListener(EditTextPreferenceModifiers.Port) + } + + uuid.preference.summaryProvider = PasswordSummaryProvider + + type.preference.isVisible = tmpBean !is HttpBean + uuid.preference.isVisible = tmpBean !is HttpBean + packetEncoding.preference.isVisible = tmpBean !is HttpBean + alterId.preference.isVisible = tmpBean is VMessBean && tmpBean?.isVLESS == false + encryption.preference.isVisible = tmpBean is VMessBean + type.preference.isVisible = tmpBean !is HttpBean + username.preference.isVisible = tmpBean is HttpBean + password.preference.isVisible = tmpBean is HttpBean + + if (tmpBean is TrojanBean) { + uuid.preference.title = resources.getString(R.string.password) + } + + encryption.preference.apply { + this as SimpleMenuPreference + if (tmpBean!!.isVLESS) { + title = resources.getString(R.string.xtls_flow) + setIcon(R.drawable.ic_baseline_stream_24) + setEntries(R.array.xtls_flow_value) + setEntryValues(R.array.xtls_flow_value) + } else { + setEntries(R.array.vmess_encryption_value) + setEntryValues(R.array.vmess_encryption_value) + } + } + + // menu with listener + + type.preference.apply { + updateView(type.readStringFromCache()) + this as SimpleMenuPreference + setOnPreferenceChangeListener { _, newValue -> + updateView(newValue as String) + true + } + } + + security.preference.apply { + updateTle(security.readStringFromCache()) + this as SimpleMenuPreference + setOnPreferenceChangeListener { _, newValue -> + updateTle(newValue as String) + true + } + } + } + + fun updateView(network: String) { + host.preference.isVisible = false + path.preference.isVisible = false + + when (network) { + "tcp" -> { + host.preference.setTitle(R.string.http_host) + path.preference.setTitle(R.string.http_path) + } + "http" -> { + host.preference.setTitle(R.string.http_host) + path.preference.setTitle(R.string.http_path) + host.preference.isVisible = true + path.preference.isVisible = true + } + "ws" -> { + host.preference.setTitle(R.string.ws_host) + path.preference.setTitle(R.string.ws_path) + host.preference.isVisible = true + path.preference.isVisible = true + } + "grpc" -> { + path.preference.setTitle(R.string.grpc_service_name) + path.preference.isVisible = true + } + } + } + + fun updateTle(tle: String) { + val isTLS = tle == "tls" + securityCategory.isVisible = isTLS + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/profile/TrojanGoSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/TrojanGoSettingsActivity.kt new file mode 100644 index 0000000..45aa98d --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/TrojanGoSettingsActivity.kt @@ -0,0 +1,130 @@ +package io.nekohasekai.sagernet.ui.profile + +import android.os.Bundle +import androidx.preference.EditTextPreference +import androidx.preference.PreferenceCategory +import com.takisoft.preferencex.PreferenceFragmentCompat +import com.takisoft.preferencex.SimpleMenuPreference +import io.nekohasekai.sagernet.Key +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.preference.EditTextPreferenceModifiers +import io.nekohasekai.sagernet.fmt.trojan_go.TrojanGoBean +import io.nekohasekai.sagernet.ktx.app + +class TrojanGoSettingsActivity : ProfileSettingsActivity() { + + override fun createEntity() = TrojanGoBean() + + override fun TrojanGoBean.init() { + DataStore.profileName = name + DataStore.serverAddress = serverAddress + DataStore.serverPort = serverPort + DataStore.serverPassword = password + DataStore.serverSNI = sni + DataStore.serverAllowInsecure = allowInsecure + DataStore.serverNetwork = type + DataStore.serverHost = host + DataStore.serverPath = path + if (encryption.startsWith("ss;")) { + DataStore.serverEncryption = "ss" + DataStore.serverMethod = encryption.substringAfter(";").substringBefore(":") + DataStore.serverPassword1 = encryption.substringAfter(":") + } else { + DataStore.serverEncryption = encryption + } + } + + override fun TrojanGoBean.serialize() { + name = DataStore.profileName + serverAddress = DataStore.serverAddress + serverPort = DataStore.serverPort + password = DataStore.serverPassword + sni = DataStore.serverSNI + allowInsecure = DataStore.serverAllowInsecure + type = DataStore.serverNetwork + host = DataStore.serverHost + path = DataStore.serverPath + encryption = when (val security = DataStore.serverEncryption) { + "ss" -> { + "ss;" + DataStore.serverMethod + ":" + DataStore.serverPassword1 + } + else -> { + security + } + } + } + + lateinit var network: SimpleMenuPreference + lateinit var encryprtion: SimpleMenuPreference + lateinit var wsCategory: PreferenceCategory + lateinit var ssCategory: PreferenceCategory + lateinit var method: SimpleMenuPreference + + val trojanGoMethods = app.resources.getStringArray(R.array.trojan_go_methods) + val trojanGoNetworks = app.resources.getStringArray(R.array.trojan_go_networks_value) + + override fun PreferenceFragmentCompat.createPreferences( + savedInstanceState: Bundle?, + rootKey: String?, + ) { + addPreferencesFromResource(R.xml.trojan_go_preferences) + findPreference(Key.SERVER_PORT)!!.apply { + setOnBindEditTextListener(EditTextPreferenceModifiers.Port) + } + findPreference(Key.SERVER_PASSWORD)!!.apply { + summaryProvider = PasswordSummaryProvider + } + findPreference(Key.SERVER_PASSWORD1)!!.apply { + summaryProvider = PasswordSummaryProvider + } + wsCategory = findPreference(Key.SERVER_WS_CATEGORY)!! + ssCategory = findPreference(Key.SERVER_SS_CATEGORY)!! + method = findPreference(Key.SERVER_METHOD)!! + + network = findPreference(Key.SERVER_NETWORK)!! + + if (network.value !in trojanGoNetworks) { + network.value = trojanGoNetworks[0] + } + + updateNetwork(network.value) + network.setOnPreferenceChangeListener { _, newValue -> + updateNetwork(newValue as String) + true + } + encryprtion = findPreference(Key.SERVER_ENCRYPTION)!! + updateEncryption(encryprtion.value) + encryprtion.setOnPreferenceChangeListener { _, newValue -> + updateEncryption(newValue as String) + true + } + } + + fun updateNetwork(newNet: String) { + when (newNet) { + "ws" -> { + wsCategory.isVisible = true + } + else -> { + wsCategory.isVisible = false + } + } + } + + fun updateEncryption(encryption: String) { + when (encryption) { + "ss" -> { + ssCategory.isVisible = true + + if (method.value !in trojanGoMethods) { + method.value = trojanGoMethods[0] + } + } + else -> { + ssCategory.isVisible = false + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/profile/TrojanSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/TrojanSettingsActivity.kt new file mode 100644 index 0000000..6210ec5 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/TrojanSettingsActivity.kt @@ -0,0 +1,9 @@ +package io.nekohasekai.sagernet.ui.profile + +import io.nekohasekai.sagernet.fmt.trojan.TrojanBean + +class TrojanSettingsActivity : StandardV2RaySettingsActivity() { + + override fun createEntity() = TrojanBean() + +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/profile/TuicSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/TuicSettingsActivity.kt new file mode 100644 index 0000000..5f81a6c --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/TuicSettingsActivity.kt @@ -0,0 +1,72 @@ +package io.nekohasekai.sagernet.ui.profile + +import android.os.Bundle +import android.widget.Switch +import androidx.preference.EditTextPreference +import androidx.preference.SwitchPreference +import com.takisoft.preferencex.PreferenceFragmentCompat +import com.takisoft.preferencex.SimpleMenuPreference +import io.nekohasekai.sagernet.Key +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.fmt.tuic.TuicBean +import io.nekohasekai.sagernet.ktx.applyDefaultValues + +class TuicSettingsActivity : ProfileSettingsActivity() { + + override fun createEntity() = TuicBean().applyDefaultValues() + + override fun TuicBean.init() { + DataStore.profileName = name + DataStore.serverAddress = serverAddress + DataStore.serverPort = serverPort + DataStore.serverPassword = token + DataStore.serverALPN = alpn + DataStore.serverCertificates = caText + DataStore.serverUDPRelayMode = udpRelayMode + DataStore.serverCongestionController = congestionController + DataStore.serverDisableSNI = disableSNI + DataStore.serverSNI = sni + DataStore.serverReduceRTT = reduceRTT + DataStore.serverMTU = mtu + DataStore.serverFastConnect = fastConnect + DataStore.serverAllowInsecure = allowInsecure + } + + override fun TuicBean.serialize() { + name = DataStore.profileName + serverAddress = DataStore.serverAddress + serverPort = DataStore.serverPort + token = DataStore.serverPassword + alpn = DataStore.serverALPN + caText = DataStore.serverCertificates + udpRelayMode = DataStore.serverUDPRelayMode + congestionController = DataStore.serverCongestionController + disableSNI = DataStore.serverDisableSNI + sni = DataStore.serverSNI + reduceRTT = DataStore.serverReduceRTT + mtu = DataStore.serverMTU + fastConnect = DataStore.serverFastConnect + allowInsecure = DataStore.serverAllowInsecure + } + + override fun PreferenceFragmentCompat.createPreferences( + savedInstanceState: Bundle?, + rootKey: String?, + ) { + addPreferencesFromResource(R.xml.tuic_preferences) + + val disableSNI = findPreference(Key.SERVER_DISABLE_SNI)!! + val sni = findPreference(Key.SERVER_SNI)!! + sni.isEnabled = !disableSNI.isChecked + disableSNI.setOnPreferenceChangeListener { _, newValue -> + sni.isEnabled = !(newValue as Boolean) + true + } + + findPreference(Key.SERVER_PASSWORD)!!.apply { + summaryProvider = PasswordSummaryProvider + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/profile/VMessSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/VMessSettingsActivity.kt new file mode 100644 index 0000000..33d3e74 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/VMessSettingsActivity.kt @@ -0,0 +1,9 @@ +package io.nekohasekai.sagernet.ui.profile + +import io.nekohasekai.sagernet.fmt.v2ray.VMessBean + +class VMessSettingsActivity : StandardV2RaySettingsActivity() { + + override fun createEntity() = VMessBean() + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/profile/WireGuardSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/WireGuardSettingsActivity.kt new file mode 100644 index 0000000..0673aa7 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/WireGuardSettingsActivity.kt @@ -0,0 +1,49 @@ +package io.nekohasekai.sagernet.ui.profile + +import android.os.Bundle +import androidx.preference.EditTextPreference +import com.takisoft.preferencex.PreferenceFragmentCompat +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.preference.EditTextPreferenceModifiers +import io.nekohasekai.sagernet.fmt.wireguard.WireGuardBean +import moe.matsuri.nb4a.proxy.PreferenceBinding +import moe.matsuri.nb4a.proxy.PreferenceBindingManager +import moe.matsuri.nb4a.proxy.Type + +class WireGuardSettingsActivity : ProfileSettingsActivity() { + + override fun createEntity() = WireGuardBean() + + private val pbm = PreferenceBindingManager() + private val name = pbm.add(PreferenceBinding(Type.Text, "name")) + private val serverAddress = pbm.add(PreferenceBinding(Type.Text, "serverAddress")) + private val serverPort = pbm.add(PreferenceBinding(Type.TextToInt, "serverPort")) + private val localAddress = pbm.add(PreferenceBinding(Type.Text, "localAddress")) + private val privateKey = pbm.add(PreferenceBinding(Type.Text, "privateKey")) + private val peerPublicKey = pbm.add(PreferenceBinding(Type.Text, "peerPublicKey")) + private val peerPreSharedKey = pbm.add(PreferenceBinding(Type.Text, "peerPreSharedKey")) + private val mtu = pbm.add(PreferenceBinding(Type.TextToInt, "mtu")) + private val reserved = pbm.add(PreferenceBinding(Type.Text, "reserved")) + + override fun WireGuardBean.init() { + pbm.writeToCacheAll(this) + } + + override fun WireGuardBean.serialize() { + pbm.fromCacheAll(this) + } + + override fun PreferenceFragmentCompat.createPreferences( + savedInstanceState: Bundle?, + rootKey: String?, + ) { + addPreferencesFromResource(R.xml.wireguard_preferences) + pbm.setPreferenceFragment(this) + + (serverPort.preference as EditTextPreference) + .setOnBindEditTextListener(EditTextPreferenceModifiers.Port) + (privateKey.preference as EditTextPreference).summaryProvider = PasswordSummaryProvider + (mtu.preference as EditTextPreference).setOnBindEditTextListener(EditTextPreferenceModifiers.Number) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/utils/Cloudflare.kt b/app/src/main/java/io/nekohasekai/sagernet/utils/Cloudflare.kt new file mode 100644 index 0000000..eee031f --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/utils/Cloudflare.kt @@ -0,0 +1,72 @@ +package io.nekohasekai.sagernet.utils + +import com.wireguard.crypto.KeyPair +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.fmt.wireguard.WireGuardBean +import io.nekohasekai.sagernet.ktx.Logs +import io.nekohasekai.sagernet.utils.cf.DeviceResponse +import io.nekohasekai.sagernet.utils.cf.RegisterRequest +import io.nekohasekai.sagernet.utils.cf.UpdateDeviceRequest +import libcore.Libcore +import moe.matsuri.nb4a.utils.JavaUtil.gson + +// kang from wgcf +object Cloudflare { + + private const val API_URL = "https://api.cloudflareclient.com" + private const val API_VERSION = "v0a1922" + + private const val CLIENT_VERSION_KEY = "CF-Client-Version" + private const val CLIENT_VERSION = "a-6.3-1922" + + fun makeWireGuardConfiguration(): WireGuardBean { + val keyPair = KeyPair() + val client = Libcore.newHttpClient().apply { + pinnedTLS12() + trySocks5(DataStore.mixedPort) + } + + try { + val response = client.newRequest().apply { + setMethod("POST") + setURL("$API_URL/$API_VERSION/reg") + setHeader(CLIENT_VERSION_KEY, CLIENT_VERSION) + setHeader("Accept", "application/json") + setHeader("Content-Type", "application/json") + setContentString(RegisterRequest.newRequest(keyPair.publicKey)) + setUserAgent("okhttp/3.12.1") + }.execute() + + Logs.d(response.contentString) + val device = gson.fromJson(response.contentString, DeviceResponse::class.java) + val accessToken = device.token + + client.newRequest().apply { + setMethod("PATCH") + setURL(API_URL + "/" + API_VERSION + "/reg/" + device.id + "/account/reg/" + device.id) + setHeader("Accept", "application/json") + setHeader("Content-Type", "application/json") + setHeader("Authorization", "Bearer $accessToken") + setHeader(CLIENT_VERSION_KEY, CLIENT_VERSION) + setContentString(UpdateDeviceRequest.newRequest()) + setUserAgent("okhttp/3.12.1") + }.execute() + + val peer = device.config.peers[0] + val localAddresses = device.config.interfaceX.addresses + return WireGuardBean().apply { + name = "CloudFlare Warp ${device.account.id}" + privateKey = keyPair.privateKey.toBase64() + peerPublicKey = peer.publicKey + serverAddress = peer.endpoint.host.substringBeforeLast(":") + serverPort = peer.endpoint.host.substringAfterLast(":").toInt() + localAddress = localAddresses.v4 + "/32" + "\n" + localAddresses.v6 + "/128" + mtu = 1280 + reserved = device.config.clientId + } + } finally { + client.close() + } + } + +} \ 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 0000000..03c32f6 --- /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/CrashHandler.kt b/app/src/main/java/io/nekohasekai/sagernet/utils/CrashHandler.kt new file mode 100644 index 0000000..292f929 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/utils/CrashHandler.kt @@ -0,0 +1,172 @@ +package io.nekohasekai.sagernet.utils + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Build +import android.util.Log +import com.jakewharton.processphoenix.ProcessPhoenix +import io.nekohasekai.sagernet.BuildConfig +import io.nekohasekai.sagernet.database.preference.PublicDatabase +import io.nekohasekai.sagernet.ktx.Logs +import io.nekohasekai.sagernet.ktx.app +import io.nekohasekai.sagernet.ui.BlankActivity +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStreamReader +import java.text.SimpleDateFormat +import java.util.* +import java.util.regex.Pattern + +object CrashHandler : Thread.UncaughtExceptionHandler { + + @Suppress("UNNECESSARY_SAFE_CALL") + override fun uncaughtException(thread: Thread, throwable: Throwable) { + // note: libc / go panic is in android log + + try { + Log.e(thread.toString(), throwable.stackTraceToString()) + } catch (e: Exception) { + } + + try { + Logs.e(thread.toString()) + Logs.e(throwable.stackTraceToString()) + } catch (e: Exception) { + } + + ProcessPhoenix.triggerRebirth(app, Intent(app, BlankActivity::class.java).apply { + putExtra("sendLog", "NB4A Crash") + }) + } + + fun formatThrowable(throwable: Throwable): String { + var format = throwable.javaClass.name + val message = throwable.message + if (!message.isNullOrBlank()) { + format += ": $message" + } + format += "\n" + + format += throwable.stackTrace.joinToString("\n") { + " at ${it.className}.${it.methodName}(${it.fileName}:${if (it.isNativeMethod) "native" else it.lineNumber})" + } + + val cause = throwable.cause + if (cause != null) { + format += "\n\nCaused by: " + formatThrowable(cause) + } + + return format + } + + fun buildReportHeader(): String { + var report = "" + report += "NekoBox for Andoird ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) ${BuildConfig.FLAVOR.uppercase()}\n" + report += "Date: ${getCurrentMilliSecondUTCTimeStamp()}\n\n" + report += "OS_VERSION: ${getSystemPropertyWithAndroidAPI("os.version")}\n" + report += "SDK_INT: ${Build.VERSION.SDK_INT}\n" + report += if ("REL" == Build.VERSION.CODENAME) { + "RELEASE: ${Build.VERSION.RELEASE}" + } else { + "CODENAME: ${Build.VERSION.CODENAME}" + } + "\n" + report += "ID: ${Build.ID}\n" + report += "DISPLAY: ${Build.DISPLAY}\n" + report += "INCREMENTAL: ${Build.VERSION.INCREMENTAL}\n" + + val systemProperties = getSystemProperties() + + report += "SECURITY_PATCH: ${systemProperties.getProperty("ro.build.version.security_patch")}\n" + report += "IS_DEBUGGABLE: ${systemProperties.getProperty("ro.debuggable")}\n" + report += "IS_EMULATOR: ${systemProperties.getProperty("ro.boot.qemu")}\n" + report += "IS_TREBLE_ENABLED: ${systemProperties.getProperty("ro.treble.enabled")}\n" + + report += "TYPE: ${Build.TYPE}\n" + report += "TAGS: ${Build.TAGS}\n\n" + + report += "MANUFACTURER: ${Build.MANUFACTURER}\n" + report += "BRAND: ${Build.BRAND}\n" + report += "MODEL: ${Build.MODEL}\n" + report += "PRODUCT: ${Build.PRODUCT}\n" + report += "BOARD: ${Build.BOARD}\n" + report += "HARDWARE: ${Build.HARDWARE}\n" + report += "DEVICE: ${Build.DEVICE}\n" + report += "SUPPORTED_ABIS: ${ + Build.SUPPORTED_ABIS.filter { it.isNotBlank() }.joinToString(", ") + }\n\n" + + + try { + report += "Settings: \n" + for (pair in PublicDatabase.kvPairDao.all()) { + report += "\n" + report += pair.key + ": " + pair.toString() + } + }catch (e: Exception) { + report += "Export settings failed: " + formatThrowable(e) + } + + report += "\n\n" + + return report + } + + private fun getSystemProperties(): Properties { + val systemProperties = Properties() + + // getprop commands returns values in the format `[key]: [value]` + // Regex matches string starting with a literal `[`, + // followed by one or more characters that do not match a closing square bracket as the key, + // followed by a literal `]: [`, + // followed by one or more characters as the value, + // followed by string ending with literal `]` + // multiline values will be ignored + val propertiesPattern = Pattern.compile("^\\[([^]]+)]: \\[(.+)]$") + try { + val process = ProcessBuilder().command("/system/bin/getprop") + .redirectErrorStream(true) + .start() + val inputStream = process.inputStream + val bufferedReader = BufferedReader(InputStreamReader(inputStream)) + var line: String? + var key: String + var value: String + while (bufferedReader.readLine().also { line = it } != null) { + val matcher = propertiesPattern.matcher(line) + if (matcher.matches()) { + key = matcher.group(1) + value = matcher.group(2) + if (key != null && value != null && !key.isEmpty() && !value.isEmpty()) systemProperties[key] = value + } + } + bufferedReader.close() + process.destroy() + } catch (e: IOException) { + Logs.e( + "Failed to get run \"/system/bin/getprop\" to get system properties.", e + ) + } + + //for (String key : systemProperties.stringPropertyNames()) { + // Logger.logVerbose(key + ": " + systemProperties.get(key)); + //} + return systemProperties + } + + private fun getSystemPropertyWithAndroidAPI(property: String): String? { + return try { + System.getProperty(property) + } catch (e: Exception) { + Logs.e("Failed to get system property \"" + property + "\":" + e.message) + null + } + } + + @SuppressLint("SimpleDateFormat") + private fun getCurrentMilliSecondUTCTimeStamp(): String { + val df = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS z") + df.timeZone = TimeZone.getTimeZone("UTC") + return df.format(Date()) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/utils/DefaultNetworkListener.kt b/app/src/main/java/io/nekohasekai/sagernet/utils/DefaultNetworkListener.kt new file mode 100644 index 0000000..6af79fd --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/utils/DefaultNetworkListener.kt @@ -0,0 +1,154 @@ +package io.nekohasekai.sagernet.utils + +import android.annotation.TargetApi +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.os.Build +import android.os.Handler +import android.os.Looper +import io.nekohasekai.sagernet.SagerNet +import io.nekohasekai.sagernet.ktx.Logs +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.actor +import kotlinx.coroutines.runBlocking +import java.net.UnknownHostException + +object DefaultNetworkListener { + private sealed class NetworkMessage { + class Start(val key: Any, val listener: (Network?) -> Unit) : NetworkMessage() + class Get : NetworkMessage() { + val response = CompletableDeferred() + } + + class Stop(val key: Any) : NetworkMessage() + + class Put(val network: Network) : NetworkMessage() + class Update(val network: Network) : NetworkMessage() + class Lost(val network: Network) : NetworkMessage() + } + + private val networkActor = GlobalScope.actor(Dispatchers.Unconfined) { + val listeners = mutableMapOf Unit>() + var network: Network? = null + val pendingRequests = arrayListOf() + for (message in channel) when (message) { + is NetworkMessage.Start -> { + if (listeners.isEmpty()) register() + listeners[message.key] = message.listener + if (network != null) message.listener(network) + } + is NetworkMessage.Get -> { + check(listeners.isNotEmpty()) { "Getting network without any listeners is not supported" } + if (network == null) pendingRequests += message else message.response.complete( + network + ) + } + is NetworkMessage.Stop -> if (listeners.isNotEmpty() && // was not empty + listeners.remove(message.key) != null && listeners.isEmpty() + ) { + network = null + unregister() + } + + is NetworkMessage.Put -> { + network = message.network + pendingRequests.forEach { it.response.complete(message.network) } + pendingRequests.clear() + listeners.values.forEach { it(network) } + } + is NetworkMessage.Update -> if (network == message.network) listeners.values.forEach { + it( + network + ) + } + is NetworkMessage.Lost -> if (network == message.network) { + network = null + listeners.values.forEach { it(null) } + } + } + } + + suspend fun start(key: Any, listener: (Network?) -> Unit) = + networkActor.send(NetworkMessage.Start(key, listener)) + + suspend fun get() = if (fallback) @TargetApi(23) { + SagerNet.connectivity.activeNetwork + ?: throw UnknownHostException() // failed to listen, return current if available + } else NetworkMessage.Get().run { + networkActor.send(this) + response.await() + } + + suspend fun stop(key: Any) = networkActor.send(NetworkMessage.Stop(key)) + + // NB: this runs in ConnectivityThread, and this behavior cannot be changed until API 26 + private object Callback : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) = + runBlocking { networkActor.send(NetworkMessage.Put(network)) } + + override fun onCapabilitiesChanged( + network: Network, networkCapabilities: NetworkCapabilities + ) { // it's a good idea to refresh capabilities + runBlocking { networkActor.send(NetworkMessage.Update(network)) } + } + + override fun onLost(network: Network) = + runBlocking { networkActor.send(NetworkMessage.Lost(network)) } + } + + private var fallback = false + private val request = NetworkRequest.Builder().apply { + addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED) + if (Build.VERSION.SDK_INT == 23) { // workarounds for OEM bugs + removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + removeCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL) + } + }.build() + private val mainHandler = Handler(Looper.getMainLooper()) + + /** + * Unfortunately registerDefaultNetworkCallback is going to return VPN interface since Android P DP1: + * https://android.googlesource.com/platform/frameworks/base/+/dda156ab0c5d66ad82bdcf76cda07cbc0a9c8a2e + * + * This makes doing a requestNetwork with REQUEST necessary so that we don't get ALL possible networks that + * satisfies default network capabilities but only THE default network. Unfortunately, we need to have + * android.permission.CHANGE_NETWORK_STATE to be able to call requestNetwork. + * + * Source: https://android.googlesource.com/platform/frameworks/base/+/2df4c7d/services/core/java/com/android/server/ConnectivityService.java#887 + */ + private fun register() { + try { + fallback = false + when (Build.VERSION.SDK_INT) { + in 31..Int.MAX_VALUE -> @TargetApi(31) { + SagerNet.connectivity.registerBestMatchingNetworkCallback( + request, Callback, mainHandler + ) + } + in 28 until 31 -> @TargetApi(28) { // we want REQUEST here instead of LISTEN + SagerNet.connectivity.requestNetwork(request, Callback, mainHandler) + } + in 26 until 28 -> @TargetApi(26) { + SagerNet.connectivity.registerDefaultNetworkCallback(Callback, mainHandler) + } + in 24 until 26 -> @TargetApi(24) { + SagerNet.connectivity.registerDefaultNetworkCallback(Callback) + } + else -> { + SagerNet.connectivity.requestNetwork(request, Callback) + // known bug on API 23: https://stackoverflow.com/a/33509180/2245107 + } + } + } catch (e: Exception) { + Logs.w(e) + fallback = true + } + } + + private fun unregister() = SagerNet.connectivity.unregisterNetworkCallback(Callback) +} 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 0000000..fc4f632 --- /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/PackageCache.kt b/app/src/main/java/io/nekohasekai/sagernet/utils/PackageCache.kt new file mode 100644 index 0000000..136563d --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/utils/PackageCache.kt @@ -0,0 +1,97 @@ +package io.nekohasekai.sagernet.utils + +import android.Manifest +import android.annotation.SuppressLint +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import io.nekohasekai.sagernet.ktx.app +import io.nekohasekai.sagernet.ktx.listenForPackageChanges +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import moe.matsuri.nb4a.plugin.Plugins + +object PackageCache { + + lateinit var installedPackages: Map + lateinit var installedPluginPackages: Map + lateinit var installedApps: Map + lateinit var packageMap: Map + val uidMap = HashMap>() + val loaded = Mutex(true) + + // called from init (suspend) + fun register() { + reload() + app.listenForPackageChanges(false) { + reload() + labelMap.clear() + } + loaded.unlock() + } + + @SuppressLint("InlinedApi") + fun reload() { + val rawPackageInfo = app.packageManager.getInstalledPackages( + PackageManager.MATCH_UNINSTALLED_PACKAGES + or PackageManager.GET_PERMISSIONS + or PackageManager.GET_PROVIDERS + or PackageManager.GET_META_DATA + ) + + installedPackages = rawPackageInfo.filter { + when (it.packageName) { + "android" -> true + else -> it.requestedPermissions?.contains(Manifest.permission.INTERNET) == true + } + }.associateBy { it.packageName } + + installedPluginPackages = rawPackageInfo.filter { + Plugins.isExeOrPlugin(it) + }.associateBy { it.packageName } + + val installed = app.packageManager.getInstalledApplications(PackageManager.GET_META_DATA) + installedApps = installed.associateBy { it.packageName } + packageMap = installed.associate { it.packageName to it.uid } + uidMap.clear() + for (info in installed) { + val uid = info.uid + uidMap.getOrPut(uid) { HashSet() }.add(info.packageName) + } + } + + operator fun get(uid: Int) = uidMap[uid] + operator fun get(packageName: String) = packageMap[packageName] + + suspend fun awaitLoad() { + if (::packageMap.isInitialized) { + return + } + loaded.withLock { + // just await + } + } + + fun awaitLoadSync() { + if (::packageMap.isInitialized) { + return + } + runBlocking { + loaded.withLock { + // just await + } + } + } + + private val labelMap = mutableMapOf() + fun loadLabel(packageName: String): String { + var label = labelMap[packageName] + if (label != null) return label + val info = installedApps[packageName] ?: return packageName + label = info.loadLabel(app.packageManager).toString() + labelMap[packageName] = label + return label + } + +} \ No newline at end of file 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 0000000..80af40a --- /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/utils/Theme.kt b/app/src/main/java/io/nekohasekai/sagernet/utils/Theme.kt new file mode 100644 index 0000000..86457c4 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/utils/Theme.kt @@ -0,0 +1,135 @@ +package io.nekohasekai.sagernet.utils + +import android.content.Context +import android.content.res.Configuration +import androidx.appcompat.app.AppCompatDelegate +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.ktx.app + +object Theme { + + const val RED = 1 + const val PINK_SSR = 2 + const val PINK = 3 + const val PURPLE = 4 + const val DEEP_PURPLE = 5 + const val INDIGO = 6 + const val BLUE = 7 + const val LIGHT_BLUE = 8 + const val CYAN = 9 + const val TEAL = 10 + const val GREEN = 11 + const val LIGHT_GREEN = 12 + const val LIME = 13 + const val YELLOW = 14 + const val AMBER = 15 + const val ORANGE = 16 + const val DEEP_ORANGE = 17 + const val BROWN = 18 + const val GREY = 19 + const val BLUE_GREY = 20 + const val BLACK = 21 + + private fun defaultTheme() = PINK_SSR + + fun apply(context: Context) { + context.setTheme(getTheme()) + } + + fun applyDialog(context: Context) { + context.setTheme(getDialogTheme()) + } + + fun getTheme(): Int { + return getTheme(DataStore.appTheme) + } + + fun getDialogTheme(): Int { + return getDialogTheme(DataStore.appTheme) + } + + fun getTheme(theme: Int): Int { + return when (theme) { + RED -> R.style.Theme_SagerNet_Red + PINK -> R.style.Theme_SagerNet + PINK_SSR -> R.style.Theme_SagerNet_Pink_SSR + PURPLE -> R.style.Theme_SagerNet_Purple + DEEP_PURPLE -> R.style.Theme_SagerNet_DeepPurple + INDIGO -> R.style.Theme_SagerNet_Indigo + BLUE -> R.style.Theme_SagerNet_Blue + LIGHT_BLUE -> R.style.Theme_SagerNet_LightBlue + CYAN -> R.style.Theme_SagerNet_Cyan + TEAL -> R.style.Theme_SagerNet_Teal + GREEN -> R.style.Theme_SagerNet_Green + LIGHT_GREEN -> R.style.Theme_SagerNet_LightGreen + LIME -> R.style.Theme_SagerNet_Lime + YELLOW -> R.style.Theme_SagerNet_Yellow + AMBER -> R.style.Theme_SagerNet_Amber + ORANGE -> R.style.Theme_SagerNet_Orange + DEEP_ORANGE -> R.style.Theme_SagerNet_DeepOrange + BROWN -> R.style.Theme_SagerNet_Brown + GREY -> R.style.Theme_SagerNet_Grey + BLUE_GREY -> R.style.Theme_SagerNet_BlueGrey + BLACK -> R.style.Theme_SagerNet_Black + else -> getTheme(defaultTheme()) + } + } + + fun getDialogTheme(theme: Int): Int { + return when (theme) { + RED -> R.style.Theme_SagerNet_Dialog_Red + PINK -> R.style.Theme_SagerNet_Dialog + PINK_SSR -> R.style.Theme_SagerNet_Dialog_Pink_SSR + PURPLE -> R.style.Theme_SagerNet_Dialog_Purple + DEEP_PURPLE -> R.style.Theme_SagerNet_Dialog_DeepPurple + INDIGO -> R.style.Theme_SagerNet_Dialog_Indigo + BLUE -> R.style.Theme_SagerNet_Dialog_Blue + LIGHT_BLUE -> R.style.Theme_SagerNet_Dialog_LightBlue + CYAN -> R.style.Theme_SagerNet_Dialog_Cyan + TEAL -> R.style.Theme_SagerNet_Dialog_Teal + GREEN -> R.style.Theme_SagerNet_Dialog_Green + LIGHT_GREEN -> R.style.Theme_SagerNet_Dialog_LightGreen + LIME -> R.style.Theme_SagerNet_Dialog_Lime + YELLOW -> R.style.Theme_SagerNet_Dialog_Yellow + AMBER -> R.style.Theme_SagerNet_Dialog_Amber + ORANGE -> R.style.Theme_SagerNet_Dialog_Orange + DEEP_ORANGE -> R.style.Theme_SagerNet_Dialog_DeepOrange + BROWN -> R.style.Theme_SagerNet_Dialog_Brown + GREY -> R.style.Theme_SagerNet_Dialog_Grey + BLUE_GREY -> R.style.Theme_SagerNet_Dialog_BlueGrey + BLACK -> R.style.Theme_SagerNet_Dialog_Black + else -> getDialogTheme(defaultTheme()) + } + } + + var currentNightMode = -1 + fun getNightMode(): Int { + if (currentNightMode == -1) { + currentNightMode = DataStore.nightTheme + } + return getNightMode(currentNightMode) + } + + fun getNightMode(mode: Int): Int { + return when (mode) { + 0 -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + 1 -> AppCompatDelegate.MODE_NIGHT_YES + 2 -> AppCompatDelegate.MODE_NIGHT_NO + else -> AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY + } + } + + fun usingNightMode(): Boolean { + return when (DataStore.nightTheme) { + 1 -> true + 2 -> false + else -> (app.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + } + } + + fun applyNightTheme() { + AppCompatDelegate.setDefaultNightMode(getNightMode()) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/utils/cf/DeviceResponse.kt b/app/src/main/java/io/nekohasekai/sagernet/utils/cf/DeviceResponse.kt new file mode 100644 index 0000000..874304d --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/utils/cf/DeviceResponse.kt @@ -0,0 +1,114 @@ +package io.nekohasekai.sagernet.utils.cf + + +import com.google.gson.annotations.SerializedName + +data class DeviceResponse( + @SerializedName("created") + var created: String = "", + @SerializedName("type") + var type: String = "", + @SerializedName("locale") + var locale: String = "", + @SerializedName("enabled") + var enabled: Boolean = false, + @SerializedName("token") + var token: String = "", + @SerializedName("waitlist_enabled") + var waitlistEnabled: Boolean = false, + @SerializedName("install_id") + var installId: String = "", + @SerializedName("warp_enabled") + var warpEnabled: Boolean = false, + @SerializedName("name") + var name: String = "", + @SerializedName("fcm_token") + var fcmToken: String = "", + @SerializedName("tos") + var tos: String = "", + @SerializedName("model") + var model: String = "", + @SerializedName("id") + var id: String = "", + @SerializedName("place") + var place: Int = 0, + @SerializedName("config") + var config: Config = Config(), + @SerializedName("updated") + var updated: String = "", + @SerializedName("key") + var key: String = "", + @SerializedName("account") + var account: Account = Account() +) { + data class Config( + @SerializedName("peers") + var peers: List = listOf(), + @SerializedName("services") + var services: Services = Services(), + @SerializedName("interface") + var interfaceX: Interface = Interface(), + @SerializedName("client_id") + var clientId: String = "" + ) { + data class Peer( + @SerializedName("public_key") + var publicKey: String = "", + @SerializedName("endpoint") + var endpoint: Endpoint = Endpoint() + ) { + data class Endpoint( + @SerializedName("v6") + var v6: String = "", + @SerializedName("host") + var host: String = "", + @SerializedName("v4") + var v4: String = "" + ) + } + + data class Services( + @SerializedName("http_proxy") + var httpProxy: String = "" + ) + + data class Interface( + @SerializedName("addresses") + var addresses: Addresses = Addresses() + ) { + data class Addresses( + @SerializedName("v6") + var v6: String = "", + @SerializedName("v4") + var v4: String = "" + ) + } + } + + data class Account( + @SerializedName("account_type") + var accountType: String = "", + @SerializedName("role") + var role: String = "", + @SerializedName("referral_renewal_countdown") + var referralRenewalCountdown: Int = 0, + @SerializedName("created") + var created: String = "", + @SerializedName("usage") + var usage: Int = 0, + @SerializedName("warp_plus") + var warpPlus: Boolean = false, + @SerializedName("referral_count") + var referralCount: Int = 0, + @SerializedName("license") + var license: String = "", + @SerializedName("quota") + var quota: Int = 0, + @SerializedName("premium_data") + var premiumData: Int = 0, + @SerializedName("id") + var id: String = "", + @SerializedName("updated") + var updated: String = "" + ) +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/utils/cf/RegisterRequest.kt b/app/src/main/java/io/nekohasekai/sagernet/utils/cf/RegisterRequest.kt new file mode 100644 index 0000000..34bbe7c --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/utils/cf/RegisterRequest.kt @@ -0,0 +1,33 @@ +package io.nekohasekai.sagernet.utils.cf + +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import com.wireguard.crypto.Key +import java.text.SimpleDateFormat +import java.util.* + +data class RegisterRequest( + @SerializedName("fcm_token") var fcmToken: String = "", + @SerializedName("install_id") var installedId: String = "", + var key: String = "", + var locale: String = "", + var model: String = "", + var tos: String = "", + var type: String = "" +) { + + companion object { + fun newRequest(publicKey: Key): String { + val request = RegisterRequest() + request.fcmToken = "" + request.installedId = "" + request.key = publicKey.toBase64() + request.locale = "en_US" + request.model = "PC" + val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'000000'+08:00", Locale.US) + request.tos = format.format(Date()) + request.type = "Android" + return Gson().toJson(request) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/utils/cf/UpdateDeviceRequest.kt b/app/src/main/java/io/nekohasekai/sagernet/utils/cf/UpdateDeviceRequest.kt new file mode 100644 index 0000000..e12915b --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/utils/cf/UpdateDeviceRequest.kt @@ -0,0 +1,12 @@ +package io.nekohasekai.sagernet.utils.cf + +import com.google.gson.Gson + +data class UpdateDeviceRequest( + var name: String, var active: Boolean +) { + companion object { + fun newRequest(name: String = "SagerNet Client", active: Boolean = true) = + Gson().toJson(UpdateDeviceRequest(name, active)) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/AppListPreference.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/AppListPreference.kt new file mode 100644 index 0000000..14fd23e --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/widget/AppListPreference.kt @@ -0,0 +1,41 @@ +package io.nekohasekai.sagernet.widget + +import android.content.Context +import android.util.AttributeSet +import androidx.preference.Preference +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.ktx.app +import io.nekohasekai.sagernet.utils.PackageCache + +class AppListPreference : Preference { + + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context, attrs: AttributeSet?, defStyle: Int) : super( + context, attrs, defStyle + ) + + constructor( + context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int + ) : super(context, attrs, defStyleAttr, defStyleRes) + + override fun getSummary(): CharSequence { + val packages = DataStore.routePackages.split("\n").filter { it.isNotBlank() }.map { + PackageCache.installedPackages[it]?.applicationInfo?.loadLabel(app.packageManager) + ?: PackageCache.installedPluginPackages[it]?.applicationInfo?.loadLabel(app.packageManager) + ?: it + } + if (packages.isEmpty()) { + return context.getString(androidx.preference.R.string.not_set) + } + val count = packages.size + if (count <= 5) return packages.joinToString("\n") + return context.getString(R.string.apps_message, count) + } + + fun postUpdate() { + notifyChanged() + } + +} \ No newline at end of file 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 0000000..8b5b63a --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/widget/AutoCollapseTextView.kt @@ -0,0 +1,39 @@ +package io.nekohasekai.sagernet.widget + +import android.annotation.SuppressLint +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) { + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent?) = try { + super.onTouchEvent(event) + } catch (e: IndexOutOfBoundsException) { + false + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/FabProgressBehavior.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/FabProgressBehavior.kt new file mode 100644 index 0000000..ae0855e --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/widget/FabProgressBehavior.kt @@ -0,0 +1,29 @@ +package io.nekohasekai.sagernet.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import androidx.coordinatorlayout.widget.CoordinatorLayout +import com.google.android.material.progressindicator.CircularProgressIndicator + +class FabProgressBehavior(context: Context, attrs: AttributeSet?) : + CoordinatorLayout.Behavior(context, attrs) { + override fun layoutDependsOn( + parent: CoordinatorLayout, + child: CircularProgressIndicator, + dependency: View, + ): Boolean { + return dependency.id == (child.layoutParams as CoordinatorLayout.LayoutParams).anchorId + } + + override fun onLayoutChild( + parent: CoordinatorLayout, child: CircularProgressIndicator, + layoutDirection: Int, + ): Boolean { + val size = parent.getDependencies(child).single().measuredHeight + child.trackThickness + return if (child.indicatorSize != size) { + child.indicatorSize = size + true + } else false + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/GroupPreference.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/GroupPreference.kt new file mode 100644 index 0000000..e27dff9 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/widget/GroupPreference.kt @@ -0,0 +1,35 @@ +package io.nekohasekai.sagernet.widget + +import android.content.Context +import android.util.AttributeSet +import com.takisoft.preferencex.SimpleMenuPreference +import io.nekohasekai.sagernet.database.SagerDatabase + +class GroupPreference : SimpleMenuPreference { + + constructor(context: Context?) : super(context) + constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context?, attrs: AttributeSet?, defStyle: Int) : super( + context, attrs, defStyle + ) + + constructor( + context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int + ) : super(context, attrs, defStyleAttr, defStyleRes) + + init { + val groups = SagerDatabase.groupDao.allGroups() + + entries = groups.map { it.displayName() }.toTypedArray() + entryValues = groups.map { "${it.id}" }.toTypedArray() + } + + override fun getSummary(): CharSequence? { + if (!value.isNullOrBlank() && value != "0") { + return SagerDatabase.groupDao.getById(value.toLong())?.displayName() + ?: super.getSummary() + } + return super.getSummary() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/LinkOrContentPreference.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/LinkOrContentPreference.kt new file mode 100644 index 0000000..8fdf1f2 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/widget/LinkOrContentPreference.kt @@ -0,0 +1,64 @@ +package io.nekohasekai.sagernet.widget + +import android.content.Context +import android.net.Uri +import android.util.AttributeSet +import androidx.core.widget.addTextChangedListener +import com.google.android.material.textfield.TextInputLayout +import com.takisoft.preferencex.EditTextPreference +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.ktx.app +import io.nekohasekai.sagernet.ktx.readableMessage +import okhttp3.HttpUrl.Companion.toHttpUrl + +class LinkOrContentPreference : EditTextPreference { + + constructor(context: Context?) : super(context) + constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super( + context, attrs, defStyleAttr + ) + + constructor( + context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int + ) : super(context, attrs, defStyleAttr, defStyleRes) + + + init { + dialogLayoutResource = R.layout.layout_link_dialog + + setOnBindEditTextListener { + val linkLayout = it.rootView.findViewById(R.id.input_layout) + fun validate() { + val link = it.text + if (link.isBlank()) { + linkLayout.isErrorEnabled = false + return + } + + try { + if (Uri.parse(link.toString()).scheme == "content") { + linkLayout.isErrorEnabled = false + return + } + val url = link.toString().toHttpUrl() + if ("http".equals(url.scheme, true)) { + linkLayout.error = app.getString(R.string.cleartext_http_warning) + linkLayout.isErrorEnabled = true + } else { + linkLayout.isErrorEnabled = false + } + } catch (e: Exception) { + linkLayout.error = e.readableMessage + linkLayout.isErrorEnabled = true + } + + } + validate() + it.addTextChangedListener { + validate() + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/LinkPreference.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/LinkPreference.kt new file mode 100644 index 0000000..9397669 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/widget/LinkPreference.kt @@ -0,0 +1,93 @@ +package io.nekohasekai.sagernet.widget + +import android.content.Context +import android.util.AttributeSet +import androidx.core.widget.addTextChangedListener +import com.google.android.material.textfield.TextInputLayout +import com.takisoft.preferencex.EditTextPreference +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.ktx.app +import io.nekohasekai.sagernet.ktx.readableMessage +import okhttp3.HttpUrl.Companion.toHttpUrl + +class LinkPreference : EditTextPreference { + + var defaultValue: String? = null + + constructor(context: Context) : this(context, null) + + constructor( + context: Context, + attrs: AttributeSet?, + ) : this(context, attrs, com.takisoft.preferencex.R.attr.editTextPreferenceStyle) + + constructor( + context: Context, + attrs: AttributeSet?, + defStyleAttr: Int, + ) : this(context, attrs, defStyleAttr, 0) + + constructor( + context: Context, + attrs: AttributeSet?, + defStyleAttr: Int, + defStyleRes: Int, + ) : super(context, attrs, defStyleAttr, defStyleRes) { + val a = context.obtainStyledAttributes( + attrs, R.styleable.Preference, defStyleAttr, defStyleRes + ) + if (a.hasValue(androidx.preference.R.styleable.Preference_defaultValue)) { + defaultValue = onGetDefaultValue( + a, androidx.preference.R.styleable.Preference_defaultValue + )?.toString() + } else if (a.hasValue(androidx.preference.R.styleable.Preference_android_defaultValue)) { + defaultValue = onGetDefaultValue( + a, androidx.preference.R.styleable.Preference_android_defaultValue + )?.toString() + } + } + + init { + dialogLayoutResource = R.layout.layout_link_dialog + + setOnBindEditTextListener { + val linkLayout = it.rootView.findViewById(R.id.input_layout) + fun validate() { + val link = it.text + if (link.isBlank()) { + linkLayout.isErrorEnabled = false + return + } + try { + val url = link.toString().toHttpUrl() + if ("http".equals(url.scheme, true)) { + linkLayout.error = app.getString(R.string.cleartext_http_warning) + linkLayout.isErrorEnabled = true + } else { + linkLayout.isErrorEnabled = false + } + } catch (e: Exception) { + linkLayout.error = e.readableMessage + linkLayout.isErrorEnabled = true + } + } + validate() + it.addTextChangedListener { + validate() + } + } + + setOnPreferenceChangeListener { _, newValue -> + if ((newValue as String).isBlank()) { + text = defaultValue + false + } else try { + newValue.toHttpUrl() + true + } catch (ignored: Exception) { + false + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/OutboundPreference.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/OutboundPreference.kt new file mode 100644 index 0000000..6adf26b --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/widget/OutboundPreference.kt @@ -0,0 +1,44 @@ +package io.nekohasekai.sagernet.widget + +import android.content.Context +import android.util.AttributeSet +import com.takisoft.preferencex.SimpleMenuPreference +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.ProfileManager + +class OutboundPreference : SimpleMenuPreference { + + constructor(context: Context?) : super(context) + constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context?, attrs: AttributeSet?, defStyle: Int) : super( + context, + attrs, + defStyle + ) + + constructor( + context: Context?, + attrs: AttributeSet?, + defStyleAttr: Int, + defStyleRes: Int + ) : super(context, attrs, defStyleAttr, defStyleRes) + + init { + setEntries(R.array.outbound_entry) + setEntryValues(R.array.outbound_value) + } + + override fun getSummary(): CharSequence? { + if (value == "3") { + val routeOutbound = DataStore.routeOutboundRule + if (routeOutbound > 0) { + ProfileManager.getProfile(routeOutbound)?.displayName()?.let { + return it + } + } + } + return super.getSummary() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/QRCodeDialog.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/QRCodeDialog.kt new file mode 100644 index 0000000..fa72f90 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/widget/QRCodeDialog.kt @@ -0,0 +1,103 @@ +package io.nekohasekai.sagernet.widget + +import android.graphics.Bitmap +import android.graphics.Color +import android.os.Bundle +import android.util.DisplayMetrics +import android.view.Gravity +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.MultiFormatWriter +import com.google.zxing.WriterException +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.ktx.Logs +import io.nekohasekai.sagernet.ktx.readableMessage +import io.nekohasekai.sagernet.ui.MainActivity +import java.nio.charset.StandardCharsets +import kotlin.math.roundToInt + +class QRCodeDialog() : DialogFragment() { + + companion object { + private const val KEY_URL = "io.nekohasekai.sagernet.QRCodeDialog.KEY_URL" + private const val KEY_NAME = "io.nekohasekai.sagernet.QRCodeDialog.KEY_NAME" + private val iso88591 = StandardCharsets.ISO_8859_1.newEncoder() + } + + constructor(url: String, displayName: String) : this() { + arguments = bundleOf( + Pair(KEY_URL, url), Pair(KEY_NAME, displayName) + ) + } + + /** + * Based on: + * https://android.googlesource.com/platform/ + packages/apps/Settings/+/0d706f0/src/com/android/settings/wifi/qrcode/QrCodeGenerator.java + * https://android.googlesource.com/platform/ + packages/apps/Settings/+/8a9ccfd/src/com/android/settings/wifi/dpp/WifiDppQrCodeGeneratorFragment.java#153 + */ + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ) = try { + // get display size + var pixelMin = 0 + + try { + val displayMetrics: DisplayMetrics = requireContext().resources.displayMetrics + val height: Int = displayMetrics.heightPixels + val width: Int = displayMetrics.widthPixels + pixelMin = if (height > width) width else height + pixelMin = (pixelMin * 0.8).roundToInt() + } catch (e: Exception) { + } + + val size = if (pixelMin > 0) pixelMin else resources.getDimensionPixelSize(R.dimen.qrcode_size) + + // draw QR Code + val url = arguments?.getString(KEY_URL)!! + val displayName = arguments?.getString(KEY_NAME)!! + + val hints = mutableMapOf() + if (!iso88591.canEncode(url)) hints[EncodeHintType.CHARACTER_SET] = StandardCharsets.UTF_8.name() + val qrBits = MultiFormatWriter().encode(url, BarcodeFormat.QR_CODE, size, size, hints) + LinearLayout(context).apply { + // Layout + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + + // QR Code Image View + addView(ImageView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT + ) + setImageBitmap(Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565).apply { + for (x in 0 until size) for (y in 0 until size) { + setPixel(x, y, if (qrBits.get(x, y)) Color.BLACK else Color.WHITE) + } + }) + }) + + // Text View + addView(TextView(context).apply { + gravity = Gravity.CENTER + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT + ) + text = displayName + }) + } + } catch (e: WriterException) { + Logs.w(e) + (activity as MainActivity).snackbar(e.readableMessage).show() + dismiss() + null + } +} \ No newline at end of file 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 0000000..b86659e --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/widget/ServiceButton.kt @@ -0,0 +1,150 @@ +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.dynamicanimation.animation.DynamicAnimation +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.vectordrawable.graphics.drawable.Animatable2Compat +import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat +import com.google.android.material.floatingactionbutton.FloatingActionButton +import com.google.android.material.progressindicator.BaseProgressIndicator +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.bg.BaseService +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import java.util.* + +class ServiceButton @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : + FloatingActionButton(context, attrs, defStyleAttr), DynamicAnimation.OnAnimationEndListener { + + private val callback = object : Animatable2Compat.AnimationCallback() { + override fun onAnimationEnd(drawable: Drawable) { + super.onAnimationEnd(drawable) + var next = animationQueue.peek() ?: return + if (next.icon.current == drawable) { + animationQueue.pop() + next = animationQueue.peek() ?: return + } + next.start() + } + } + + private inner class AnimatedState( + @DrawableRes resId: Int, + private val onStart: BaseProgressIndicator<*>.() -> Unit = { hideProgress() } + ) { + val icon: AnimatedVectorDrawableCompat = + AnimatedVectorDrawableCompat.create(context, resId)!!.apply { + registerAnimationCallback(this@ServiceButton.callback) + } + + fun start() { + setImageDrawable(icon) + icon.start() + progress.onStart() + } + + fun stop() = icon.stop() + } + + private val iconStopped by lazy { AnimatedState(R.drawable.ic_service_stopped) } + private val iconConnecting by lazy { + AnimatedState(R.drawable.ic_service_connecting) { + hideProgress() + delayedAnimation = (context as LifecycleOwner).lifecycleScope.launchWhenStarted { + delay(context.resources.getInteger(android.R.integer.config_mediumAnimTime) + 1000L) + isIndeterminate = true + show() + } + } + } + private val iconConnected by lazy { + AnimatedState(R.drawable.ic_service_connected) { + delayedAnimation?.cancel() + setProgressCompat(1, true) + } + } + private val iconStopping by lazy { AnimatedState(R.drawable.ic_service_stopping) } + private val animationQueue = ArrayDeque() + + private var checked = false + private var delayedAnimation: Job? = null + private lateinit var progress: BaseProgressIndicator<*> + fun initProgress(progress: BaseProgressIndicator<*>) { + this.progress = progress + progress.progressDrawable?.addSpringAnimationEndListener(this) + } + + override fun onAnimationEnd( + animation: DynamicAnimation>?, canceled: Boolean, value: Float, + velocity: Float + ) { + if (!canceled) progress.hide() + } + + private fun hideProgress() { + delayedAnimation?.cancel() + progress.hide() + } + + 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: AnimatedState, animate: Boolean) { + fun counters(a: AnimatedState, b: AnimatedState): 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) icon.start() + } else animationQueue.removeLast() + } else { + animationQueue.peekFirst()?.stop() + animationQueue.clear() + 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 0000000..2116c23 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt @@ -0,0 +1,161 @@ +package io.nekohasekai.sagernet.widget + +import android.annotation.SuppressLint +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.database.DataStore +import io.nekohasekai.sagernet.ktx.* +import io.nekohasekai.sagernet.ui.MainActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +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: YourBehavior + + var allowShow = true + + override fun getBehavior(): YourBehavior { + if (!this::behavior.isInitialized) behavior = YourBehavior { allowShow } + return behavior + } + + class YourBehavior(val getAllowShow: () -> Boolean) : 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 + ) + } + + override fun slideUp(child: BottomAppBar) { + if (!getAllowShow()) return + super.slideUp(child) + } + + override fun slideDown(child: BottomAppBar) { + if (!getAllowShow()) return + super.slideDown(child) + } + } + + + 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) { + delay(100L) + activity.whenStarted { what() } + } + if ((state == BaseService.State.Connected).also { hideOnScroll = it }) { + postWhenStarted { + if (allowShow) performShow() + setStatus(app.getText(R.string.vpn_connected)) + } + } else { + postWhenStarted { + performHide() + } + updateSpeed(0, 0) + setStatus( + context.getText( + when (state) { + BaseService.State.Connecting -> R.string.connecting + BaseService.State.Stopping -> R.string.stopping + else -> R.string.not_connected + } + ) + ) + } + } + + @SuppressLint("SetTextI18n") + fun updateSpeed(txRate: Long, rxRate: Long) { + txText.text = "▲ ${ + context.getString( + R.string.speed, Formatter.formatFileSize(context, txRate) + ) + }" + rxText.text = "▼ ${ + context.getString( + R.string.speed, Formatter.formatFileSize(context, rxRate) + ) + }" + } + + fun testConnection() { + val activity = context as MainActivity + isEnabled = false + setStatus(app.getText(R.string.connection_test_testing)) + runOnDefaultDispatcher { + try { + val elapsed = activity.urlTest() + onMainDispatcher { + isEnabled = true + setStatus( + app.getString( + if (DataStore.connectionTestURL.startsWith("https://")) { + R.string.connection_test_available + } else { + R.string.connection_test_available_http + }, elapsed + ) + ) + } + + } catch (e: Exception) { + Logs.w(e.toString()) + onMainDispatcher { + isEnabled = true + setStatus(app.getText(R.string.connection_test_testing)) + + activity.snackbar( + app.getString( + R.string.connection_test_error, e.readableMessage + ) + ).show() + } + } + } + } + +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/UndoSnackbarManager.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/UndoSnackbarManager.kt new file mode 100644 index 0000000..ad6e129 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/widget/UndoSnackbarManager.kt @@ -0,0 +1,55 @@ +package io.nekohasekai.sagernet.widget + +import com.google.android.material.snackbar.Snackbar +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.ui.ThemedActivity + +/** + * @param activity ThemedActivity. + * //@param view The view to find a parent from. + * @param undo Callback for undoing removals. + * @param commit Callback for committing removals. + * @tparam T Item type. + */ +class UndoSnackbarManager( + private val activity: ThemedActivity, + private val callback: Interface, +) { + + interface Interface { + fun undo(actions: List>) + fun commit(actions: List>) + } + + private val recycleBin = ArrayList>() + private val removedCallback = object : Snackbar.Callback() { + override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { + if (last === transientBottomBar && event != DISMISS_EVENT_ACTION) { + callback.commit(recycleBin) + recycleBin.clear() + last = null + } + } + } + + private var last: Snackbar? = null + + fun remove(items: Collection>) { + recycleBin.addAll(items) + val count = recycleBin.size + activity.snackbar(activity.resources.getQuantityString(R.plurals.removed, count, count)) + .apply { + addCallback(removedCallback) + setAction(R.string.undo) { + callback.undo(recycleBin.reversed()) + recycleBin.clear() + } + last = this + show() + } + } + + fun remove(vararg items: Pair) = remove(items.toList()) + + fun flush() = last?.dismiss() +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/UserAgentPreference.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/UserAgentPreference.kt new file mode 100644 index 0000000..2ec9ffc --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/widget/UserAgentPreference.kt @@ -0,0 +1,31 @@ +package io.nekohasekai.sagernet.widget + +import android.content.Context +import android.util.AttributeSet +import com.takisoft.preferencex.EditTextPreference +import io.nekohasekai.sagernet.ktx.USER_AGENT + +class UserAgentPreference : EditTextPreference { + + constructor(context: Context?) : super(context) + constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context?, attrs: AttributeSet?, defStyle: Int) : super( + context, attrs, defStyle + ) + + constructor( + context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int + ) : super(context, attrs, defStyleAttr, defStyleRes) + + public override fun notifyChanged() { + super.notifyChanged() + } + + override fun getSummary(): CharSequence? { + if (text.isNullOrBlank()) { + return USER_AGENT + } + return super.getSummary() + } + +} \ No newline at end of file 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 0000000..fc35dd0 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/widget/WindowInsetsListeners.kt @@ -0,0 +1,40 @@ +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/java/moe/matsuri/nb4a/DNS.kt b/app/src/main/java/moe/matsuri/nb4a/DNS.kt new file mode 100644 index 0000000..db9902b --- /dev/null +++ b/app/src/main/java/moe/matsuri/nb4a/DNS.kt @@ -0,0 +1,74 @@ +package moe.matsuri.nb4a + +import io.nekohasekai.sagernet.database.DataStore + +object DNS { + fun SingBoxOptions.DNSServerOptions.applyDNSNetworkSettings(isDirect: Boolean) { + if (isDirect) { + if (DataStore.dnsNetwork.contains("NoDirectIPv4")) this.strategy = "ipv6_only" + if (DataStore.dnsNetwork.contains("NoDirectIPv6")) this.strategy = "ipv4_only" + } else { + if (DataStore.dnsNetwork.contains("NoRemoteIPv4")) this.strategy = "ipv6_only" + if (DataStore.dnsNetwork.contains("NoRemoteIPv6")) this.strategy = "ipv4_only" + } + } + + fun SingBoxOptions.DNSRule_DefaultOptions.makeSingBoxRule(list: List) { + geosite = mutableListOf() + domain = mutableListOf() + domain_suffix = mutableListOf() + domain_regex = mutableListOf() + domain_keyword = mutableListOf() + list.forEach { + if (it.startsWith("geosite:")) { + geosite.plusAssign(it.removePrefix("geosite:")) + } else if (it.startsWith("full:")) { + domain.plusAssign(it.removePrefix("full:")) + } else if (it.startsWith("domain:")) { + domain_suffix.plusAssign(it.removePrefix("domain:")) + } else if (it.startsWith("regexp:")) { + domain_regex.plusAssign(it.removePrefix("regexp:")) + } else if (it.startsWith("keyword:")) { + domain_keyword.plusAssign(it.removePrefix("keyword:")) + } else { + domain.plusAssign(it) + } + } + } + + fun SingBoxOptions.Rule_DefaultOptions.makeSingBoxRule(list: List, isIP: Boolean) { + if (isIP) { + ip_cidr = mutableListOf() + geoip = mutableListOf() + } else { + geosite = mutableListOf() + domain = mutableListOf() + domain_suffix = mutableListOf() + domain_regex = mutableListOf() + domain_keyword = mutableListOf() + } + list.forEach { + if (isIP) { + if (it.startsWith("geoip:")) { + geoip.plusAssign(it.removePrefix("geoip:")) + } else { + ip_cidr.plusAssign(it) + } + return@forEach + } + if (it.startsWith("geosite:")) { + geosite.plusAssign(it.removePrefix("geosite:")) + } else if (it.startsWith("full:")) { + domain.plusAssign(it.removePrefix("full:")) + } else if (it.startsWith("domain:")) { + domain_suffix.plusAssign(it.removePrefix("domain:")) + } else if (it.startsWith("regexp:")) { + domain_regex.plusAssign(it.removePrefix("regexp:")) + } else if (it.startsWith("keyword:")) { + domain_keyword.plusAssign(it.removePrefix("keyword:")) + } else { + domain.plusAssign(it) + } + } + } +} diff --git a/app/src/main/java/moe/matsuri/nb4a/Protocols.kt b/app/src/main/java/moe/matsuri/nb4a/Protocols.kt new file mode 100644 index 0000000..8148511 --- /dev/null +++ b/app/src/main/java/moe/matsuri/nb4a/Protocols.kt @@ -0,0 +1,83 @@ +package moe.matsuri.nb4a + +import android.content.Context +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.ProxyEntity.Companion.TYPE_NEKO +import io.nekohasekai.sagernet.fmt.AbstractBean +import io.nekohasekai.sagernet.ktx.app +import io.nekohasekai.sagernet.ktx.getColorAttr +import moe.matsuri.nb4a.plugin.NekoPluginManager + +// Settings for all protocols, built-in or plugin +object Protocols { + // Mux + + fun shouldEnableMux(protocol: String): Boolean { + return DataStore.muxProtocols.contains(protocol) + } + + fun getCanMuxList(): List { + // built-in and support mux + // sing-box support ss & vmess & trojan smux + val list = mutableListOf("vmess", "trojan", "trojan-go", "shadowsocks") + + NekoPluginManager.getProtocols().forEach { + if (it.protocolConfig.optBoolean("canMux")) { + list.add(it.protocolId) + } + } + + return list + } + + // Deduplication + + class Deduplication( + val bean: AbstractBean + ) { + + fun hash(): String { + return bean.serverAddress + bean.serverPort + } + + override fun hashCode(): Int { + return hash().toByteArray().contentHashCode() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Deduplication + + return hash() == other.hash() + } + + } + + // Display + + fun Context.getProtocolColor(type: Int): Int { + return when (type) { + TYPE_NEKO -> getColorAttr(android.R.attr.textColorPrimary) + else -> getColorAttr(R.attr.accentOrTextSecondary) + } + } + + // Test + + fun genFriendlyMsg(msg: String): String { + val msgL = msg.lowercase() + return when { + msgL.contains("timeout") || msgL.contains("deadline") -> { + app.getString(R.string.connection_test_timeout) + } + msgL.contains("refused") || msgL.contains("closed pipe") -> { + app.getString(R.string.connection_test_refused) + } + else -> msg + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java b/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java new file mode 100644 index 0000000..a57466c --- /dev/null +++ b/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java @@ -0,0 +1,3873 @@ +package moe.matsuri.nb4a; + +import static moe.matsuri.nb4a.utils.JavaUtil.gson; + +import com.google.gson.annotations.SerializedName; + +import java.util.List; +import java.util.Map; + +public class SingBoxOptions { + + // base + + public static class SingBoxOption { + public Map asMap() { + return gson.fromJson(gson.toJson(this), Map.class); + } + } + + // custom classes + + public static class User { + public String username; + public String password; + } + + public static class MyOptions extends SingBoxOption { + public LogOptions log; + + public DNSOptions dns; + + public NTPOptions ntp; + + public List inbounds; + + public List> outbounds; + + public RouteOptions route; + + public ExperimentalOptions experimental; + } + + public static class Outbound_WireGuardOptions_Fix extends Outbound { + + // Generate note: nested type DialerOptions + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + + public String domain_strategy; + + public Long fallback_delay; + + // End of public DialerOptions ; + + // Generate note: nested type ServerOptions + public String server; + + public Integer server_port; + + // End of public ServerOptions ; + + public Boolean system_interface; + + public String interface_name; + + // Generate note: Listable + public List local_address; + + public String private_key; + + public String peer_public_key; + + public String pre_shared_key; + + public String reserved; // fixed, can fill a base64 str + + public Integer workers; + + public Integer mtu; + + public String network; + + } + + // paste generate output here + + public static class ClashAPIOptions extends SingBoxOption { + + public String external_controller; + + public String external_ui; + + public String secret; + + public String default_mode; + + public Boolean store_selected; + + public String cache_file; + + } + + public static class SelectorOutboundOptions extends SingBoxOption { + + public List outbounds; + + @SerializedName("default") + public String default_; + + } + + public static class URLTestOutboundOptions extends SingBoxOption { + + public List outbounds; + + public String url; + + public Long interval; + + public Integer tolerance; + + } + + + public static class Options extends SingBoxOption { + + public LogOptions log; + + public DNSOptions dns; + + public NTPOptions ntp; + + public List inbounds; + + public List outbounds; + + public RouteOptions route; + + public ExperimentalOptions experimental; + + } + + public static class LogOptions extends SingBoxOption { + + public Boolean disabled; + + public String level; + + public String output; + + public Boolean timestamp; + + // Generate note: option type: public Boolean DisableColor; + + } + + public static class DirectInboundOptions extends SingBoxOption { + + // Generate note: nested type ListenOptions + public String listen; + + public Integer listen_port; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + // Generate note: option type: public Boolean UDPFragmentDefault; + + public Long udp_timeout; + + public Boolean proxy_protocol; + + public Boolean proxy_protocol_accept_no_header; + + public String detour; + + // Generate note: nested type InboundOptions + public Boolean sniff; + + public Boolean sniff_override_destination; + + public Long sniff_timeout; + + public String domain_strategy; + + // End of public InboundOptions ; + + // End of public ListenOptions ; + + public String network; + + public String override_address; + + public Integer override_port; + + } + + public static class DirectOutboundOptions extends SingBoxOption { + + // Generate note: nested type DialerOptions + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + // Generate note: option type: public Boolean UDPFragmentDefault; + + public String domain_strategy; + + public Long fallback_delay; + + // End of public DialerOptions ; + + public String override_address; + + public Integer override_port; + + public Integer proxy_protocol; + + } + + public static class DNSOptions extends SingBoxOption { + + public List servers; + + public List rules; + + @SerializedName("final") + public String final_; + + // Generate note: nested type DNSClientOptions + public String strategy; + + public Boolean disable_cache; + + public Boolean disable_expire; + + // End of public DNSClientOptions ; + + } + + public static class DNSClientOptions extends SingBoxOption { + + public String strategy; + + public Boolean disable_cache; + + public Boolean disable_expire; + + } + + public static class DNSServerOptions extends SingBoxOption { + + public String tag; + + public String address; + + public String address_resolver; + + public String address_strategy; + + public Long address_fallback_delay; + + public String strategy; + + public String detour; + + } + + + public static class DNSRule extends SingBoxOption { + + public String type; + + // Generate note: option type: public DefaultDNSRule DefaultOptions; + + // Generate note: option type: public LogicalDNSRule LogicalOptions; + + } + + public static class DefaultDNSRule extends SingBoxOption { + + // Generate note: Listable + public List inbound; + + public Integer ip_version; + + // Generate note: Listable + public List query_type; + + public String network; + + // Generate note: Listable + public List auth_user; + + // Generate note: Listable + public List protocol; + + // Generate note: Listable + public List domain; + + // Generate note: Listable + public List domain_suffix; + + // Generate note: Listable + public List domain_keyword; + + // Generate note: Listable + public List domain_regex; + + // Generate note: Listable + public List geosite; + + // Generate note: Listable + public List source_geoip; + + // Generate note: Listable + public List source_ip_cidr; + + // Generate note: Listable + public List source_port; + + // Generate note: Listable + public List source_port_range; + + // Generate note: Listable + public List port; + + // Generate note: Listable + public List port_range; + + // Generate note: Listable + public List process_name; + + // Generate note: Listable + public List process_path; + + // Generate note: Listable + public List package_name; + + // Generate note: Listable + public List user; + + // Generate note: Listable + public List user_id; + + // Generate note: Listable + public List outbound; + + public String clash_mode; + + public Boolean invert; + + public String server; + + public Boolean disable_cache; + + } + + public static class LogicalDNSRule extends SingBoxOption { + + public String mode; + + public List rules; + + public Boolean invert; + + public String server; + + public Boolean disable_cache; + + } + + public static class ExperimentalOptions extends SingBoxOption { + + public ClashAPIOptions clash_api; + + public V2RayAPIOptions v2ray_api; + + } + + public static class HysteriaInboundOptions extends SingBoxOption { + + // Generate note: nested type ListenOptions + public String listen; + + public Integer listen_port; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + // Generate note: option type: public Boolean UDPFragmentDefault; + + public Long udp_timeout; + + public Boolean proxy_protocol; + + public Boolean proxy_protocol_accept_no_header; + + public String detour; + + // Generate note: nested type InboundOptions + public Boolean sniff; + + public Boolean sniff_override_destination; + + public Long sniff_timeout; + + public String domain_strategy; + + // End of public InboundOptions ; + + // End of public ListenOptions ; + + public String up; + + public Integer up_mbps; + + public String down; + + public Integer down_mbps; + + public String obfs; + + public List users; + + public Long recv_window_conn; + + public Long recv_window_client; + + public Integer max_conn_client; + + public Boolean disable_mtu_discovery; + + public InboundTLSOptions tls; + + } + + public static class HysteriaUser extends SingBoxOption { + + public String name; + + public List auth; + + public String auth_str; + + } + + public static class HysteriaOutboundOptions extends SingBoxOption { + + // Generate note: nested type DialerOptions + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + // Generate note: option type: public Boolean UDPFragmentDefault; + + public String domain_strategy; + + public Long fallback_delay; + + // End of public DialerOptions ; + + // Generate note: nested type ServerOptions + public String server; + + public Integer server_port; + + // End of public ServerOptions ; + + public String up; + + public Integer up_mbps; + + public String down; + + public Integer down_mbps; + + public String obfs; + + public List auth; + + public String auth_str; + + public Long recv_window_conn; + + public Long recv_window; + + public Boolean disable_mtu_discovery; + + public String network; + + public OutboundTLSOptions tls; + + } + + + public static class Inbound extends SingBoxOption { + + public String type; + + public String tag; + + // Generate note: option type: public TunInboundOptions TunOptions; + + // Generate note: option type: public RedirectInboundOptions RedirectOptions; + + // Generate note: option type: public TProxyInboundOptions TProxyOptions; + + // Generate note: option type: public DirectInboundOptions DirectOptions; + + // Generate note: option type: public SocksInboundOptions SocksOptions; + + // Generate note: option type: public HTTPMixedInboundOptions HTTPOptions; + + // Generate note: option type: public HTTPMixedInboundOptions MixedOptions; + + // Generate note: option type: public ShadowsocksInboundOptions ShadowsocksOptions; + + // Generate note: option type: public VMessInboundOptions VMessOptions; + + // Generate note: option type: public TrojanInboundOptions TrojanOptions; + + // Generate note: option type: public NaiveInboundOptions NaiveOptions; + + // Generate note: option type: public HysteriaInboundOptions HysteriaOptions; + + // Generate note: option type: public ShadowTLSInboundOptions ShadowTLSOptions; + + // Generate note: option type: public VLESSInboundOptions VLESSOptions; + + } + + public static class InboundOptions extends SingBoxOption { + + public Boolean sniff; + + public Boolean sniff_override_destination; + + public Long sniff_timeout; + + public String domain_strategy; + + } + + public static class ListenOptions extends SingBoxOption { + + public String listen; + + public Integer listen_port; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + // Generate note: option type: public Boolean UDPFragmentDefault; + + public Long udp_timeout; + + public Boolean proxy_protocol; + + public Boolean proxy_protocol_accept_no_header; + + public String detour; + + // Generate note: nested type InboundOptions + public Boolean sniff; + + public Boolean sniff_override_destination; + + public Long sniff_timeout; + + public String domain_strategy; + + // End of public InboundOptions ; + + } + + public static class NaiveInboundOptions extends SingBoxOption { + + // Generate note: nested type ListenOptions + public String listen; + + public Integer listen_port; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + // Generate note: option type: public Boolean UDPFragmentDefault; + + public Long udp_timeout; + + public Boolean proxy_protocol; + + public Boolean proxy_protocol_accept_no_header; + + public String detour; + + // Generate note: nested type InboundOptions + public Boolean sniff; + + public Boolean sniff_override_destination; + + public Long sniff_timeout; + + public String domain_strategy; + + // End of public InboundOptions ; + + // End of public ListenOptions ; + + public List users; + + public String network; + + public InboundTLSOptions tls; + + } + + public static class NTPOptions extends SingBoxOption { + + public Boolean enabled; + + public Long interval; + + // Generate note: nested type ServerOptions + public String server; + + public Integer server_port; + + // End of public ServerOptions ; + + // Generate note: nested type DialerOptions + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + // Generate note: option type: public Boolean UDPFragmentDefault; + + public String domain_strategy; + + public Long fallback_delay; + + // End of public DialerOptions ; + + } + + + public static class Outbound extends SingBoxOption { + + public String type; + + public String tag; + + // Generate note: option type: public DirectOutboundOptions DirectOptions; + + // Generate note: option type: public SocksOutboundOptions SocksOptions; + + // Generate note: option type: public HTTPOutboundOptions HTTPOptions; + + // Generate note: option type: public ShadowsocksOutboundOptions ShadowsocksOptions; + + // Generate note: option type: public VMessOutboundOptions VMessOptions; + + // Generate note: option type: public TrojanOutboundOptions TrojanOptions; + + // Generate note: option type: public WireGuardOutboundOptions WireGuardOptions; + + // Generate note: option type: public HysteriaOutboundOptions HysteriaOptions; + + // Generate note: option type: public TorOutboundOptions TorOptions; + + // Generate note: option type: public SSHOutboundOptions SSHOptions; + + // Generate note: option type: public ShadowTLSOutboundOptions ShadowTLSOptions; + + // Generate note: option type: public ShadowsocksROutboundOptions ShadowsocksROptions; + + // Generate note: option type: public VLESSOutboundOptions VLESSOptions; + + // Generate note: option type: public SelectorOutboundOptions SelectorOptions; + + // Generate note: option type: public URLTestOutboundOptions URLTestOptions; + + } + + public static class DialerOptions extends SingBoxOption { + + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + // Generate note: option type: public Boolean UDPFragmentDefault; + + public String domain_strategy; + + public Long fallback_delay; + + } + + public static class ServerOptions extends SingBoxOption { + + public String server; + + public Integer server_port; + + } + + public static class MultiplexOptions extends SingBoxOption { + + public Boolean enabled; + + public String protocol; + + public Integer max_connections; + + public Integer min_streams; + + public Integer max_streams; + + } + + public static class OnDemandOptions extends SingBoxOption { + + public Boolean enabled; + + public List rules; + + } + + public static class OnDemandRule extends SingBoxOption { + + public String action; + + // Generate note: Listable + public List dns_search_domain_match; + + // Generate note: Listable + public List dns_server_address_match; + + public String interface_type_match; + + // Generate note: Listable + public List ssid_match; + + public String probe_url; + + } + + + public static class RedirectInboundOptions extends SingBoxOption { + + // Generate note: nested type ListenOptions + public String listen; + + public Integer listen_port; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + // Generate note: option type: public Boolean UDPFragmentDefault; + + public Long udp_timeout; + + public Boolean proxy_protocol; + + public Boolean proxy_protocol_accept_no_header; + + public String detour; + + // Generate note: nested type InboundOptions + public Boolean sniff; + + public Boolean sniff_override_destination; + + public Long sniff_timeout; + + public String domain_strategy; + + // End of public InboundOptions ; + + // End of public ListenOptions ; + + } + + public static class TProxyInboundOptions extends SingBoxOption { + + // Generate note: nested type ListenOptions + public String listen; + + public Integer listen_port; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + // Generate note: option type: public Boolean UDPFragmentDefault; + + public Long udp_timeout; + + public Boolean proxy_protocol; + + public Boolean proxy_protocol_accept_no_header; + + public String detour; + + // Generate note: nested type InboundOptions + public Boolean sniff; + + public Boolean sniff_override_destination; + + public Long sniff_timeout; + + public String domain_strategy; + + // End of public InboundOptions ; + + // End of public ListenOptions ; + + public String network; + + } + + public static class RouteOptions extends SingBoxOption { + + public GeoIPOptions geoip; + + public GeositeOptions geosite; + + public List rules; + + @SerializedName("final") + public String final_; + + public Boolean find_process; + + public Boolean auto_detect_interface; + + public Boolean override_android_vpn; + + public String default_interface; + + public Integer default_mark; + + } + + public static class GeoIPOptions extends SingBoxOption { + + public String path; + + public String download_url; + + public String download_detour; + + } + + public static class GeositeOptions extends SingBoxOption { + + public String path; + + public String download_url; + + public String download_detour; + + } + + + public static class Rule extends SingBoxOption { + + public String type; + + // Generate note: option type: public DefaultRule DefaultOptions; + + // Generate note: option type: public LogicalRule LogicalOptions; + + } + + public static class DefaultRule extends SingBoxOption { + + // Generate note: Listable + public List inbound; + + public Integer ip_version; + + public String network; + + // Generate note: Listable + public List auth_user; + + // Generate note: Listable + public List protocol; + + // Generate note: Listable + public List domain; + + // Generate note: Listable + public List domain_suffix; + + // Generate note: Listable + public List domain_keyword; + + // Generate note: Listable + public List domain_regex; + + // Generate note: Listable + public List geosite; + + // Generate note: Listable + public List source_geoip; + + // Generate note: Listable + public List geoip; + + // Generate note: Listable + public List source_ip_cidr; + + // Generate note: Listable + public List ip_cidr; + + // Generate note: Listable + public List source_port; + + // Generate note: Listable + public List source_port_range; + + // Generate note: Listable + public List port; + + // Generate note: Listable + public List port_range; + + // Generate note: Listable + public List process_name; + + // Generate note: Listable + public List process_path; + + // Generate note: Listable + public List package_name; + + // Generate note: Listable + public List user; + + // Generate note: Listable + public List user_id; + + public String clash_mode; + + public Boolean invert; + + public String outbound; + + } + + public static class LogicalRule extends SingBoxOption { + + public String mode; + + public List rules; + + public Boolean invert; + + public String outbound; + + } + + public static class ShadowsocksInboundOptions extends SingBoxOption { + + // Generate note: nested type ListenOptions + public String listen; + + public Integer listen_port; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + // Generate note: option type: public Boolean UDPFragmentDefault; + + public Long udp_timeout; + + public Boolean proxy_protocol; + + public Boolean proxy_protocol_accept_no_header; + + public String detour; + + // Generate note: nested type InboundOptions + public Boolean sniff; + + public Boolean sniff_override_destination; + + public Long sniff_timeout; + + public String domain_strategy; + + // End of public InboundOptions ; + + // End of public ListenOptions ; + + public String network; + + public String method; + + public String password; + + public List users; + + public List destinations; + + } + + public static class ShadowsocksUser extends SingBoxOption { + + public String name; + + public String password; + + } + + public static class ShadowsocksDestination extends SingBoxOption { + + public String name; + + public String password; + + // Generate note: nested type ServerOptions + public String server; + + public Integer server_port; + + // End of public ServerOptions ; + + } + + public static class ShadowsocksOutboundOptions extends SingBoxOption { + + // Generate note: nested type DialerOptions + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + // Generate note: option type: public Boolean UDPFragmentDefault; + + public String domain_strategy; + + public Long fallback_delay; + + // End of public DialerOptions ; + + // Generate note: nested type ServerOptions + public String server; + + public Integer server_port; + + // End of public ServerOptions ; + + public String method; + + public String password; + + public String plugin; + + public String plugin_opts; + + public String network; + + public Boolean udp_over_tcp; + + public MultiplexOptions multiplex; + + } + + public static class ShadowsocksROutboundOptions extends SingBoxOption { + + // Generate note: nested type DialerOptions + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + // Generate note: option type: public Boolean UDPFragmentDefault; + + public String domain_strategy; + + public Long fallback_delay; + + // End of public DialerOptions ; + + // Generate note: nested type ServerOptions + public String server; + + public Integer server_port; + + // End of public ServerOptions ; + + public String method; + + public String password; + + public String obfs; + + public String obfs_param; + + public String protocol; + + public String protocol_param; + + public String network; + + } + + public static class ShadowTLSInboundOptions extends SingBoxOption { + + // Generate note: nested type ListenOptions + public String listen; + + public Integer listen_port; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + // Generate note: option type: public Boolean UDPFragmentDefault; + + public Long udp_timeout; + + public Boolean proxy_protocol; + + public Boolean proxy_protocol_accept_no_header; + + public String detour; + + // Generate note: nested type InboundOptions + public Boolean sniff; + + public Boolean sniff_override_destination; + + public Long sniff_timeout; + + public String domain_strategy; + + // End of public InboundOptions ; + + // End of public ListenOptions ; + + public Integer version; + + public String password; + + public List users; + + public ShadowTLSHandshakeOptions handshake; + + public Map handshake_for_server_name; + + public Boolean strict_mode; + + } + + public static class ShadowTLSUser extends SingBoxOption { + + public String name; + + public String password; + + } + + public static class ShadowTLSHandshakeOptions extends SingBoxOption { + + // Generate note: nested type ServerOptions + public String server; + + public Integer server_port; + + // End of public ServerOptions ; + + // Generate note: nested type DialerOptions + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + // Generate note: option type: public Boolean UDPFragmentDefault; + + public String domain_strategy; + + public Long fallback_delay; + + // End of public DialerOptions ; + + } + + public static class ShadowTLSOutboundOptions extends SingBoxOption { + + // Generate note: nested type DialerOptions + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + // Generate note: option type: public Boolean UDPFragmentDefault; + + public String domain_strategy; + + public Long fallback_delay; + + // End of public DialerOptions ; + + // Generate note: nested type ServerOptions + public String server; + + public Integer server_port; + + // End of public ServerOptions ; + + public Integer version; + + public String password; + + public OutboundTLSOptions tls; + + } + + public static class SocksInboundOptions extends SingBoxOption { + + // Generate note: nested type ListenOptions + public String listen; + + public Integer listen_port; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + // Generate note: option type: public Boolean UDPFragmentDefault; + + public Long udp_timeout; + + public Boolean proxy_protocol; + + public Boolean proxy_protocol_accept_no_header; + + public String detour; + + // Generate note: nested type InboundOptions + public Boolean sniff; + + public Boolean sniff_override_destination; + + public Long sniff_timeout; + + public String domain_strategy; + + // End of public InboundOptions ; + + // End of public ListenOptions ; + + public List users; + + } + + public static class HTTPMixedInboundOptions extends SingBoxOption { + + // Generate note: nested type ListenOptions + public String listen; + + public Integer listen_port; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + // Generate note: option type: public Boolean UDPFragmentDefault; + + public Long udp_timeout; + + public Boolean proxy_protocol; + + public Boolean proxy_protocol_accept_no_header; + + public String detour; + + // Generate note: nested type InboundOptions + public Boolean sniff; + + public Boolean sniff_override_destination; + + public Long sniff_timeout; + + public String domain_strategy; + + // End of public InboundOptions ; + + // End of public ListenOptions ; + + public List users; + + public Boolean set_system_proxy; + + public InboundTLSOptions tls; + + } + + public static class SocksOutboundOptions extends SingBoxOption { + + // Generate note: nested type DialerOptions + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + // Generate note: option type: public Boolean UDPFragmentDefault; + + public String domain_strategy; + + public Long fallback_delay; + + // End of public DialerOptions ; + + // Generate note: nested type ServerOptions + public String server; + + public Integer server_port; + + // End of public ServerOptions ; + + public String version; + + public String username; + + public String password; + + public String network; + + public Boolean udp_over_tcp; + + } + + public static class HTTPOutboundOptions extends SingBoxOption { + + // Generate note: nested type DialerOptions + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + // Generate note: option type: public Boolean UDPFragmentDefault; + + public String domain_strategy; + + public Long fallback_delay; + + // End of public DialerOptions ; + + // Generate note: nested type ServerOptions + public String server; + + public Integer server_port; + + // End of public ServerOptions ; + + public String username; + + public String password; + + public OutboundTLSOptions tls; + + } + + public static class SSHOutboundOptions extends SingBoxOption { + + // Generate note: nested type DialerOptions + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + // Generate note: option type: public Boolean UDPFragmentDefault; + + public String domain_strategy; + + public Long fallback_delay; + + // End of public DialerOptions ; + + // Generate note: nested type ServerOptions + public String server; + + public Integer server_port; + + // End of public ServerOptions ; + + public String user; + + public String password; + + public String private_key; + + public String private_key_path; + + public String private_key_passphrase; + + // Generate note: Listable + public List host_key; + + // Generate note: Listable + public List host_key_algorithms; + + public String client_version; + + } + + public static class InboundTLSOptions extends SingBoxOption { + + public Boolean enabled; + + public String server_name; + + public Boolean insecure; + + // Generate note: Listable + public List alpn; + + public String min_version; + + public String max_version; + + // Generate note: Listable + public List cipher_suites; + + public String certificate; + + public String certificate_path; + + public String key; + + public String key_path; + + public InboundACMEOptions acme; + + public InboundRealityOptions reality; + + } + + public static class OutboundTLSOptions extends SingBoxOption { + + public Boolean enabled; + + public Boolean disable_sni; + + public String server_name; + + public Boolean insecure; + + // Generate note: Listable + public List alpn; + + public String min_version; + + public String max_version; + + // Generate note: Listable + public List cipher_suites; + + public String certificate; + + public String certificate_path; + + public OutboundECHOptions ech; + + public OutboundUTLSOptions utls; + + public OutboundRealityOptions reality; + + } + + public static class InboundRealityOptions extends SingBoxOption { + + public Boolean enabled; + + public InboundRealityHandshakeOptions handshake; + + public String private_key; + + // Generate note: Listable + public List short_id; + + public Long max_time_difference; + + } + + public static class InboundRealityHandshakeOptions extends SingBoxOption { + + // Generate note: nested type ServerOptions + public String server; + + public Integer server_port; + + // End of public ServerOptions ; + + // Generate note: nested type DialerOptions + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + // Generate note: option type: public Boolean UDPFragmentDefault; + + public String domain_strategy; + + public Long fallback_delay; + + // End of public DialerOptions ; + + } + + public static class OutboundECHOptions extends SingBoxOption { + + public Boolean enabled; + + public Boolean pq_signature_schemes_enabled; + + public Boolean dynamic_record_sizing_disabled; + + public String config; + + } + + public static class OutboundUTLSOptions extends SingBoxOption { + + public Boolean enabled; + + public String fingerprint; + + } + + public static class OutboundRealityOptions extends SingBoxOption { + + public Boolean enabled; + + public String public_key; + + public String short_id; + + } + + public static class InboundACMEOptions extends SingBoxOption { + + // Generate note: Listable + public List domain; + + public String data_directory; + + public String default_server_name; + + public String email; + + public String provider; + + public Boolean disable_http_challenge; + + public Boolean disable_tls_alpn_challenge; + + public Integer alternative_http_port; + + public Integer alternative_tls_port; + + public ACMEExternalAccountOptions external_account; + + } + + public static class ACMEExternalAccountOptions extends SingBoxOption { + + public String key_id; + + public String mac_key; + + } + + public static class TorOutboundOptions extends SingBoxOption { + + // Generate note: nested type DialerOptions + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + // Generate note: option type: public Boolean UDPFragmentDefault; + + public String domain_strategy; + + public Long fallback_delay; + + // End of public DialerOptions ; + + public String executable_path; + + public List extra_args; + + public String data_directory; + + public Map torrc; + + } + + public static class TrojanInboundOptions extends SingBoxOption { + + // Generate note: nested type ListenOptions + public String listen; + + public Integer listen_port; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + // Generate note: option type: public Boolean UDPFragmentDefault; + + public Long udp_timeout; + + public Boolean proxy_protocol; + + public Boolean proxy_protocol_accept_no_header; + + public String detour; + + // Generate note: nested type InboundOptions + public Boolean sniff; + + public Boolean sniff_override_destination; + + public Long sniff_timeout; + + public String domain_strategy; + + // End of public InboundOptions ; + + // End of public ListenOptions ; + + public List users; + + public InboundTLSOptions tls; + + public ServerOptions fallback; + + public Map fallback_for_alpn; + + public V2RayTransportOptions transport; + + } + + public static class TrojanUser extends SingBoxOption { + + public String name; + + public String password; + + } + + public static class TrojanOutboundOptions extends SingBoxOption { + + // Generate note: nested type DialerOptions + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + // Generate note: option type: public Boolean UDPFragmentDefault; + + public String domain_strategy; + + public Long fallback_delay; + + // End of public DialerOptions ; + + // Generate note: nested type ServerOptions + public String server; + + public Integer server_port; + + // End of public ServerOptions ; + + public String password; + + public String network; + + public OutboundTLSOptions tls; + + public MultiplexOptions multiplex; + + public V2RayTransportOptions transport; + + } + + public static class TunInboundOptions extends SingBoxOption { + + public String interface_name; + + public Integer mtu; + + // Generate note: Listable + public List inet4_address; + + // Generate note: Listable + public List inet6_address; + + public Boolean auto_route; + + public Boolean strict_route; + + // Generate note: Listable + public List inet4_route_address; + + // Generate note: Listable + public List inet6_route_address; + + // Generate note: Listable + public List include_uid; + + // Generate note: Listable + public List include_uid_range; + + // Generate note: Listable + public List exclude_uid; + + // Generate note: Listable + public List exclude_uid_range; + + // Generate note: Listable + public List include_android_user; + + // Generate note: Listable + public List include_package; + + // Generate note: Listable + public List exclude_package; + + public Boolean endpoint_independent_nat; + + public Long udp_timeout; + + public String stack; + + public TunPlatformOptions platform; + + // Generate note: nested type InboundOptions + public Boolean sniff; + + public Boolean sniff_override_destination; + + public Long sniff_timeout; + + public String domain_strategy; + + // End of public InboundOptions ; + + } + + public static class TunPlatformOptions extends SingBoxOption { + + public HTTPProxyOptions http_proxy; + + } + + public static class HTTPProxyOptions extends SingBoxOption { + + public Boolean enabled; + + // Generate note: nested type ServerOptions + public String server; + + public Integer server_port; + + // End of public ServerOptions ; + + } + + + public static class V2RayAPIOptions extends SingBoxOption { + + public String listen; + + public V2RayStatsServiceOptions stats; + + } + + public static class V2RayStatsServiceOptions extends SingBoxOption { + + public Boolean enabled; + + public List inbounds; + + public List outbounds; + + public List users; + + } + + + public static class V2RayTransportOptions extends SingBoxOption { + + public String type; + + // Generate note: option type: public V2RayHTTPOptions HTTPOptions; + + // Generate note: option type: public V2RayWebsocketOptions WebsocketOptions; + + // Generate note: option type: public V2RayQUICOptions QUICOptions; + + // Generate note: option type: public V2RayGRPCOptions GRPCOptions; + + } + + public static class V2RayHTTPOptions extends SingBoxOption { + + // Generate note: Listable + public List host; + + public String path; + + public String method; + + public Map headers; + + } + + public static class V2RayWebsocketOptions extends SingBoxOption { + + public String path; + + public Map headers; + + public Integer max_early_data; + + public String early_data_header_name; + + } + + + public static class V2RayGRPCOptions extends SingBoxOption { + + public String service_name; + + // Generate note: option type: public Boolean ForceLite; + + } + + public static class VLESSInboundOptions extends SingBoxOption { + + // Generate note: nested type ListenOptions + public String listen; + + public Integer listen_port; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + // Generate note: option type: public Boolean UDPFragmentDefault; + + public Long udp_timeout; + + public Boolean proxy_protocol; + + public Boolean proxy_protocol_accept_no_header; + + public String detour; + + // Generate note: nested type InboundOptions + public Boolean sniff; + + public Boolean sniff_override_destination; + + public Long sniff_timeout; + + public String domain_strategy; + + // End of public InboundOptions ; + + // End of public ListenOptions ; + + public List users; + + public InboundTLSOptions tls; + + public V2RayTransportOptions transport; + + } + + public static class VLESSUser extends SingBoxOption { + + public String name; + + public String uuid; + + public String flow; + + } + + public static class VLESSOutboundOptions extends SingBoxOption { + + // Generate note: nested type DialerOptions + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + // Generate note: option type: public Boolean UDPFragmentDefault; + + public String domain_strategy; + + public Long fallback_delay; + + // End of public DialerOptions ; + + // Generate note: nested type ServerOptions + public String server; + + public Integer server_port; + + // End of public ServerOptions ; + + public String uuid; + + public String flow; + + public String network; + + public OutboundTLSOptions tls; + + public V2RayTransportOptions transport; + + public String packet_encoding; + + } + + public static class VMessInboundOptions extends SingBoxOption { + + // Generate note: nested type ListenOptions + public String listen; + + public Integer listen_port; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + // Generate note: option type: public Boolean UDPFragmentDefault; + + public Long udp_timeout; + + public Boolean proxy_protocol; + + public Boolean proxy_protocol_accept_no_header; + + public String detour; + + // Generate note: nested type InboundOptions + public Boolean sniff; + + public Boolean sniff_override_destination; + + public Long sniff_timeout; + + public String domain_strategy; + + // End of public InboundOptions ; + + // End of public ListenOptions ; + + public List users; + + public InboundTLSOptions tls; + + public V2RayTransportOptions transport; + + } + + public static class VMessUser extends SingBoxOption { + + public String name; + + public String uuid; + + public Integer alterId; + + } + + public static class VMessOutboundOptions extends SingBoxOption { + + // Generate note: nested type DialerOptions + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + // Generate note: option type: public Boolean UDPFragmentDefault; + + public String domain_strategy; + + public Long fallback_delay; + + // End of public DialerOptions ; + + // Generate note: nested type ServerOptions + public String server; + + public Integer server_port; + + // End of public ServerOptions ; + + public String uuid; + + public String security; + + public Integer alter_id; + + public Boolean global_padding; + + public Boolean authenticated_length; + + public String network; + + public OutboundTLSOptions tls; + + public String packet_encoding; + + public MultiplexOptions multiplex; + + public V2RayTransportOptions transport; + + } + + public static class WireGuardOutboundOptions extends SingBoxOption { + + // Generate note: nested type DialerOptions + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + // Generate note: option type: public Boolean UDPFragmentDefault; + + public String domain_strategy; + + public Long fallback_delay; + + // End of public DialerOptions ; + + // Generate note: nested type ServerOptions + public String server; + + public Integer server_port; + + // End of public ServerOptions ; + + public Boolean system_interface; + + public String interface_name; + + // Generate note: Listable + public List local_address; + + public String private_key; + + public String peer_public_key; + + public String pre_shared_key; + + public List reserved; + + public Integer workers; + + public Integer mtu; + + public String network; + + } + + public static class DNSRule_DefaultOptions extends DNSRule { + + // Generate note: Listable + public List inbound; + + public Integer ip_version; + + // Generate note: Listable + public List query_type; + + public String network; + + // Generate note: Listable + public List auth_user; + + // Generate note: Listable + public List protocol; + + // Generate note: Listable + public List domain; + + // Generate note: Listable + public List domain_suffix; + + // Generate note: Listable + public List domain_keyword; + + // Generate note: Listable + public List domain_regex; + + // Generate note: Listable + public List geosite; + + // Generate note: Listable + public List source_geoip; + + // Generate note: Listable + public List source_ip_cidr; + + // Generate note: Listable + public List source_port; + + // Generate note: Listable + public List source_port_range; + + // Generate note: Listable + public List port; + + // Generate note: Listable + public List port_range; + + // Generate note: Listable + public List process_name; + + // Generate note: Listable + public List process_path; + + // Generate note: Listable + public List package_name; + + // Generate note: Listable + public List user; + + // Generate note: Listable + public List user_id; + + // Generate note: Listable + public List outbound; + + public String clash_mode; + + public Boolean invert; + + public String server; + + public Boolean disable_cache; + + } + + public static class DNSRule_LogicalOptions extends DNSRule { + + public String mode; + + public List rules; + + public Boolean invert; + + public String server; + + public Boolean disable_cache; + + } + + public static class Inbound_TunOptions extends Inbound { + + public String interface_name; + + public Integer mtu; + + // Generate note: Listable + public List inet4_address; + + // Generate note: Listable + public List inet6_address; + + public Boolean auto_route; + + public Boolean strict_route; + + // Generate note: Listable + public List inet4_route_address; + + // Generate note: Listable + public List inet6_route_address; + + // Generate note: Listable + public List include_uid; + + // Generate note: Listable + public List include_uid_range; + + // Generate note: Listable + public List exclude_uid; + + // Generate note: Listable + public List exclude_uid_range; + + // Generate note: Listable + public List include_android_user; + + // Generate note: Listable + public List include_package; + + // Generate note: Listable + public List exclude_package; + + public Boolean endpoint_independent_nat; + + public Long udp_timeout; + + public String stack; + + public TunPlatformOptions platform; + + // Generate note: nested type InboundOptions + public Boolean sniff; + + public Boolean sniff_override_destination; + + public Long sniff_timeout; + + public String domain_strategy; + + // End of public InboundOptions ; + + } + + public static class Inbound_RedirectOptions extends Inbound { + + // Generate note: nested type ListenOptions + public String listen; + + public Integer listen_port; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + + public Long udp_timeout; + + public Boolean proxy_protocol; + + public Boolean proxy_protocol_accept_no_header; + + public String detour; + + // Generate note: nested type InboundOptions + public Boolean sniff; + + public Boolean sniff_override_destination; + + public Long sniff_timeout; + + public String domain_strategy; + + // End of public InboundOptions ; + + // End of public ListenOptions ; + + } + + public static class Inbound_TProxyOptions extends Inbound { + + // Generate note: nested type ListenOptions + public String listen; + + public Integer listen_port; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + + public Long udp_timeout; + + public Boolean proxy_protocol; + + public Boolean proxy_protocol_accept_no_header; + + public String detour; + + // Generate note: nested type InboundOptions + public Boolean sniff; + + public Boolean sniff_override_destination; + + public Long sniff_timeout; + + public String domain_strategy; + + // End of public InboundOptions ; + + // End of public ListenOptions ; + + public String network; + + } + + public static class Inbound_DirectOptions extends Inbound { + + // Generate note: nested type ListenOptions + public String listen; + + public Integer listen_port; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + + public Long udp_timeout; + + public Boolean proxy_protocol; + + public Boolean proxy_protocol_accept_no_header; + + public String detour; + + // Generate note: nested type InboundOptions + public Boolean sniff; + + public Boolean sniff_override_destination; + + public Long sniff_timeout; + + public String domain_strategy; + + // End of public InboundOptions ; + + // End of public ListenOptions ; + + public String network; + + public String override_address; + + public Integer override_port; + + } + + public static class Inbound_SocksOptions extends Inbound { + + // Generate note: nested type ListenOptions + public String listen; + + public Integer listen_port; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + + public Long udp_timeout; + + public Boolean proxy_protocol; + + public Boolean proxy_protocol_accept_no_header; + + public String detour; + + // Generate note: nested type InboundOptions + public Boolean sniff; + + public Boolean sniff_override_destination; + + public Long sniff_timeout; + + public String domain_strategy; + + // End of public InboundOptions ; + + // End of public ListenOptions ; + + public List users; + + } + + public static class Inbound_HTTPOptions extends Inbound { + + // Generate note: nested type ListenOptions + public String listen; + + public Integer listen_port; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + + public Long udp_timeout; + + public Boolean proxy_protocol; + + public Boolean proxy_protocol_accept_no_header; + + public String detour; + + // Generate note: nested type InboundOptions + public Boolean sniff; + + public Boolean sniff_override_destination; + + public Long sniff_timeout; + + public String domain_strategy; + + // End of public InboundOptions ; + + // End of public ListenOptions ; + + public List users; + + public Boolean set_system_proxy; + + public InboundTLSOptions tls; + + } + + public static class Inbound_MixedOptions extends Inbound { + + // Generate note: nested type ListenOptions + public String listen; + + public Integer listen_port; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + + public Long udp_timeout; + + public Boolean proxy_protocol; + + public Boolean proxy_protocol_accept_no_header; + + public String detour; + + // Generate note: nested type InboundOptions + public Boolean sniff; + + public Boolean sniff_override_destination; + + public Long sniff_timeout; + + public String domain_strategy; + + // End of public InboundOptions ; + + // End of public ListenOptions ; + + public List users; + + public Boolean set_system_proxy; + + public InboundTLSOptions tls; + + } + + public static class Inbound_ShadowsocksOptions extends Inbound { + + // Generate note: nested type ListenOptions + public String listen; + + public Integer listen_port; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + + public Long udp_timeout; + + public Boolean proxy_protocol; + + public Boolean proxy_protocol_accept_no_header; + + public String detour; + + // Generate note: nested type InboundOptions + public Boolean sniff; + + public Boolean sniff_override_destination; + + public Long sniff_timeout; + + public String domain_strategy; + + // End of public InboundOptions ; + + // End of public ListenOptions ; + + public String network; + + public String method; + + public String password; + + public List users; + + public List destinations; + + } + + public static class Inbound_VMessOptions extends Inbound { + + // Generate note: nested type ListenOptions + public String listen; + + public Integer listen_port; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + + public Long udp_timeout; + + public Boolean proxy_protocol; + + public Boolean proxy_protocol_accept_no_header; + + public String detour; + + // Generate note: nested type InboundOptions + public Boolean sniff; + + public Boolean sniff_override_destination; + + public Long sniff_timeout; + + public String domain_strategy; + + // End of public InboundOptions ; + + // End of public ListenOptions ; + + public List users; + + public InboundTLSOptions tls; + + public V2RayTransportOptions transport; + + } + + public static class Inbound_TrojanOptions extends Inbound { + + // Generate note: nested type ListenOptions + public String listen; + + public Integer listen_port; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + + public Long udp_timeout; + + public Boolean proxy_protocol; + + public Boolean proxy_protocol_accept_no_header; + + public String detour; + + // Generate note: nested type InboundOptions + public Boolean sniff; + + public Boolean sniff_override_destination; + + public Long sniff_timeout; + + public String domain_strategy; + + // End of public InboundOptions ; + + // End of public ListenOptions ; + + public List users; + + public InboundTLSOptions tls; + + public ServerOptions fallback; + + public Map fallback_for_alpn; + + public V2RayTransportOptions transport; + + } + + public static class Inbound_NaiveOptions extends Inbound { + + // Generate note: nested type ListenOptions + public String listen; + + public Integer listen_port; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + + public Long udp_timeout; + + public Boolean proxy_protocol; + + public Boolean proxy_protocol_accept_no_header; + + public String detour; + + // Generate note: nested type InboundOptions + public Boolean sniff; + + public Boolean sniff_override_destination; + + public Long sniff_timeout; + + public String domain_strategy; + + // End of public InboundOptions ; + + // End of public ListenOptions ; + + public List users; + + public String network; + + public InboundTLSOptions tls; + + } + + public static class Inbound_HysteriaOptions extends Inbound { + + // Generate note: nested type ListenOptions + public String listen; + + public Integer listen_port; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + + public Long udp_timeout; + + public Boolean proxy_protocol; + + public Boolean proxy_protocol_accept_no_header; + + public String detour; + + // Generate note: nested type InboundOptions + public Boolean sniff; + + public Boolean sniff_override_destination; + + public Long sniff_timeout; + + public String domain_strategy; + + // End of public InboundOptions ; + + // End of public ListenOptions ; + + public String up; + + public Integer up_mbps; + + public String down; + + public Integer down_mbps; + + public String obfs; + + public List users; + + public Long recv_window_conn; + + public Long recv_window_client; + + public Integer max_conn_client; + + public Boolean disable_mtu_discovery; + + public InboundTLSOptions tls; + + } + + public static class Inbound_ShadowTLSOptions extends Inbound { + + // Generate note: nested type ListenOptions + public String listen; + + public Integer listen_port; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + + public Long udp_timeout; + + public Boolean proxy_protocol; + + public Boolean proxy_protocol_accept_no_header; + + public String detour; + + // Generate note: nested type InboundOptions + public Boolean sniff; + + public Boolean sniff_override_destination; + + public Long sniff_timeout; + + public String domain_strategy; + + // End of public InboundOptions ; + + // End of public ListenOptions ; + + public Integer version; + + public String password; + + public List users; + + public ShadowTLSHandshakeOptions handshake; + + public Map handshake_for_server_name; + + public Boolean strict_mode; + + } + + public static class Inbound_VLESSOptions extends Inbound { + + // Generate note: nested type ListenOptions + public String listen; + + public Integer listen_port; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + + public Long udp_timeout; + + public Boolean proxy_protocol; + + public Boolean proxy_protocol_accept_no_header; + + public String detour; + + // Generate note: nested type InboundOptions + public Boolean sniff; + + public Boolean sniff_override_destination; + + public Long sniff_timeout; + + public String domain_strategy; + + // End of public InboundOptions ; + + // End of public ListenOptions ; + + public List users; + + public InboundTLSOptions tls; + + public V2RayTransportOptions transport; + + } + + public static class Outbound_DirectOptions extends Outbound { + + // Generate note: nested type DialerOptions + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + + public String domain_strategy; + + public Long fallback_delay; + + // End of public DialerOptions ; + + public String override_address; + + public Integer override_port; + + public Integer proxy_protocol; + + } + + public static class Outbound_SocksOptions extends Outbound { + + // Generate note: nested type DialerOptions + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + + public String domain_strategy; + + public Long fallback_delay; + + // End of public DialerOptions ; + + // Generate note: nested type ServerOptions + public String server; + + public Integer server_port; + + // End of public ServerOptions ; + + public String version; + + public String username; + + public String password; + + public String network; + + public Boolean udp_over_tcp; + + } + + public static class Outbound_HTTPOptions extends Outbound { + + // Generate note: nested type DialerOptions + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + + public String domain_strategy; + + public Long fallback_delay; + + // End of public DialerOptions ; + + // Generate note: nested type ServerOptions + public String server; + + public Integer server_port; + + // End of public ServerOptions ; + + public String username; + + public String password; + + public OutboundTLSOptions tls; + + } + + public static class Outbound_ShadowsocksOptions extends Outbound { + + // Generate note: nested type DialerOptions + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + + public String domain_strategy; + + public Long fallback_delay; + + // End of public DialerOptions ; + + // Generate note: nested type ServerOptions + public String server; + + public Integer server_port; + + // End of public ServerOptions ; + + public String method; + + public String password; + + public String plugin; + + public String plugin_opts; + + public String network; + + public Boolean udp_over_tcp; + + public MultiplexOptions multiplex; + + } + + public static class Outbound_VMessOptions extends Outbound { + + // Generate note: nested type DialerOptions + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + + public String domain_strategy; + + public Long fallback_delay; + + // End of public DialerOptions ; + + // Generate note: nested type ServerOptions + public String server; + + public Integer server_port; + + // End of public ServerOptions ; + + public String uuid; + + public String security; + + public Integer alter_id; + + public Boolean global_padding; + + public Boolean authenticated_length; + + public String network; + + public OutboundTLSOptions tls; + + public String packet_encoding; + + public MultiplexOptions multiplex; + + public V2RayTransportOptions transport; + + } + + public static class Outbound_TrojanOptions extends Outbound { + + // Generate note: nested type DialerOptions + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + + public String domain_strategy; + + public Long fallback_delay; + + // End of public DialerOptions ; + + // Generate note: nested type ServerOptions + public String server; + + public Integer server_port; + + // End of public ServerOptions ; + + public String password; + + public String network; + + public OutboundTLSOptions tls; + + public MultiplexOptions multiplex; + + public V2RayTransportOptions transport; + + } + + public static class Outbound_WireGuardOptions extends Outbound { + + // Generate note: nested type DialerOptions + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + + public String domain_strategy; + + public Long fallback_delay; + + // End of public DialerOptions ; + + // Generate note: nested type ServerOptions + public String server; + + public Integer server_port; + + // End of public ServerOptions ; + + public Boolean system_interface; + + public String interface_name; + + // Generate note: Listable + public List local_address; + + public String private_key; + + public String peer_public_key; + + public String pre_shared_key; + + public List reserved; + + public Integer workers; + + public Integer mtu; + + public String network; + + } + + public static class Outbound_HysteriaOptions extends Outbound { + + // Generate note: nested type DialerOptions + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + + public String domain_strategy; + + public Long fallback_delay; + + // End of public DialerOptions ; + + // Generate note: nested type ServerOptions + public String server; + + public Integer server_port; + + // End of public ServerOptions ; + + public String up; + + public Integer up_mbps; + + public String down; + + public Integer down_mbps; + + public String obfs; + + public List auth; + + public String auth_str; + + public Long recv_window_conn; + + public Long recv_window; + + public Boolean disable_mtu_discovery; + + public String network; + + public OutboundTLSOptions tls; + + } + + public static class Outbound_TorOptions extends Outbound { + + // Generate note: nested type DialerOptions + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + + public String domain_strategy; + + public Long fallback_delay; + + // End of public DialerOptions ; + + public String executable_path; + + public List extra_args; + + public String data_directory; + + public Map torrc; + + } + + public static class Outbound_SSHOptions extends Outbound { + + // Generate note: nested type DialerOptions + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + + public String domain_strategy; + + public Long fallback_delay; + + // End of public DialerOptions ; + + // Generate note: nested type ServerOptions + public String server; + + public Integer server_port; + + // End of public ServerOptions ; + + public String user; + + public String password; + + public String private_key; + + public String private_key_path; + + public String private_key_passphrase; + + // Generate note: Listable + public List host_key; + + // Generate note: Listable + public List host_key_algorithms; + + public String client_version; + + } + + public static class Outbound_ShadowTLSOptions extends Outbound { + + // Generate note: nested type DialerOptions + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + + public String domain_strategy; + + public Long fallback_delay; + + // End of public DialerOptions ; + + // Generate note: nested type ServerOptions + public String server; + + public Integer server_port; + + // End of public ServerOptions ; + + public Integer version; + + public String password; + + public OutboundTLSOptions tls; + + } + + public static class Outbound_ShadowsocksROptions extends Outbound { + + // Generate note: nested type DialerOptions + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + + public String domain_strategy; + + public Long fallback_delay; + + // End of public DialerOptions ; + + // Generate note: nested type ServerOptions + public String server; + + public Integer server_port; + + // End of public ServerOptions ; + + public String method; + + public String password; + + public String obfs; + + public String obfs_param; + + public String protocol; + + public String protocol_param; + + public String network; + + } + + public static class Outbound_VLESSOptions extends Outbound { + + // Generate note: nested type DialerOptions + public String detour; + + public String bind_interface; + + public String inet4_bind_address; + + public String inet6_bind_address; + + public String protect_path; + + public Integer routing_mark; + + public Boolean reuse_addr; + + public Long connect_timeout; + + public Boolean tcp_fast_open; + + public Boolean udp_fragment; + + + public String domain_strategy; + + public Long fallback_delay; + + // End of public DialerOptions ; + + // Generate note: nested type ServerOptions + public String server; + + public Integer server_port; + + // End of public ServerOptions ; + + public String uuid; + + public String flow; + + public String network; + + public OutboundTLSOptions tls; + + public V2RayTransportOptions transport; + + public String packet_encoding; + + } + + public static class Outbound_SelectorOptions extends Outbound { + + public List outbounds; + + @SerializedName("default") + public String default_; + + } + + public static class Outbound_URLTestOptions extends Outbound { + + public List outbounds; + + public String url; + + public Long interval; + + public Integer tolerance; + + } + + public static class Rule_DefaultOptions extends Rule { + + // Generate note: Listable + public List inbound; + + public Integer ip_version; + + public String network; + + // Generate note: Listable + public List auth_user; + + // Generate note: Listable + public List protocol; + + // Generate note: Listable + public List domain; + + // Generate note: Listable + public List domain_suffix; + + // Generate note: Listable + public List domain_keyword; + + // Generate note: Listable + public List domain_regex; + + // Generate note: Listable + public List geosite; + + // Generate note: Listable + public List source_geoip; + + // Generate note: Listable + public List geoip; + + // Generate note: Listable + public List source_ip_cidr; + + // Generate note: Listable + public List ip_cidr; + + // Generate note: Listable + public List source_port; + + // Generate note: Listable + public List source_port_range; + + // Generate note: Listable + public List port; + + // Generate note: Listable + public List port_range; + + // Generate note: Listable + public List process_name; + + // Generate note: Listable + public List process_path; + + // Generate note: Listable + public List package_name; + + // Generate note: Listable + public List user; + + // Generate note: Listable + public List user_id; + + public String clash_mode; + + public Boolean invert; + + public String outbound; + + } + + public static class Rule_LogicalOptions extends Rule { + + public String mode; + + public List rules; + + public Boolean invert; + + public String outbound; + + } + + public static class V2RayTransportOptions_HTTPOptions extends V2RayTransportOptions { + + // Generate note: Listable + public List host; + + public String path; + + public String method; + + public Map headers; + + } + + public static class V2RayTransportOptions_WebsocketOptions extends V2RayTransportOptions { + + public String path; + + public Map headers; + + public Integer max_early_data; + + public String early_data_header_name; + + } + + + public static class V2RayTransportOptions_GRPCOptions extends V2RayTransportOptions { + + public String service_name; + + + } + +} diff --git a/app/src/main/java/moe/matsuri/nb4a/TempDatabase.kt b/app/src/main/java/moe/matsuri/nb4a/TempDatabase.kt new file mode 100644 index 0000000..4070cdb --- /dev/null +++ b/app/src/main/java/moe/matsuri/nb4a/TempDatabase.kt @@ -0,0 +1,30 @@ +package moe.matsuri.nb4a + +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import io.nekohasekai.sagernet.SagerNet +import io.nekohasekai.sagernet.database.preference.KeyValuePair +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +@Database(entities = [KeyValuePair::class], version = 1) +abstract class TempDatabase : RoomDatabase() { + + companion object { + @Suppress("EXPERIMENTAL_API_USAGE") + private val instance by lazy { + Room.inMemoryDatabaseBuilder(SagerNet.application, TempDatabase::class.java) + .allowMainThreadQueries() + .enableMultiInstanceInvalidation() + .fallbackToDestructiveMigration() + .setQueryExecutor { GlobalScope.launch { it.run() } } + .build() + } + + val profileCacheDao get() = instance.profileCacheDao() + + } + + abstract fun profileCacheDao(): KeyValuePair.Dao +} \ No newline at end of file diff --git a/app/src/main/java/moe/matsuri/nb4a/net/LocalResolverImpl.kt b/app/src/main/java/moe/matsuri/nb4a/net/LocalResolverImpl.kt new file mode 100644 index 0000000..8a5a219 --- /dev/null +++ b/app/src/main/java/moe/matsuri/nb4a/net/LocalResolverImpl.kt @@ -0,0 +1,80 @@ +package moe.matsuri.nb4a.net + +import android.net.DnsResolver +import android.os.Build +import android.os.CancellationSignal +import io.nekohasekai.sagernet.SagerNet +import io.nekohasekai.sagernet.ktx.tryResume +import io.nekohasekai.sagernet.ktx.tryResumeWithException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.runBlocking +import java.net.InetAddress +import kotlin.coroutines.suspendCoroutine + +object LocalResolverImpl : libcore.LocalResolver { + + override fun lookupIP(network: String, domain: String): String { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + return runBlocking { + suspendCoroutine { continuation -> + val signal = CancellationSignal() + val callback = object : DnsResolver.Callback> { + @Suppress("ThrowableNotThrown") + override fun onAnswer(answer: Collection, rcode: Int) { + // libcore/v2ray.go + when { + answer.isNotEmpty() -> { + continuation.tryResume((answer as Collection).mapNotNull { it?.hostAddress } + .joinToString(",")) + } + rcode == 0 -> { + // fuck AAAA no record + // features/dns/client.go + continuation.tryResume("") + } + else -> { + // Need return rcode + // proxy/dns/dns.go + continuation.tryResumeWithException(Exception("$rcode")) + } + } + } + + override fun onError(error: DnsResolver.DnsException) { + continuation.tryResumeWithException(error) + } + } + val type = when { + network.endsWith("4") -> DnsResolver.TYPE_A + network.endsWith("6") -> DnsResolver.TYPE_AAAA + else -> null + } + if (type != null) { + DnsResolver.getInstance().query( + SagerNet.underlyingNetwork, + domain, + type, + DnsResolver.FLAG_EMPTY, + Dispatchers.IO.asExecutor(), + signal, + callback + ) + } else { + DnsResolver.getInstance().query( + SagerNet.underlyingNetwork, + domain, + DnsResolver.FLAG_EMPTY, + Dispatchers.IO.asExecutor(), + signal, + callback + ) + } + } + } + } else { + throw Exception("114514") + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/moe/matsuri/nb4a/plugin/NekoPluginManager.kt b/app/src/main/java/moe/matsuri/nb4a/plugin/NekoPluginManager.kt new file mode 100644 index 0000000..d7ae50e --- /dev/null +++ b/app/src/main/java/moe/matsuri/nb4a/plugin/NekoPluginManager.kt @@ -0,0 +1,153 @@ +package moe.matsuri.nb4a.plugin + +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.SagerNet +import io.nekohasekai.sagernet.bg.BaseService +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.ktx.forEach +import io.nekohasekai.sagernet.utils.PackageCache +import moe.matsuri.nb4a.proxy.neko.NekoJSInterface +import okhttp3.internal.closeQuietly +import org.json.JSONObject +import java.io.File +import java.util.zip.CRC32 +import java.util.zip.ZipFile + +object NekoPluginManager { + const val managerVersion = 2 + + val plugins get() = DataStore.nekoPlugins.split("\n").filter { it.isNotBlank() } + + // plgID to plgConfig object + fun getManagedPlugins(): Map { + val ret = mutableMapOf() + plugins.forEach { + tryGetPlgConfig(it)?.apply { + ret[it] = this + } + } + return ret + } + + class Protocol( + val protocolId: String, val plgId: String, val protocolConfig: JSONObject + ) + + fun getProtocols(): List { + val ret = mutableListOf() + getManagedPlugins().forEach { (t, u) -> + u.optJSONArray("protocols")?.forEach { _, any -> + if (any is JSONObject) { + val name = any.optString("protocolId") + ret.add(Protocol(name, t, any)) + } + } + } + return ret + } + + fun findProtocol(protocolId: String): Protocol? { + getManagedPlugins().forEach { (t, u) -> + u.optJSONArray("protocols")?.forEach { _, any -> + if (any is JSONObject) { + if (protocolId == any.optString("protocolId")) { + return Protocol(protocolId, t, any) + } + } + } + } + return null + } + + fun removeManagedPlugin(plgId: String) { + DataStore.configurationStore.remove(plgId) + val dir = File(SagerNet.application.filesDir.absolutePath + "/plugins/" + plgId) + if (dir.exists()) { + dir.deleteRecursively() + } + } + + fun extractPlugin(plgId: String, install: Boolean) { + val app = PackageCache.installedApps[plgId] ?: return + val apk = File(app.publicSourceDir) + if (!apk.exists()) { + return + } + if (!install && !plugins.contains(plgId)) { + return + } + + val zipFile = ZipFile(apk) + val unzipDir = File(SagerNet.application.filesDir.absolutePath + "/plugins/" + plgId) + unzipDir.mkdirs() + for (entry in zipFile.entries()) { + if (entry.name.startsWith("assets/")) { + val relativePath = entry.name.removePrefix("assets/") + val outFile = File(unzipDir, relativePath) + if (entry.isDirectory) { + outFile.mkdirs() + continue + } + + if (outFile.isDirectory) { + outFile.delete() + } else if (outFile.exists()) { + val checksum = CRC32() + checksum.update(outFile.readBytes()) + if (checksum.value == entry.crc) { + continue + } + } + + val input = zipFile.getInputStream(entry) + outFile.outputStream().use { + input.copyTo(it) + } + } + } + zipFile.closeQuietly() + } + + suspend fun installPlugin(plgId: String) { + if (plgId == "moe.matsuri.plugin.singbox" || plgId == "moe.matsuri.plugin.xray") { + throw Exception("This plugin is deprecated") + } + extractPlugin(plgId, true) + NekoJSInterface.Default.destroyJsi(plgId) + NekoJSInterface.Default.requireJsi(plgId).init() + NekoJSInterface.Default.destroyJsi(plgId) + } + + const val PLUGIN_APP_VERSION = "_v_vc" + const val PLUGIN_APP_VERSION_NAME = "_v_vn" + + // Return null if not managed + fun tryGetPlgConfig(plgId: String): JSONObject? { + return try { + JSONObject(DataStore.configurationStore.getString(plgId)!!) + } catch (e: Exception) { + null + } + } + + fun updatePlgConfig(plgId: String, plgConfig: JSONObject) { + PackageCache.installedPluginPackages[plgId]?.apply { + // longVersionCode requires API 28 +// plgConfig.put(PLUGIN_APP_VERSION, versionCode) + plgConfig.put(PLUGIN_APP_VERSION_NAME, versionName) + } + DataStore.configurationStore.putString(plgId, plgConfig.toString()) + } + + fun htmlPath(plgId: String): String { + val htmlFile = File(SagerNet.application.filesDir.absolutePath + "/plugins/" + plgId) + return htmlFile.absolutePath + } + + class PluginInternalException(val protocolId: String) : Exception(), + BaseService.ExpectedException { + override fun getLocalizedMessage() = + SagerNet.application.getString(R.string.neko_plugin_internal_error, protocolId) + } + +} \ No newline at end of file diff --git a/app/src/main/java/moe/matsuri/nb4a/plugin/Plugins.kt b/app/src/main/java/moe/matsuri/nb4a/plugin/Plugins.kt new file mode 100644 index 0000000..e07adc2 --- /dev/null +++ b/app/src/main/java/moe/matsuri/nb4a/plugin/Plugins.kt @@ -0,0 +1,115 @@ +package moe.matsuri.nb4a.plugin + +import android.content.Intent +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.content.pm.ProviderInfo +import android.net.Uri +import android.os.Build +import android.widget.Toast +import io.nekohasekai.sagernet.SagerNet +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.plugin.PluginManager.loadString +import io.nekohasekai.sagernet.utils.PackageCache + +object Plugins { + const val AUTHORITIES_PREFIX_SEKAI_EXE = "io.nekohasekai.sagernet.plugin." + const val AUTHORITIES_PREFIX_NEKO_EXE = "moe.matsuri.exe." + const val AUTHORITIES_PREFIX_NEKO_PLUGIN = "moe.matsuri.plugin." + + const val ACTION_NATIVE_PLUGIN = "io.nekohasekai.sagernet.plugin.ACTION_NATIVE_PLUGIN" + + const val METADATA_KEY_ID = "io.nekohasekai.sagernet.plugin.id" + const val METADATA_KEY_EXECUTABLE_PATH = "io.nekohasekai.sagernet.plugin.executable_path" + + fun isExeOrPlugin(pkg: PackageInfo): Boolean { + if (pkg.providers == null || pkg.providers.isEmpty()) return false + val provider = pkg.providers[0] ?: return false + val auth = provider.authority ?: return false + return auth.startsWith(AUTHORITIES_PREFIX_SEKAI_EXE) + || auth.startsWith(AUTHORITIES_PREFIX_NEKO_EXE) + || auth.startsWith(AUTHORITIES_PREFIX_NEKO_PLUGIN) + } + + fun preferExePrefix(): String { + return AUTHORITIES_PREFIX_NEKO_EXE + } + + fun isUsingMatsuriExe(pluginId: String): Boolean { + getPlugin(pluginId)?.apply { + if (authority.startsWith(AUTHORITIES_PREFIX_NEKO_EXE)) { + return true + } + } + return false; + } + + fun displayExeProvider(pkgName: String): String { + return if (pkgName.startsWith(AUTHORITIES_PREFIX_SEKAI_EXE)) { + "SagerNet" + } else if (pkgName.startsWith(AUTHORITIES_PREFIX_NEKO_EXE)) { + "Matsuri" + } else { + "Unknown" + } + } + + fun getPlugin(pluginId: String): ProviderInfo? { + if (pluginId.isBlank()) return null + + // try queryIntentContentProviders + var providers = getPluginOld(pluginId) + + // try PackageCache + if (providers.isEmpty()) providers = getPluginNew(pluginId) + + // not found + if (providers.isEmpty()) return null + + if (providers.size > 1) { + val prefer = providers.filter { + it.authority.startsWith(preferExePrefix()) + } + if (prefer.size == 1) providers = prefer + } + + if (providers.size > 1) { + val message = + "Conflicting plugins found from: ${providers.joinToString { it.packageName }}" + Toast.makeText(SagerNet.application, message, Toast.LENGTH_LONG).show() + } + + return providers[0] + } + + fun getPluginNew(pluginId: String): List { + PackageCache.awaitLoadSync() + val pkgs = PackageCache.installedPluginPackages + .map { it.value } + .filter { it.providers[0].loadString(METADATA_KEY_ID) == pluginId } + return pkgs.map { it.providers[0] } + } + + private fun buildUri(id: String, auth: String) = Uri.Builder() + .scheme("plugin") + .authority(auth) + .path("/$id") + .build() + + private fun getPluginOld(pluginId: String): List { + var flags = PackageManager.GET_META_DATA + if (Build.VERSION.SDK_INT >= 24) { + flags = + flags or PackageManager.MATCH_DIRECT_BOOT_UNAWARE or PackageManager.MATCH_DIRECT_BOOT_AWARE + } + val list1 = SagerNet.application.packageManager.queryIntentContentProviders( + Intent(ACTION_NATIVE_PLUGIN, buildUri(pluginId, "io.nekohasekai.sagernet")), flags + ) + val list2 = SagerNet.application.packageManager.queryIntentContentProviders( + Intent(ACTION_NATIVE_PLUGIN, buildUri(pluginId, "moe.matsuri.lite")), flags + ) + return (list1 + list2).mapNotNull { + it.providerInfo + }.filter { it.exported } + } +} diff --git a/app/src/main/java/moe/matsuri/nb4a/proxy/PreferenceBinding.kt b/app/src/main/java/moe/matsuri/nb4a/proxy/PreferenceBinding.kt new file mode 100644 index 0000000..185a103 --- /dev/null +++ b/app/src/main/java/moe/matsuri/nb4a/proxy/PreferenceBinding.kt @@ -0,0 +1,100 @@ +package moe.matsuri.nb4a.proxy + +import androidx.preference.Preference +import com.takisoft.preferencex.EditTextPreference +import com.takisoft.preferencex.PreferenceFragmentCompat +import com.takisoft.preferencex.SimpleMenuPreference +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.ktx.Logs +import io.nekohasekai.sagernet.ktx.readableMessage + +object Type { + const val Text = 0 + const val TextToInt = 1 + const val Int = 2 + const val Bool = 3 +} + +class PreferenceBinding( + val type: Int = Type.Text, + var fieldName: String, + var bean: Any? = null, + var pf: PreferenceFragmentCompat? = null +) { + + var cacheName = fieldName + var disable = false + + fun readStringFromCache(): String { + return DataStore.profileCacheStore.getString(cacheName) ?: "" + } + + fun readBoolFromCache(): Boolean { + return DataStore.profileCacheStore.getBoolean(cacheName, false) + } + + fun readIntFromCache(): Int { + return DataStore.profileCacheStore.getInt(cacheName, 0) + } + + fun readStringToIntFromCache(): Int { + val value = DataStore.profileCacheStore.getString(cacheName)?.toIntOrNull() ?: 0 +// Logs.d("readStringToIntFromCache $value $cacheName -> $fieldName") + return value + } + + fun fromCache() { + if (disable) return + val f = try { + bean!!.javaClass.getField(fieldName) + } catch (e: Exception) { + Logs.d("binding no field: ${e.readableMessage}") + return + } + when (type) { + Type.Text -> f.set(bean, readStringFromCache()) + Type.TextToInt -> f.set(bean, readStringToIntFromCache()) + Type.Int -> f.set(bean, readIntFromCache()) + Type.Bool -> f.set(bean, readBoolFromCache()) + } + } + + fun writeToCache() { + if (disable) return + val f = try { + bean!!.javaClass.getField(fieldName) ?: return + } catch (e: Exception) { + Logs.d("binding no field: ${e.readableMessage}") + return + } + val value = f.get(bean) + when (type) { + Type.Text -> { + if (value is String) { +// Logs.d("writeToCache TEXT $value $cacheName -> $fieldName") + DataStore.profileCacheStore.putString(cacheName, value) + } + } + Type.TextToInt -> { + if (value is Int) { +// Logs.d("writeToCache TEXT2INT $value $cacheName -> $fieldName") + DataStore.profileCacheStore.putString(cacheName, value.toString()) + } + } + Type.Int -> { + if (value is Int) { + DataStore.profileCacheStore.putInt(cacheName, value) + } + } + Type.Bool -> { + if (value is Boolean) { + DataStore.profileCacheStore.putBoolean(cacheName, value) + } + } + } + } + + val preference by lazy { + pf!!.findPreference(cacheName)!! + } +} diff --git a/app/src/main/java/moe/matsuri/nb4a/proxy/PreferenceBindingManager.kt b/app/src/main/java/moe/matsuri/nb4a/proxy/PreferenceBindingManager.kt new file mode 100644 index 0000000..5da4856 --- /dev/null +++ b/app/src/main/java/moe/matsuri/nb4a/proxy/PreferenceBindingManager.kt @@ -0,0 +1,33 @@ +package moe.matsuri.nb4a.proxy + +import com.takisoft.preferencex.PreferenceFragmentCompat + +class PreferenceBindingManager { + val items = mutableListOf() + + fun add(b: PreferenceBinding): PreferenceBinding { + items.add(b) + return b + } + + fun fromCacheAll(bean: Any) { + items.forEach { + it.bean = bean + it.fromCache() + } + } + + fun writeToCacheAll(bean: Any) { + items.forEach { + it.bean = bean + it.writeToCache() + } + } + + fun setPreferenceFragment(pf: PreferenceFragmentCompat) { + items.forEach { + it.pf = pf + } + } + +} diff --git a/app/src/main/java/moe/matsuri/nb4a/proxy/config/ConfigBean.java b/app/src/main/java/moe/matsuri/nb4a/proxy/config/ConfigBean.java new file mode 100644 index 0000000..b3f18a0 --- /dev/null +++ b/app/src/main/java/moe/matsuri/nb4a/proxy/config/ConfigBean.java @@ -0,0 +1,73 @@ +package moe.matsuri.nb4a.proxy.config; + +import androidx.annotation.NonNull; + +import com.esotericsoftware.kryo.io.ByteBufferInput; +import com.esotericsoftware.kryo.io.ByteBufferOutput; + +import org.jetbrains.annotations.NotNull; + +import io.nekohasekai.sagernet.fmt.KryoConverters; +import io.nekohasekai.sagernet.fmt.internal.InternalBean; +import moe.matsuri.nb4a.utils.JavaUtil; + +public class ConfigBean extends InternalBean { + + public Integer type; // 0=config 1=outbound + public String config; + + @Override + public void initializeDefaultValues() { + super.initializeDefaultValues(); + if (type == null) type = 0; + if (config == null) config = ""; + } + + @Override + public void serialize(ByteBufferOutput output) { + output.writeInt(0); + super.serialize(output); + output.writeInt(type); + output.writeString(config); + } + + @Override + public void deserialize(ByteBufferInput input) { + int version = input.readInt(); + super.deserialize(input); + type = input.readInt(); + config = input.readString(); + } + + @Override + public String displayName() { + if (JavaUtil.isNotBlank(name)) { + return name; + } else { + return "Custom " + Math.abs(hashCode()); + } + } + + public String displayType() { + return type == 0 ? "sing-box config" : "sing-box outbound"; + } + + @NotNull + @Override + public ConfigBean clone() { + return KryoConverters.deserialize(new ConfigBean(), KryoConverters.serialize(this)); + } + + public static final Creator CREATOR = new CREATOR() { + @NonNull + @Override + public ConfigBean newInstance() { + return new ConfigBean(); + } + + @Override + public ConfigBean[] newArray(int size) { + return new ConfigBean[size]; + } + }; +} \ No newline at end of file diff --git a/app/src/main/java/moe/matsuri/nb4a/proxy/config/ConfigSettingActivity.kt b/app/src/main/java/moe/matsuri/nb4a/proxy/config/ConfigSettingActivity.kt new file mode 100644 index 0000000..246ec65 --- /dev/null +++ b/app/src/main/java/moe/matsuri/nb4a/proxy/config/ConfigSettingActivity.kt @@ -0,0 +1,67 @@ +package moe.matsuri.nb4a.proxy.config + +import android.os.Bundle +import androidx.preference.PreferenceDataStore +import androidx.preference.SwitchPreference +import com.takisoft.preferencex.EditTextPreference +import com.takisoft.preferencex.PreferenceFragmentCompat +import io.nekohasekai.sagernet.Key +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.preference.OnPreferenceDataStoreChangeListener +import io.nekohasekai.sagernet.ui.profile.ProfileSettingsActivity + +class ConfigSettingActivity : + ProfileSettingsActivity(), + OnPreferenceDataStoreChangeListener { + + var beanType: Int = 0 + + lateinit var configPreference: EditTextPreference + + override fun createEntity() = ConfigBean() + + override fun ConfigBean.init() { + // CustomBean to input + beanType = type + DataStore.profileName = name + DataStore.serverConfig = config + } + + override fun ConfigBean.serialize() { + // CustomBean from input + type = beanType + name = DataStore.profileName + config = DataStore.serverConfig + } + + override fun onCreate(savedInstanceState: Bundle?) { + intent?.getIntExtra("type", 0)?.apply { beanType = this } + super.onCreate(savedInstanceState) + } + + override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) { + if (key != Key.PROFILE_DIRTY) { + DataStore.dirty = true + } + if (key == Key.SERVER_CONFIG) { + if (::configPreference.isInitialized) { + configPreference.text = store.getString(key, "") + } + } else if (key == "isOutboundOnly") { + beanType = if (store.getBoolean(key, false)) 1 else 0 + } + } + + override fun PreferenceFragmentCompat.createPreferences( + savedInstanceState: Bundle?, + rootKey: String?, + ) { + addPreferencesFromResource(R.xml.config_preferences) + + configPreference = findPreference(Key.SERVER_CONFIG)!! + + findPreference("isOutboundOnly")!!.isChecked = beanType == 1 + } + +} \ No newline at end of file diff --git a/app/src/main/java/moe/matsuri/nb4a/proxy/neko/NekoBean.java b/app/src/main/java/moe/matsuri/nb4a/proxy/neko/NekoBean.java new file mode 100644 index 0000000..3a86b7f --- /dev/null +++ b/app/src/main/java/moe/matsuri/nb4a/proxy/neko/NekoBean.java @@ -0,0 +1,110 @@ +package moe.matsuri.nb4a.proxy.neko; + +import androidx.annotation.NonNull; + +import com.esotericsoftware.kryo.io.ByteBufferInput; +import com.esotericsoftware.kryo.io.ByteBufferOutput; + +import org.jetbrains.annotations.NotNull; +import org.json.JSONObject; + +import io.nekohasekai.sagernet.R; +import io.nekohasekai.sagernet.SagerNet; +import io.nekohasekai.sagernet.fmt.AbstractBean; +import io.nekohasekai.sagernet.fmt.KryoConverters; +import io.nekohasekai.sagernet.ktx.Logs; +import moe.matsuri.nb4a.plugin.NekoPluginManager; + +public class NekoBean extends AbstractBean { + + // BoxInstance use this + public JSONObject allConfig = null; + + public String plgId; + public String protocolId; + public JSONObject sharedStorage = new JSONObject(); + + @Override + public void initializeDefaultValues() { + super.initializeDefaultValues(); + if (protocolId == null) protocolId = ""; + if (plgId == null) plgId = "moe.matsuri.plugin.donotexist"; + } + + @Override + public void serialize(ByteBufferOutput output) { + output.writeInt(0); + super.serialize(output); + output.writeString(plgId); + output.writeString(protocolId); + output.writeString(sharedStorage.toString()); + } + + @Override + public void deserialize(ByteBufferInput input) { + int version = input.readInt(); + super.deserialize(input); + plgId = input.readString(); + protocolId = input.readString(); + sharedStorage = tryParseJSON(input.readString()); + } + + @NotNull + public static JSONObject tryParseJSON(String input) { + JSONObject ret; + try { + ret = new JSONObject(input); + } catch (Exception e) { + ret = new JSONObject(); + Logs.INSTANCE.e(e); + } + return ret; + } + + public String displayType() { + NekoPluginManager.Protocol p = NekoPluginManager.INSTANCE.findProtocol(protocolId); + String neko = SagerNet.application.getResources().getString(R.string.neko_plugin); + if (p == null) return neko; + return p.getProtocolId(); + } + + @Override + public boolean canMapping() { + NekoPluginManager.Protocol p = NekoPluginManager.INSTANCE.findProtocol(protocolId); + if (p == null) return false; + return p.getProtocolConfig().optBoolean("canMapping"); + } + + @Override + public boolean canICMPing() { + NekoPluginManager.Protocol p = NekoPluginManager.INSTANCE.findProtocol(protocolId); + if (p == null) return false; + return p.getProtocolConfig().optBoolean("canICMPing"); + } + + @Override + public boolean canTCPing() { + NekoPluginManager.Protocol p = NekoPluginManager.INSTANCE.findProtocol(protocolId); + if (p == null) return false; + return p.getProtocolConfig().optBoolean("canTCPing"); + } + + @NotNull + @Override + public NekoBean clone() { + return KryoConverters.deserialize(new NekoBean(), KryoConverters.serialize(this)); + } + + public static final Creator CREATOR = new CREATOR() { + @NonNull + @Override + public NekoBean newInstance() { + return new NekoBean(); + } + + @Override + public NekoBean[] newArray(int size) { + return new NekoBean[size]; + } + }; +} \ No newline at end of file diff --git a/app/src/main/java/moe/matsuri/nb4a/proxy/neko/NekoFmt.kt b/app/src/main/java/moe/matsuri/nb4a/proxy/neko/NekoFmt.kt new file mode 100644 index 0000000..88e45ec --- /dev/null +++ b/app/src/main/java/moe/matsuri/nb4a/proxy/neko/NekoFmt.kt @@ -0,0 +1,123 @@ +package moe.matsuri.nb4a.proxy.neko + +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.ktx.Logs +import io.nekohasekai.sagernet.ktx.getStr +import io.nekohasekai.sagernet.ktx.runOnIoDispatcher +import libcore.Libcore +import moe.matsuri.nb4a.Protocols +import moe.matsuri.nb4a.plugin.NekoPluginManager +import org.json.JSONObject +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +suspend fun parseShareLink(plgId: String, protocolId: String, link: String): NekoBean = + suspendCoroutine { + runOnIoDispatcher { + val jsi = NekoJSInterface.Default.requireJsi(plgId) + jsi.lock() + + try { + jsi.init() + + val jsip = jsi.switchProtocol(protocolId) + val sharedStorage = jsip.parseShareLink(link) + + // NekoBean from link + val bean = NekoBean() + bean.plgId = plgId + bean.protocolId = protocolId + bean.sharedStorage = NekoBean.tryParseJSON(sharedStorage) + bean.onSharedStorageSet() + + it.resume(bean) + } catch (e: Exception) { + Logs.e(e) + it.resume(NekoBean().apply { + this.plgId = plgId + this.protocolId = protocolId + }) + } + + jsi.unlock() + // destroy when all link parsed + } + } + +fun NekoBean.shareLink(): String { + return sharedStorage.optString("shareLink") +} + +// Only run in bg process +// seems no concurrent +suspend fun NekoBean.updateAllConfig(port: Int) = suspendCoroutine { + allConfig = null + + runOnIoDispatcher { + val jsi = NekoJSInterface.Default.requireJsi(plgId) + jsi.lock() + + try { + jsi.init() + val jsip = jsi.switchProtocol(protocolId) + + // runtime arguments + val otherArgs = mutableMapOf() + otherArgs["finalAddress"] = finalAddress + otherArgs["finalPort"] = finalPort + otherArgs["muxEnabled"] = Protocols.shouldEnableMux(protocolId) + otherArgs["muxConcurrency"] = DataStore.muxConcurrency + + val ret = jsip.buildAllConfig(port, this@updateAllConfig, otherArgs) + + // result + allConfig = JSONObject(ret) + } catch (e: Exception) { + Logs.e(e) + } + + jsi.unlock() + it.resume(Unit) + // destroy when config generated / all tests finished + } +} + +fun NekoBean.cacheGet(id: String): String? { + return DataStore.profileCacheStore.getString("neko_${hash()}_$id") +} + +fun NekoBean.cacheSet(id: String, value: String) { + DataStore.profileCacheStore.putString("neko_${hash()}_$id", value) +} + +fun NekoBean.hash(): String { + var a = plgId + a += protocolId + a += sharedStorage.toString() + return Libcore.sha256Hex(a.toByteArray()) +} + +// must call it to update something like serverAddress +fun NekoBean.onSharedStorageSet() { + serverAddress = sharedStorage.getStr("serverAddress") + serverPort = sharedStorage.getStr("serverPort")?.toInt() ?: 1080 + if (serverAddress == null || serverAddress.isBlank()) { + serverAddress = "127.0.0.1" + } + name = sharedStorage.optString("name") +} + +fun NekoBean.needBypassRootUid(): Boolean { + val p = NekoPluginManager.findProtocol(protocolId) ?: return false + return p.protocolConfig.optBoolean("needBypassRootUid") +} + +fun NekoBean.haveStandardLink(): Boolean { + val p = NekoPluginManager.findProtocol(protocolId) ?: return false + return p.protocolConfig.optBoolean("haveStandardLink") +} + +fun NekoBean.canShare(): Boolean { + val p = NekoPluginManager.findProtocol(protocolId) ?: return false + return p.protocolConfig.optBoolean("canShare") +} diff --git a/app/src/main/java/moe/matsuri/nb4a/proxy/neko/NekoJSInterface.kt b/app/src/main/java/moe/matsuri/nb4a/proxy/neko/NekoJSInterface.kt new file mode 100644 index 0000000..35b5712 --- /dev/null +++ b/app/src/main/java/moe/matsuri/nb4a/proxy/neko/NekoJSInterface.kt @@ -0,0 +1,388 @@ +package moe.matsuri.nb4a.proxy.neko + +import android.annotation.SuppressLint +import android.webkit.* +import android.widget.Toast +import androidx.preference.Preference +import androidx.preference.PreferenceScreen +import com.takisoft.preferencex.SimpleMenuPreference +import io.nekohasekai.sagernet.BuildConfig +import io.nekohasekai.sagernet.SagerNet +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.ktx.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.withContext +import moe.matsuri.nb4a.plugin.NekoPluginManager +import moe.matsuri.nb4a.utils.JavaUtil +import moe.matsuri.nb4a.utils.Util +import moe.matsuri.nb4a.utils.WebViewUtil +import org.json.JSONObject +import java.io.File +import java.io.FileInputStream +import java.util.* +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +class NekoJSInterface(val plgId: String) { + + private val mutex = Mutex() + private var webView: WebView? = null + val jsObject = JsObject() + var plgConfig: JSONObject? = null + var plgConfigException: Exception? = null + val protocols = mutableMapOf() + val loaded = AtomicBoolean() + + suspend fun lock() { + mutex.lock(null) + } + + fun unlock() { + mutex.unlock(null) + } + + // load webview and js + // Return immediately when already loaded + // Return plgConfig or throw exception + suspend fun init() = withContext(Dispatchers.Main) { + initInternal() + } + + @SuppressLint("SetJavaScriptEnabled") + private suspend fun initInternal() = suspendCoroutine { + if (loaded.get()) { + plgConfig?.apply { + it.resume(this) + return@suspendCoroutine + } + plgConfigException?.apply { + it.resumeWithException(this) + return@suspendCoroutine + } + it.resumeWithException(Exception("wtf")) + return@suspendCoroutine + } + + WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG) + NekoPluginManager.extractPlugin(plgId, false) + + webView = WebView(SagerNet.application.applicationContext) + webView!!.settings.javaScriptEnabled = true + webView!!.addJavascriptInterface(jsObject, "neko") + webView!!.webViewClient = object : WebViewClient() { + // provide files + override fun shouldInterceptRequest( + view: WebView?, request: WebResourceRequest? + ): WebResourceResponse { + return WebViewUtil.interceptRequest( + { res -> + val f = File(NekoPluginManager.htmlPath(plgId), res) + if (f.exists()) { + FileInputStream(f) + } else { + null + } + }, + view, + request + ) + } + + override fun onReceivedError( + view: WebView?, request: WebResourceRequest?, error: WebResourceError? + ) { + WebViewUtil.onReceivedError(view, request, error) + } + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + if (loaded.getAndSet(true)) return + + runOnIoDispatcher { + // Process nekoInit + var ret = "" + try { + ret = nekoInit() + val obj = JSONObject(ret) + if (!obj.getBoolean("ok")) { + throw Exception("plugin refuse to run: ${obj.optString("reason")}") + } + val min = obj.getInt("minVersion") + if (min > NekoPluginManager.managerVersion) { + throw Exception("manager version ${NekoPluginManager.managerVersion} too old, this plugin requires >= $min") + } + plgConfig = obj + NekoPluginManager.updatePlgConfig(plgId, obj) + it.resume(obj) + } catch (e: Exception) { + val e2 = Exception("nekoInit: " + e.readableMessage + "\n\n" + ret) + plgConfigException = e2 + it.resumeWithException(e2) + } + } + } + } + webView!!.loadUrl("http://$plgId/plugin.html") + } + + // Android call JS + + private suspend fun callJS(script: String): String = suspendCoroutine { + val jsLatch = CountDownLatch(1) + var jsReceivedValue = "" + + runOnMainDispatcher { + if (webView != null) { + webView!!.evaluateJavascript(script) { value -> + jsReceivedValue = value + jsLatch.countDown() + } + } else { + jsReceivedValue = "webView is null" + jsLatch.countDown() + } + } + + jsLatch.await(5, TimeUnit.SECONDS) + + // evaluateJavascript escapes Javascript's String + jsReceivedValue = JavaUtil.unescapeString(jsReceivedValue.removeSurrounding("\"")) + if (BuildConfig.DEBUG) Logs.d("$script: $jsReceivedValue") + it.resume(jsReceivedValue) + } + + // call once + private suspend fun nekoInit(): String { + val sendData = JSONObject() + sendData.put("lang", Locale.getDefault().toLanguageTag()) + sendData.put("plgId", plgId) + sendData.put("managerVersion", NekoPluginManager.managerVersion) + + return callJS( + "nekoInit(\"${ + Util.b64EncodeUrlSafe( + sendData.toString().toByteArray() + ) + }\")" + ) + } + + fun switchProtocol(id: String): NekoProtocol { + lateinit var p: NekoProtocol + if (protocols.containsKey(id)) { + p = protocols[id]!! + } else { + p = NekoProtocol(id) { callJS(it) } + protocols[id] = p + } + jsObject.protocol = p + return p + } + + suspend fun getAbout(): String { + return callJS("nekoAbout()") + } + + inner class NekoProtocol(val protocolId: String, val callJS: suspend (String) -> String) { + private suspend fun callProtocol(method: String, b64Str: String?): String { + var arg = "" + if (b64Str != null) { + arg = "\"" + b64Str + "\"" + } + return callJS("nekoProtocol(\"$protocolId\").$method($arg)") + } + + suspend fun buildAllConfig( + port: Int, bean: NekoBean, otherArgs: Map? + ): String { + val sendData = JSONObject() + sendData.put("port", port) + sendData.put( + "sharedStorage", + Util.b64EncodeUrlSafe(bean.sharedStorage.toString().toByteArray()) + ) + otherArgs?.forEach { (t, u) -> sendData.put(t, u) } + + return callProtocol( + "buildAllConfig", Util.b64EncodeUrlSafe(sendData.toString().toByteArray()) + ) + } + + suspend fun parseShareLink(shareLink: String): String { + val sendData = JSONObject() + sendData.put("shareLink", shareLink) + + return callProtocol( + "parseShareLink", Util.b64EncodeUrlSafe(sendData.toString().toByteArray()) + ) + } + + // UI Interface + + suspend fun setSharedStorage(sharedStorage: String) { + callProtocol( + "setSharedStorage", + Util.b64EncodeUrlSafe(sharedStorage.toByteArray()) + ) + } + + suspend fun requireSetProfileCache() { + callProtocol("requireSetProfileCache", null) + } + + suspend fun requirePreferenceScreenConfig(): String { + return callProtocol("requirePreferenceScreenConfig", null) + } + + suspend fun sharedStorageFromProfileCache(): String { + return callProtocol("sharedStorageFromProfileCache", null) + } + + suspend fun onPreferenceCreated() { + callProtocol("onPreferenceCreated", null) + } + + suspend fun onPreferenceChanged(key: String, v: Any) { + val sendData = JSONObject() + sendData.put("key", key) + sendData.put("newValue", v) + + callProtocol( + "onPreferenceChanged", + Util.b64EncodeUrlSafe(sendData.toString().toByteArray()) + ) + } + + } + + inner class JsObject { + var preferenceScreen: PreferenceScreen? = null + var protocol: NekoProtocol? = null + + // JS call Android + + @JavascriptInterface + fun toast(s: String) { + Toast.makeText(SagerNet.application.applicationContext, s, Toast.LENGTH_SHORT).show() + } + + @JavascriptInterface + fun logError(s: String) { + Logs.e("logError: $s") + } + + @JavascriptInterface + fun setPreferenceVisibility(key: String, isVisible: Boolean) { + runBlockingOnMainDispatcher { + preferenceScreen?.findPreference(key)?.isVisible = isVisible + } + } + + @JavascriptInterface + fun setPreferenceTitle(key: String, title: String) { + runBlockingOnMainDispatcher { + preferenceScreen?.findPreference(key)?.title = title + } + } + + @JavascriptInterface + fun setMenu(key: String, entries: String) { + runBlockingOnMainDispatcher { + preferenceScreen?.findPreference(key)?.apply { + NekoPreferenceInflater.setMenu(this, JSONObject(entries)) + } + } + } + + @JavascriptInterface + fun listenOnPreferenceChanged(key: String) { + preferenceScreen?.findPreference(key) + ?.setOnPreferenceChangeListener { preference, newValue -> + runOnIoDispatcher { + protocol?.onPreferenceChanged(preference.key, newValue) + } + true + } + } + + @JavascriptInterface + fun setKV(type: Int, key: String, jsonStr: String) { + try { + val v = JSONObject(jsonStr) + when (type) { + 0 -> DataStore.profileCacheStore.putBoolean(key, v.getBoolean("v")) + 1 -> DataStore.profileCacheStore.putFloat(key, v.getDouble("v").toFloat()) + 2 -> DataStore.profileCacheStore.putInt(key, v.getInt("v")) + 3 -> DataStore.profileCacheStore.putLong(key, v.getLong("v")) + 4 -> DataStore.profileCacheStore.putString(key, v.getString("v")) + } + } catch (e: Exception) { + Logs.e("setKV: $e") + } + } + + @JavascriptInterface + fun getKV(type: Int, key: String): String { + val v = JSONObject() + try { + when (type) { + 0 -> v.put("v", DataStore.profileCacheStore.getBoolean(key)) + 1 -> v.put("v", DataStore.profileCacheStore.getFloat(key)) + 2 -> v.put("v", DataStore.profileCacheStore.getInt(key)) + 3 -> v.put("v", DataStore.profileCacheStore.getLong(key)) + 4 -> v.put("v", DataStore.profileCacheStore.getString(key)) + } + } catch (e: Exception) { + Logs.e("getKV: $e") + } + return v.toString() + } + + } + + fun destroy() { + webView?.onPause() + webView?.removeAllViews() + webView?.destroy() + webView = null + } + + suspend fun destorySuspend() = withContext(Dispatchers.Main) { + destroy() + } + + object Default { + val map = mutableMapOf() + + suspend fun destroyJsi(plgId: String) = withContext(Dispatchers.Main) { + if (map.containsKey(plgId)) { + map[plgId]!!.destroy() + map.remove(plgId) + } + } + + // now it's manually managed + suspend fun destroyAllJsi() = withContext(Dispatchers.Main) { + map.forEach { (t, u) -> + u.destroy() + map.remove(t) + } + } + + suspend fun requireJsi(plgId: String): NekoJSInterface = withContext(Dispatchers.Main) { + lateinit var jsi: NekoJSInterface + if (map.containsKey(plgId)) { + jsi = map[plgId]!! + } else { + jsi = NekoJSInterface(plgId) + map[plgId] = jsi + } + return@withContext jsi + } + } +} diff --git a/app/src/main/java/moe/matsuri/nb4a/proxy/neko/NekoPreferenceInflater.kt b/app/src/main/java/moe/matsuri/nb4a/proxy/neko/NekoPreferenceInflater.kt new file mode 100644 index 0000000..5453f5e --- /dev/null +++ b/app/src/main/java/moe/matsuri/nb4a/proxy/neko/NekoPreferenceInflater.kt @@ -0,0 +1,92 @@ +package moe.matsuri.nb4a.proxy.neko + +import androidx.preference.Preference +import androidx.preference.PreferenceScreen +import androidx.preference.SwitchPreference +import com.takisoft.preferencex.EditTextPreference +import com.takisoft.preferencex.PreferenceCategory +import com.takisoft.preferencex.SimpleMenuPreference +import io.nekohasekai.sagernet.database.preference.EditTextPreferenceModifiers +import io.nekohasekai.sagernet.ktx.forEach +import io.nekohasekai.sagernet.ktx.getStr +import io.nekohasekai.sagernet.ui.profile.ProfileSettingsActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import moe.matsuri.nb4a.utils.getDrawableByName +import org.json.JSONArray +import org.json.JSONObject + +object NekoPreferenceInflater { + suspend fun inflate(pref: JSONArray, preferenceScreen: PreferenceScreen) = + withContext(Dispatchers.Main) { + val context = preferenceScreen.context + pref.forEach { _, category -> + category as JSONObject + + val preferenceCategory = PreferenceCategory(context) + preferenceScreen.addPreference(preferenceCategory) + + category.getStr("key")?.apply { preferenceCategory.key = this } + category.getStr("title")?.apply { preferenceCategory.title = this } + + category.optJSONArray("preferences")?.forEach { _, any -> + if (any is JSONObject) { + lateinit var p: Preference + // Create Preference + when (any.getStr("type")) { + "EditTextPreference" -> { + p = EditTextPreference(context).apply { + when (any.getStr("summaryProvider")) { + null -> summaryProvider = androidx.preference.EditTextPreference.SimpleSummaryProvider.getInstance() + "PasswordSummaryProvider" -> summaryProvider = ProfileSettingsActivity.PasswordSummaryProvider + } + when (any.getStr("EditTextPreferenceModifiers")) { + "Monospace" -> onBindEditTextListener = EditTextPreferenceModifiers.Monospace + "Hosts" -> onBindEditTextListener = EditTextPreferenceModifiers.Hosts + "Port" -> onBindEditTextListener = EditTextPreferenceModifiers.Port + "Number" -> onBindEditTextListener = EditTextPreferenceModifiers.Number + } + } + } + "SwitchPreference" -> { + p = SwitchPreference(context) + } + "SimpleMenuPreference" -> { + p = SimpleMenuPreference(context).apply { + summaryProvider = androidx.preference.ListPreference.SimpleSummaryProvider.getInstance() + val entries = any.optJSONObject("entries") + if (entries != null) setMenu(this, entries) + } + } + } + // Set key & title + p.key = any.getString("key") + any.getStr("title")?.apply { p.title = this } + // Set icon + any.getStr("icon")?.apply { + p.icon = context.getDrawableByName(this) + } + // Set summary + any.getStr("summary")?.apply { + p.summary = this + } + // Add to category + preferenceCategory.addPreference(p) + } + } + } + } + + fun setMenu(p: SimpleMenuPreference, entries: JSONObject) { + val menuEntries = mutableListOf() + val menuEntryValues = mutableListOf() + entries.forEach { s, b -> + menuEntryValues.add(s) + menuEntries.add(b as String) + } + entries.apply { + p.setEntries(menuEntries.toTypedArray()) + p.setEntryValues(menuEntryValues.toTypedArray()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/matsuri/nb4a/proxy/neko/NekoSettingActivity.kt b/app/src/main/java/moe/matsuri/nb4a/proxy/neko/NekoSettingActivity.kt new file mode 100644 index 0000000..0f3eb7c --- /dev/null +++ b/app/src/main/java/moe/matsuri/nb4a/proxy/neko/NekoSettingActivity.kt @@ -0,0 +1,102 @@ +package moe.matsuri.nb4a.proxy.neko + +import android.os.Bundle +import android.view.View +import androidx.core.view.isVisible +import androidx.preference.PreferenceDataStore +import com.takisoft.preferencex.PreferenceFragmentCompat +import io.nekohasekai.sagernet.Key +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.ktx.runOnIoDispatcher +import io.nekohasekai.sagernet.ui.profile.ProfileSettingsActivity +import moe.matsuri.nb4a.ui.Dialogs +import org.json.JSONArray + +class NekoSettingActivity : ProfileSettingsActivity() { + + lateinit var jsi: NekoJSInterface + lateinit var jsip: NekoJSInterface.NekoProtocol + lateinit var plgId: String + lateinit var protocolId: String + var loaded = false + + override fun createEntity() = NekoBean() + + override fun NekoBean.init() { + if (!this@NekoSettingActivity::plgId.isInitialized) this@NekoSettingActivity.plgId = plgId + if (!this@NekoSettingActivity::protocolId.isInitialized) this@NekoSettingActivity.protocolId = protocolId + DataStore.profileCacheStore.putString("name", name) + DataStore.sharedStorage = sharedStorage.toString() + } + + override fun NekoBean.serialize() { + // NekoBean from input + plgId = this@NekoSettingActivity.plgId + protocolId = this@NekoSettingActivity.protocolId + + sharedStorage = NekoBean.tryParseJSON(DataStore.sharedStorage) + onSharedStorageSet() + } + + override fun onCreate(savedInstanceState: Bundle?) { + intent?.getStringExtra("plgId")?.apply { plgId = this } + intent?.getStringExtra("protocolId")?.apply { protocolId = this } + super.onCreate(savedInstanceState) + } + + override fun PreferenceFragmentCompat.viewCreated(view: View, savedInstanceState: Bundle?) { + listView.isVisible = false + } + + override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) { + if (loaded && key != Key.PROFILE_DIRTY) { + DataStore.dirty = true + } + } + + override fun PreferenceFragmentCompat.createPreferences( + savedInstanceState: Bundle?, + rootKey: String?, + ) { + addPreferencesFromResource(R.xml.neko_preferences) + + // Create a jsi + jsi = NekoJSInterface(plgId) + runOnIoDispatcher { + try { + jsi.init() + jsip = jsi.switchProtocol(protocolId) + jsi.jsObject.preferenceScreen = preferenceScreen + + // Because of the Preference problem, first require the KV and then inflate the UI + jsip.setSharedStorage(DataStore.sharedStorage) + jsip.requireSetProfileCache() + + val config = jsip.requirePreferenceScreenConfig() + val pref = JSONArray(config) + + NekoPreferenceInflater.inflate(pref, preferenceScreen) + jsip.onPreferenceCreated() + + runOnUiThread { + loaded = true + listView.isVisible = true + } + } catch (e: Exception) { + Dialogs.logExceptionAndShow(this@NekoSettingActivity, e) { finish() } + } + } + } + + override suspend fun saveAndExit() { + DataStore.sharedStorage = jsip.sharedStorageFromProfileCache() + super.saveAndExit() // serialize & finish + } + + override fun onDestroy() { + jsi.destroy() + super.onDestroy() + } + +} \ No newline at end of file diff --git a/app/src/main/java/moe/matsuri/nb4a/ui/ColorPickerPreference.kt b/app/src/main/java/moe/matsuri/nb4a/ui/ColorPickerPreference.kt new file mode 100644 index 0000000..ee0728e --- /dev/null +++ b/app/src/main/java/moe/matsuri/nb4a/ui/ColorPickerPreference.kt @@ -0,0 +1,120 @@ +package moe.matsuri.nb4a.ui + +import android.content.Context +import android.content.res.Resources +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.GridLayout +import android.widget.ImageView +import android.widget.LinearLayout +import androidx.appcompat.app.AlertDialog +import androidx.core.content.res.ResourcesCompat +import androidx.core.content.res.TypedArrayUtils +import androidx.core.graphics.drawable.DrawableCompat +import androidx.core.view.setPadding +import androidx.preference.Preference +import androidx.preference.PreferenceViewHolder +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.ktx.getColorAttr +import io.nekohasekai.sagernet.ktx.isExpertFlavor +import kotlin.math.roundToInt + +class ColorPickerPreference +@JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, defStyle: Int = TypedArrayUtils.getAttr( + context, + androidx.preference.R.attr.editTextPreferenceStyle, + android.R.attr.editTextPreferenceStyle + ) +) : Preference( + context, attrs, defStyle +) { + + var inited = false + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + + val widgetFrame = holder.findViewById(android.R.id.widget_frame) as LinearLayout + + if (!inited) { + inited = true + + widgetFrame.addView( + getNekoImageViewAtColor( + context.getColorAttr(R.attr.colorPrimary), + 48, + 0 + ) + ) + widgetFrame.visibility = View.VISIBLE + } + } + + fun getNekoImageViewAtColor(color: Int, sizeDp: Int, paddingDp: Int): ImageView { + // dp to pixel + val factor = context.resources.displayMetrics.density + val size = (sizeDp * factor).roundToInt() + val paddingSize = (paddingDp * factor).roundToInt() + + return ImageView(context).apply { + layoutParams = ViewGroup.LayoutParams(size, size) + setPadding(paddingSize) + setImageDrawable(getNekoAtColor(resources, color)) + } + } + + fun getNekoAtColor(res: Resources, color: Int): Drawable { + val neko = ResourcesCompat.getDrawable( + res, + R.drawable.ic_baseline_fiber_manual_record_24, + null + )!! + DrawableCompat.setTint(neko.mutate(), color) + return neko + } + + override fun onClick() { + super.onClick() + + lateinit var dialog: AlertDialog + + val grid = GridLayout(context).apply { + columnCount = 4 + + val colors = context.resources.getIntArray(R.array.material_colors) + var i = 0 + + for (color in colors) { + i++ //Theme.kt + if (!isExpertFlavor && i in listOf(21)) continue + + val themeId = i + val view = getNekoImageViewAtColor(color, 64, 0).apply { + setOnClickListener { + persistInt(themeId) + dialog.dismiss() + callChangeListener(themeId) + } + } + addView(view) + } + + } + + dialog = MaterialAlertDialogBuilder(context).setTitle(title) + .setView(LinearLayout(context).apply { + gravity = Gravity.CENTER + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT + ) + addView(grid) + }) + .setNegativeButton(android.R.string.cancel, null) + .show() + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/matsuri/nb4a/ui/Dialogs.kt b/app/src/main/java/moe/matsuri/nb4a/ui/Dialogs.kt new file mode 100644 index 0000000..0bc7de4 --- /dev/null +++ b/app/src/main/java/moe/matsuri/nb4a/ui/Dialogs.kt @@ -0,0 +1,38 @@ +package moe.matsuri.nb4a.ui + +import android.content.Context +import android.widget.TextView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.ktx.Logs +import io.nekohasekai.sagernet.ktx.readableMessage +import io.nekohasekai.sagernet.ktx.runOnMainDispatcher + +object Dialogs { + fun logExceptionAndShow(context: Context, e: Exception, callback: Runnable) { + Logs.e(e) + runOnMainDispatcher { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.error_title) + .setMessage(e.readableMessage) + .setCancelable(false) + .setPositiveButton(android.R.string.ok) { _, _ -> + callback.run() + } + .show() + } + } + + fun message(context: Context, title: String, message: String) { + runOnMainDispatcher { + val dialog = MaterialAlertDialogBuilder(context) + .setTitle(title) + .setMessage(message) + .setCancelable(true) + .show() + dialog.findViewById(android.R.id.message)?.apply { + setTextIsSelectable(true) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/matsuri/nb4a/ui/LongClickSwitchPreference.kt b/app/src/main/java/moe/matsuri/nb4a/ui/LongClickSwitchPreference.kt new file mode 100644 index 0000000..aee8dfe --- /dev/null +++ b/app/src/main/java/moe/matsuri/nb4a/ui/LongClickSwitchPreference.kt @@ -0,0 +1,33 @@ +package moe.matsuri.nb4a.ui + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import androidx.core.content.res.TypedArrayUtils +import androidx.preference.PreferenceViewHolder +import androidx.preference.R +import androidx.preference.SwitchPreference + +class LongClickSwitchPreference +@JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = TypedArrayUtils.getAttr( + context, R.attr.switchPreferenceStyle, android.R.attr.switchPreferenceStyle + ), defStyleRes: Int = 0 +) : SwitchPreference( + context, attrs, defStyleAttr, defStyleRes +) { + private var mLongClickListener: View.OnLongClickListener? = null + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + val itemView: View = holder.itemView + itemView.setOnLongClickListener { + mLongClickListener?.onLongClick(it) ?: true + } + } + + fun setOnLongClickListener(longClickListener: View.OnLongClickListener) { + this.mLongClickListener = longClickListener + } + +} diff --git a/app/src/main/java/moe/matsuri/nb4a/ui/MTUPreference.kt b/app/src/main/java/moe/matsuri/nb4a/ui/MTUPreference.kt new file mode 100644 index 0000000..cbcdf8d --- /dev/null +++ b/app/src/main/java/moe/matsuri/nb4a/ui/MTUPreference.kt @@ -0,0 +1,51 @@ +package moe.matsuri.nb4a.ui + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.view.inputmethod.EditorInfo +import android.widget.EditText +import androidx.preference.PreferenceViewHolder +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.takisoft.preferencex.SimpleMenuPreference + +class MTUPreference : SimpleMenuPreference { + constructor(context: Context?) : super(context) + constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super( + context, attrs, defStyleAttr + ) + + constructor( + context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int + ) : super(context, attrs, defStyleAttr, defStyleRes) + + init { + setSummaryProvider { + value.toString() + } + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + val itemView: View = holder.itemView + itemView.setOnLongClickListener { + val view = EditText(context).apply { + inputType = EditorInfo.TYPE_CLASS_NUMBER + setText(preferenceDataStore?.getString(key, "") ?: "") + } + + MaterialAlertDialogBuilder(context).setTitle("MTU") + .setView(view) + .setPositiveButton(android.R.string.ok) { _, _ -> + val mtu = view.text.toString().toInt() + if (mtu < 1000 || mtu > 10000) return@setPositiveButton + value = mtu.toString() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + true + } + } + +} diff --git a/app/src/main/java/moe/matsuri/nb4a/utils/JavaUtil.java b/app/src/main/java/moe/matsuri/nb4a/utils/JavaUtil.java new file mode 100644 index 0000000..1e36147 --- /dev/null +++ b/app/src/main/java/moe/matsuri/nb4a/utils/JavaUtil.java @@ -0,0 +1,200 @@ +package moe.matsuri.nb4a.utils; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.app.Application; +import android.content.Context; +import android.os.Build; +import android.text.TextUtils; +import android.webkit.WebView; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.ToNumberPolicy; + +import java.io.File; +import java.io.RandomAccessFile; +import java.lang.reflect.Method; +import java.nio.channels.FileLock; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.nekohasekai.sagernet.BuildConfig; +import io.nekohasekai.sagernet.ktx.Logs; +import kotlin.text.StringsKt; + +public class JavaUtil { + + // The encoded character of each character escape. + // This array functions as the keys of a sorted map, from encoded characters to decoded characters. + static final char[] ENCODED_ESCAPES = {'\"', '\'', '\\', 'b', 'f', 'n', 'r', 't'}; + + // The decoded character of each character escape. + // This array functions as the values of a sorted map, from encoded characters to decoded characters. + static final char[] DECODED_ESCAPES = {'\"', '\'', '\\', '\b', '\f', '\n', '\r', '\t'}; + + // A pattern that matches an escape. + // What follows the escape indicator is captured by group 1=character 2=octal 3=Unicode. + static final Pattern PATTERN = Pattern.compile("\\\\(?:(b|t|n|f|r|\\\"|\\\'|\\\\)|((?:[0-3]?[0-7])?[0-7])|u+(\\p{XDigit}{4}))"); + + // Process the return of webView.evaluateJavascript + public static String unescapeString(CharSequence encodedString) { + Matcher matcher = PATTERN.matcher(encodedString); + StringBuffer decodedString = new StringBuffer(); + // Find each escape of the encoded string in succession. + while (matcher.find()) { + char ch; + if (matcher.start(1) >= 0) { + // Decode a character escape. + ch = DECODED_ESCAPES[Arrays.binarySearch(ENCODED_ESCAPES, matcher.group(1).charAt(0))]; + } else if (matcher.start(2) >= 0) { + // Decode an octal escape. + ch = (char) (Integer.parseInt(matcher.group(2), 8)); + } else /* if (matcher.start(3) >= 0) */ { + // Decode a Unicode escape. + ch = (char) (Integer.parseInt(matcher.group(3), 16)); + } + // Replace the escape with the decoded character. + matcher.appendReplacement(decodedString, Matcher.quoteReplacement(String.valueOf(ch))); + } + // Append the remainder of the encoded string to the decoded string. + // The remainder is the longest suffix of the encoded string such that the suffix contains no escapes. + matcher.appendTail(decodedString); + return new String(decodedString); + } + + // Webview Utils + + public static void handleWebviewDir(Context context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + return; + } + try { + Set pathSet = new HashSet<>(); + String suffix; + String dataPath = context.getDataDir().getAbsolutePath(); + String webViewDir = "/app_webview"; + String huaweiWebViewDir = "/app_hws_webview"; + String lockFile = "/webview_data.lock"; + String processName = Application.getProcessName(); + if (!BuildConfig.APPLICATION_ID.equals(processName)) {//判断不等于默认进程名称 + suffix = TextUtils.isEmpty(processName) ? context.getPackageName() : processName; + WebView.setDataDirectorySuffix(suffix); + suffix = "_" + suffix; + pathSet.add(dataPath + webViewDir + suffix + lockFile); + if (checkIsHuaweiRom()) { + pathSet.add(dataPath + huaweiWebViewDir + suffix + lockFile); + } + } else { + //主进程 + suffix = "_" + processName; + pathSet.add(dataPath + webViewDir + lockFile);//默认未添加进程名后缀 + pathSet.add(dataPath + webViewDir + suffix + lockFile);//系统自动添加了进程名后缀 + if (checkIsHuaweiRom()) {//部分华为手机更改了webview目录名 + pathSet.add(dataPath + huaweiWebViewDir + lockFile); + pathSet.add(dataPath + huaweiWebViewDir + suffix + lockFile); + } + } + for (String path : pathSet) { + File file = new File(path); + if (file.exists()) { + tryLockOrRecreateFile(file); + break; + } + } + } catch (Exception e) { + Logs.INSTANCE.e(e); + } + } + + @TargetApi(Build.VERSION_CODES.P) + private static void tryLockOrRecreateFile(File file) { + try { + FileLock tryLock = new RandomAccessFile(file, "rw").getChannel().tryLock(); + if (tryLock != null) { + tryLock.close(); + } else { + createFile(file, file.delete()); + } + } catch (Exception e) { + e.printStackTrace(); + boolean deleted = false; + if (file.exists()) { + deleted = file.delete(); + } + createFile(file, deleted); + } + } + + private static void createFile(File file, boolean deleted) { + try { + if (deleted && !file.exists()) { + file.createNewFile(); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + private static boolean checkIsHuaweiRom() { + return Build.MANUFACTURER.contains("HUAWEI"); + } + + @SuppressLint("PrivateApi") + public static String getProcessName() { + if (Build.VERSION.SDK_INT >= 28) + return Application.getProcessName(); + + // Using the same technique as Application.getProcessName() for older devices + // Using reflection since ActivityThread is an internal API + + try { + Class activityThread = Class.forName("android.app.ActivityThread"); + String methodName = "currentProcessName"; + Method getProcessName = activityThread.getDeclaredMethod(methodName); + return (String) getProcessName.invoke(null); + } catch (Exception e) { + return BuildConfig.APPLICATION_ID; + } + } + + // Old hutool Utils + + public static boolean isNullOrBlank(String str) { + return str == null || StringsKt.isBlank(str); + } + + public static boolean isNotBlank(String str) { + return !isNullOrBlank(str); + } + + private static final char[] HEX_ARRAY = "0123456789abcdef".toCharArray(); + + public static String bytesToHex(byte[] bytes) { + char[] hexChars = new char[bytes.length * 2]; + for (int j = 0; j < bytes.length; j++) { + int v = bytes[j] & 0xFF; + hexChars[j * 2] = HEX_ARRAY[v >>> 4]; + hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F]; + } + return new String(hexChars); + } + + public static boolean isEmpty(byte[] array) { + return array == null || array.length == 0; + } + + // gson + + public static final Gson gson = new GsonBuilder() + .setPrettyPrinting() + .setNumberToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) + .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) + .setLenient() + .disableHtmlEscaping() + .create(); + +} diff --git a/app/src/main/java/moe/matsuri/nb4a/utils/KotlinUtil.kt b/app/src/main/java/moe/matsuri/nb4a/utils/KotlinUtil.kt new file mode 100644 index 0000000..0b45095 --- /dev/null +++ b/app/src/main/java/moe/matsuri/nb4a/utils/KotlinUtil.kt @@ -0,0 +1,53 @@ +package moe.matsuri.nb4a.utils + +import android.content.Context +import android.graphics.drawable.Drawable +import androidx.appcompat.content.res.AppCompatResources +import io.nekohasekai.sagernet.SagerNet +import io.nekohasekai.sagernet.ktx.Logs +import java.io.File + +// SagerNet Class + +fun SagerNet.cleanWebview() { + var pathToClean = "app_webview" + if (isBgProcess) pathToClean += "_$process" + try { + val dataDir = filesDir.parentFile!! + File(dataDir, "$pathToClean/BrowserMetrics").recreate(true) + File(dataDir, "$pathToClean/BrowserMetrics-spare.pma").recreate(false) + } catch (e: Exception) { + Logs.e(e) + } +} + +fun File.recreate(dir: Boolean) { + if (parentFile?.isDirectory != true) return + if (dir && !isFile) { + if (exists()) deleteRecursively() + createNewFile() + } else if (!dir && !isDirectory) { + if (exists()) delete() + mkdir() + } +} + +// Context utils + +fun Context.getDrawableByName(name: String?): Drawable? { + val resourceId: Int = resources.getIdentifier(name, "drawable", packageName) + return AppCompatResources.getDrawable(this, resourceId) +} + +// Traffic display + +fun Long.toBytesString(): String { + return when { + this > 1024 * 1024 * 1024 -> String.format( + "%.2f GiB", (this.toDouble() / 1024 / 1024 / 1024) + ) + this > 1024 * 1024 -> String.format("%.2f MiB", (this.toDouble() / 1024 / 1024)) + this > 1024 -> String.format("%.2f KiB", (this.toDouble() / 1024)) + else -> "$this Bytes" + } +} diff --git a/app/src/main/java/moe/matsuri/nb4a/utils/NGUtil.kt b/app/src/main/java/moe/matsuri/nb4a/utils/NGUtil.kt new file mode 100644 index 0000000..7035104 --- /dev/null +++ b/app/src/main/java/moe/matsuri/nb4a/utils/NGUtil.kt @@ -0,0 +1,237 @@ +package moe.matsuri.nb4a.utils + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.text.Editable +import android.util.Base64 +import io.nekohasekai.sagernet.ktx.Logs +import java.net.URLDecoder +import java.net.URLEncoder +import java.util.* + +// Copy form v2rayNG to parse their stupid format + +object NGUtil { + + /** + * convert string to editalbe for kotlin + * + * @param text + * @return + */ + fun getEditable(text: String): Editable { + return Editable.Factory.getInstance().newEditable(text) + } + + /** + * find value in array position + */ + fun arrayFind(array: Array, value: String): Int { + for (i in array.indices) { + if (array[i] == value) { + return i + } + } + return -1 + } + + /** + * parseInt + */ + fun parseInt(str: String): Int { + return try { + Integer.parseInt(str) + } catch (e: Exception) { + e.printStackTrace() + 0 + } + } + + /** + * get text from clipboard + */ + fun getClipboard(context: Context): String { + return try { + val cmb = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cmb.primaryClip?.getItemAt(0)?.text.toString() + } catch (e: Exception) { + e.printStackTrace() + "" + } + } + + /** + * set text to clipboard + */ + fun setClipboard(context: Context, content: String) { + try { + val cmb = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clipData = ClipData.newPlainText(null, content) + cmb.setPrimaryClip(clipData) + } catch (e: Exception) { + e.printStackTrace() + } + } + + /** + * base64 decode + */ + fun decode(text: String): String { + tryDecodeBase64(text)?.let { return it } + if (text.endsWith('=')) { + // try again for some loosely formatted base64 + tryDecodeBase64(text.trimEnd('='))?.let { return it } + } + return "" + } + + fun tryDecodeBase64(text: String): String? { + try { + return Base64.decode(text, Base64.NO_WRAP).toString(charset("UTF-8")) + } catch (e: Exception) { + Logs.i( "Parse base64 standard failed $e") + } + try { + return Base64.decode(text, Base64.NO_WRAP.or(Base64.URL_SAFE)).toString(charset("UTF-8")) + } catch (e: Exception) { + Logs.i( "Parse base64 url safe failed $e") + } + return null + } + + /** + * base64 encode + */ + fun encode(text: String): String { + return try { + Base64.encodeToString(text.toByteArray(charset("UTF-8")), Base64.NO_WRAP) + } catch (e: Exception) { + e.printStackTrace() + "" + } + } + + /** + * is ip address + */ + fun isIpAddress(value: String): Boolean { + try { + var addr = value + if (addr.isEmpty() || addr.isBlank()) { + return false + } + //CIDR + if (addr.indexOf("/") > 0) { + val arr = addr.split("/") + if (arr.count() == 2 && Integer.parseInt(arr[1]) > 0) { + addr = arr[0] + } + } + + // "::ffff:192.168.173.22" + // "[::ffff:192.168.173.22]:80" + if (addr.startsWith("::ffff:") && '.' in addr) { + addr = addr.drop(7) + } else if (addr.startsWith("[::ffff:") && '.' in addr) { + addr = addr.drop(8).replace("]", "") + } + + // addr = addr.toLowerCase() + val octets = addr.split('.').toTypedArray() + if (octets.size == 4) { + if(octets[3].indexOf(":") > 0) { + addr = addr.substring(0, addr.indexOf(":")) + } + return isIpv4Address(addr) + } + + // Ipv6addr [2001:abc::123]:8080 + return isIpv6Address(addr) + } catch (e: Exception) { + e.printStackTrace() + return false + } + } + + fun isPureIpAddress(value: String): Boolean { + return (isIpv4Address(value) || isIpv6Address(value)) + } + + fun isIpv4Address(value: String): Boolean { + val regV4 = Regex("^([01]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])\\.([01]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])\\.([01]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])\\.([01]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])$") + return regV4.matches(value) + } + + fun isIpv6Address(value: String): Boolean { + var addr = value + if (addr.indexOf("[") == 0 && addr.lastIndexOf("]") > 0) { + addr = addr.drop(1) + addr = addr.dropLast(addr.count() - addr.lastIndexOf("]")) + } + val regV6 = Regex("^((?:[0-9A-Fa-f]{1,4}))?((?::[0-9A-Fa-f]{1,4}))*::((?:[0-9A-Fa-f]{1,4}))?((?::[0-9A-Fa-f]{1,4}))*|((?:[0-9A-Fa-f]{1,4}))((?::[0-9A-Fa-f]{1,4})){7}$") + return regV6.matches(addr) + } + + private fun isCoreDNSAddress(s: String): Boolean { + return s.startsWith("https") || s.startsWith("tcp") || s.startsWith("quic") + } + + fun openUri(context: Context, uriString: String) { + val uri = Uri.parse(uriString) + context.startActivity(Intent(Intent.ACTION_VIEW, uri)) + } + + /** + * uuid + */ + fun getUuid(): String { + return try { + UUID.randomUUID().toString().replace("-", "") + } catch (e: Exception) { + e.printStackTrace() + "" + } + } + + fun urlDecode(url: String): String { + return try { + URLDecoder.decode(url, "UTF-8") + } catch (e: Exception) { + url + } + } + + fun urlEncode(url: String): String { + return try { + URLEncoder.encode(url, "UTF-8") + } catch (e: Exception) { + e.printStackTrace() + url + } + } + + /** + * package path + */ + fun packagePath(context: Context): String { + var path = context.filesDir.toString() + path = path.replace("files", "") + //path += "tun2socks" + + return path + } + + /** + * readTextFromAssets + */ + fun readTextFromAssets(context: Context, fileName: String): String { + val content = context.assets.open(fileName).bufferedReader().use { + it.readText() + } + return content + } + +} \ No newline at end of file diff --git a/app/src/main/java/moe/matsuri/nb4a/utils/SendLog.kt b/app/src/main/java/moe/matsuri/nb4a/utils/SendLog.kt new file mode 100644 index 0000000..b37384e --- /dev/null +++ b/app/src/main/java/moe/matsuri/nb4a/utils/SendLog.kt @@ -0,0 +1,73 @@ +package moe.matsuri.nb4a.utils + +import android.content.Context +import android.content.Intent +import androidx.core.content.FileProvider +import io.nekohasekai.sagernet.BuildConfig +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.SagerNet +import io.nekohasekai.sagernet.ktx.Logs +import io.nekohasekai.sagernet.ktx.app +import io.nekohasekai.sagernet.ktx.use +import io.nekohasekai.sagernet.utils.CrashHandler +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException + +object SendLog { + // Create full log and send + fun sendLog(context: Context, title: String) { + val logFile = File.createTempFile( + "$title ", + ".log", + File(app.cacheDir, "log").also { it.mkdirs() }) + + var report = CrashHandler.buildReportHeader() + + report += "Logcat: \n\n" + + logFile.writeText(report) + + try { + Runtime.getRuntime().exec(arrayOf("logcat", "-d")).inputStream.use( + FileOutputStream( + logFile, true + ) + ) + logFile.appendText("\n") + } catch (e: IOException) { + Logs.w(e) + logFile.appendText("Export logcat error: " + CrashHandler.formatThrowable(e)) + } + + logFile.appendText("\n") + logFile.appendBytes(getNekoLog(0)) + + context.startActivity( + Intent.createChooser( + Intent(Intent.ACTION_SEND).setType("text/x-log") + .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .putExtra( + Intent.EXTRA_STREAM, FileProvider.getUriForFile( + context, BuildConfig.APPLICATION_ID + ".cache", logFile + ) + ), context.getString(R.string.abc_shareactionprovider_share_with) + ) + ) + } + + // Get log bytes from neko.log + fun getNekoLog(max: Long): ByteArray { + val file = File( + SagerNet.application.cacheDir, + "neko.log" + ) + val len = file.length() + val stream = FileInputStream(file) + if (max in 1 until len) { + stream.skip(len - max) // TODO string? + } + return stream.readBytes() + } +} diff --git a/app/src/main/java/moe/matsuri/nb4a/utils/Util.kt b/app/src/main/java/moe/matsuri/nb4a/utils/Util.kt new file mode 100644 index 0000000..1a077bc --- /dev/null +++ b/app/src/main/java/moe/matsuri/nb4a/utils/Util.kt @@ -0,0 +1,137 @@ +package moe.matsuri.nb4a.utils + +import android.annotation.SuppressLint +import android.util.Base64 +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonParseException +import java.io.ByteArrayOutputStream +import java.lang.reflect.Type +import java.text.NumberFormat +import java.text.SimpleDateFormat +import java.util.* +import java.util.zip.Deflater +import java.util.zip.Inflater + +object Util { + + /** + * 取两个文本之间的文本值 + * + * @param text 源文本 比如:欲取全文本为 12345 + * @param left 文本前面 + * @param right 后面文本 + * @return 返回 String + */ + fun getSubString(text: String, left: String?, right: String?): String { + var zLen: Int + if (left == null || left.isEmpty()) { + zLen = 0 + } else { + zLen = text.indexOf(left) + if (zLen > -1) { + zLen += left.length + } else { + zLen = 0 + } + } + var yLen = if (right == null) -1 else text.indexOf(right, zLen) + if (yLen < 0 || right == null || right.isEmpty()) { + yLen = text.length + } + return text.substring(zLen, yLen) + } + + // Base64 for all + + fun b64EncodeUrlSafe(s: String): String { + return b64EncodeUrlSafe(s.toByteArray()) + } + + fun b64EncodeUrlSafe(b: ByteArray): String { + return String(Base64.encode(b, Base64.NO_PADDING or Base64.NO_WRAP or Base64.URL_SAFE)) + } + + // v2rayN Style + fun b64EncodeOneLine(b: ByteArray): String { + return String(Base64.encode(b, Base64.NO_WRAP)) + } + + fun b64EncodeDefault(b: ByteArray): String { + return String(Base64.encode(b, Base64.DEFAULT)) + } + + fun b64Decode(b: String): ByteArray { + var ret: ByteArray? = null + + // padding 自动处理,不用理 + // URLSafe 需要替换这两个,不要用 URL_SAFE 否则处理非 Safe 的时候会乱码 + val str = b.replace("-", "+").replace("_", "/") + + val flags = listOf( + Base64.DEFAULT, // 多行 + Base64.NO_WRAP, // 单行 + ) + + for (flag in flags) { + try { + ret = Base64.decode(str, flag) + } catch (e: Exception) { + } + if (ret != null) return ret + } + + throw IllegalStateException("Cannot decode base64") + } + + fun zlibCompress(input: ByteArray, level: Int): ByteArray { + // Compress the bytes + // 1 to 4 bytes/char for UTF-8 + val output = ByteArray(input.size * 4) + val compressor = Deflater(level).apply { + setInput(input) + finish() + } + val compressedDataLength: Int = compressor.deflate(output) + compressor.end() + return output.copyOfRange(0, compressedDataLength) + } + + fun zlibDecompress(input: ByteArray): ByteArray { + val inflater = Inflater() + val outputStream = ByteArrayOutputStream() + + return outputStream.use { + val buffer = ByteArray(1024) + + inflater.setInput(input) + + var count = -1 + while (count != 0) { + count = inflater.inflate(buffer) + outputStream.write(buffer, 0, count) + } + + inflater.end() + outputStream.toByteArray() + } + } + + // Format Time + + @SuppressLint("SimpleDateFormat") + val sdf1 = SimpleDateFormat("yyyy-MM-dd HH:mm:ss") + + fun timeStamp2Text(t: Long): String { + return sdf1.format(Date(t)) + } + + fun tryToSetField(o: Any, name: String, value: Any) { + try { + o.javaClass.getField(name).set(o, value) + } catch (_: Exception) { + } + } + +} diff --git a/app/src/main/java/moe/matsuri/nb4a/utils/WebViewUtil.kt b/app/src/main/java/moe/matsuri/nb4a/utils/WebViewUtil.kt new file mode 100644 index 0000000..ebce693 --- /dev/null +++ b/app/src/main/java/moe/matsuri/nb4a/utils/WebViewUtil.kt @@ -0,0 +1,38 @@ +package moe.matsuri.nb4a.utils + +import android.os.Build +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import io.nekohasekai.sagernet.ktx.Logs +import java.io.ByteArrayInputStream +import java.io.InputStream + +object WebViewUtil { + fun onReceivedError( + view: WebView?, request: WebResourceRequest?, error: WebResourceError? + ) { + if (Build.VERSION.SDK_INT >= 23 && error != null) { + Logs.e("WebView error description: ${error.description}") + } + Logs.e("WebView error: ${error.toString()}") + } + + fun interceptRequest( + res: (String) -> InputStream?, view: WebView?, request: WebResourceRequest? + ): WebResourceResponse { + val path = request?.url?.path ?: "404" + val input = res(path) + var mime = "text/plain" + if (path.endsWith(".js")) mime = "application/javascript" + if (path.endsWith(".html")) mime = "text/html" + return if (input != null) { + WebResourceResponse(mime, "UTF-8", input) + } else { + WebResourceResponse( + "text/plain", "UTF-8", ByteArrayInputStream("".toByteArray()) + ) + } + } +} diff --git a/app/src/main/res/color/chip_background.xml b/app/src/main/res/color/chip_background.xml new file mode 100644 index 0000000..01a73d6 --- /dev/null +++ b/app/src/main/res/color/chip_background.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/color/chip_ripple_color.xml b/app/src/main/res/color/chip_ripple_color.xml new file mode 100644 index 0000000..24e6fb4 --- /dev/null +++ b/app/src/main/res/color/chip_ripple_color.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/color/chip_text_color.xml b/app/src/main/res/color/chip_text_color.xml new file mode 100644 index 0000000..c1319e0 --- /dev/null +++ b/app/src/main/res/color/chip_text_color.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/color/navigation_icon.xml b/app/src/main/res/color/navigation_icon.xml new file mode 100644 index 0000000..99c3f94 --- /dev/null +++ b/app/src/main/res/color/navigation_icon.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/navigation_item.xml b/app/src/main/res/color/navigation_item.xml new file mode 100644 index 0000000..f21c999 --- /dev/null +++ b/app/src/main/res/color/navigation_item.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-v26/ic_qu_camera_launcher.xml b/app/src/main/res/drawable-v26/ic_qu_camera_launcher.xml new file mode 100644 index 0000000..93d5410 --- /dev/null +++ b/app/src/main/res/drawable-v26/ic_qu_camera_launcher.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/app/src/main/res/drawable-v26/ic_qu_shadowsocks_launcher.xml b/app/src/main/res/drawable-v26/ic_qu_shadowsocks_launcher.xml new file mode 100644 index 0000000..c039c2b --- /dev/null +++ b/app/src/main/res/drawable-v26/ic_qu_shadowsocks_launcher.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/baseline_arrow_back_24.xml b/app/src/main/res/drawable/baseline_arrow_back_24.xml new file mode 100644 index 0000000..2a31b2e --- /dev/null +++ b/app/src/main/res/drawable/baseline_arrow_back_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/baseline_construction_24.xml b/app/src/main/res/drawable/baseline_construction_24.xml new file mode 100644 index 0000000..1828ec8 --- /dev/null +++ b/app/src/main/res/drawable/baseline_construction_24.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/baseline_delete_sweep_24.xml b/app/src/main/res/drawable/baseline_delete_sweep_24.xml new file mode 100644 index 0000000..22560a4 --- /dev/null +++ b/app/src/main/res/drawable/baseline_delete_sweep_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/baseline_developer_board_24.xml b/app/src/main/res/drawable/baseline_developer_board_24.xml new file mode 100644 index 0000000..100c0e9 --- /dev/null +++ b/app/src/main/res/drawable/baseline_developer_board_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/baseline_flight_takeoff_24.xml b/app/src/main/res/drawable/baseline_flight_takeoff_24.xml new file mode 100644 index 0000000..081726c --- /dev/null +++ b/app/src/main/res/drawable/baseline_flight_takeoff_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/baseline_public_24.xml b/app/src/main/res/drawable/baseline_public_24.xml new file mode 100644 index 0000000..19fb425 --- /dev/null +++ b/app/src/main/res/drawable/baseline_public_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/baseline_save_24.xml b/app/src/main/res/drawable/baseline_save_24.xml new file mode 100644 index 0000000..1a8d86d --- /dev/null +++ b/app/src/main/res/drawable/baseline_save_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/baseline_send_24.xml b/app/src/main/res/drawable/baseline_send_24.xml new file mode 100644 index 0000000..f0d63e1 --- /dev/null +++ b/app/src/main/res/drawable/baseline_send_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/baseline_translate_24.xml b/app/src/main/res/drawable/baseline_translate_24.xml new file mode 100644 index 0000000..4e7e364 --- /dev/null +++ b/app/src/main/res/drawable/baseline_translate_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/baseline_widgets_24.xml b/app/src/main/res/drawable/baseline_widgets_24.xml new file mode 100644 index 0000000..05b871b --- /dev/null +++ b/app/src/main/res/drawable/baseline_widgets_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/baseline_wrap_text_24.xml b/app/src/main/res/drawable/baseline_wrap_text_24.xml new file mode 100644 index 0000000..c9de17b --- /dev/null +++ b/app/src/main/res/drawable/baseline_wrap_text_24.xml @@ -0,0 +1,11 @@ + + + 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 0000000..fd76192 --- /dev/null +++ b/app/src/main/res/drawable/ic_action_copyright.xml @@ -0,0 +1,11 @@ + + + + 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 0000000..0e9d1eb --- /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 0000000..98bda1a --- /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 0000000..dd725be --- /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 0000000..8bf04e3 --- /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_lock.xml b/app/src/main/res/drawable/ic_action_lock.xml new file mode 100644 index 0000000..811a5b6 --- /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 0000000..169e93a --- /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 0000000..c8ffa88 --- /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 0000000..1f0802a --- /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 0000000..d949e9f --- /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 0000000..7296673 --- /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_add_road_24.xml b/app/src/main/res/drawable/ic_baseline_add_road_24.xml new file mode 100644 index 0000000..0b8771e --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_add_road_24.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_baseline_airplanemode_active_24.xml b/app/src/main/res/drawable/ic_baseline_airplanemode_active_24.xml new file mode 100644 index 0000000..0ad3ad1 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_airplanemode_active_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_android_24.xml b/app/src/main/res/drawable/ic_baseline_android_24.xml new file mode 100644 index 0000000..9a2e721 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_android_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_bug_report_24.xml b/app/src/main/res/drawable/ic_baseline_bug_report_24.xml new file mode 100644 index 0000000..7853f61 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_bug_report_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_camera_24.xml b/app/src/main/res/drawable/ic_baseline_camera_24.xml new file mode 100644 index 0000000..c0d3501 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_camera_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_card_giftcard_24.xml b/app/src/main/res/drawable/ic_baseline_card_giftcard_24.xml new file mode 100644 index 0000000..d5b3e1c --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_card_giftcard_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_cast_connected_24.xml b/app/src/main/res/drawable/ic_baseline_cast_connected_24.xml new file mode 100644 index 0000000..0e44ec6 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_cast_connected_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_center_focus_weak_24.xml b/app/src/main/res/drawable/ic_baseline_center_focus_weak_24.xml new file mode 100644 index 0000000..eb56c21 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_center_focus_weak_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_color_lens_24.xml b/app/src/main/res/drawable/ic_baseline_color_lens_24.xml new file mode 100644 index 0000000..4bf1550 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_color_lens_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_compare_arrows_24.xml b/app/src/main/res/drawable/ic_baseline_compare_arrows_24.xml new file mode 100644 index 0000000..9f2ab69 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_compare_arrows_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_domain_24.xml b/app/src/main/res/drawable/ic_baseline_domain_24.xml new file mode 100644 index 0000000..06b4137 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_domain_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_download_24.xml b/app/src/main/res/drawable/ic_baseline_download_24.xml new file mode 100644 index 0000000..1f61509 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_download_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_emoji_emotions_24.xml b/app/src/main/res/drawable/ic_baseline_emoji_emotions_24.xml new file mode 100644 index 0000000..6a85a99 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_emoji_emotions_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_fast_forward_24.xml b/app/src/main/res/drawable/ic_baseline_fast_forward_24.xml new file mode 100644 index 0000000..e3f30c6 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_fast_forward_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_fiber_manual_record_24.xml b/app/src/main/res/drawable/ic_baseline_fiber_manual_record_24.xml new file mode 100644 index 0000000..d191830 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_fiber_manual_record_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_fingerprint_24.xml b/app/src/main/res/drawable/ic_baseline_fingerprint_24.xml new file mode 100644 index 0000000..4ad0310 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_fingerprint_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_flip_camera_android_24.xml b/app/src/main/res/drawable/ic_baseline_flip_camera_android_24.xml new file mode 100644 index 0000000..951aa1f --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_flip_camera_android_24.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_baseline_format_align_left_24.xml b/app/src/main/res/drawable/ic_baseline_format_align_left_24.xml new file mode 100644 index 0000000..4ff0c3a --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_format_align_left_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_grid_3x3_24.xml b/app/src/main/res/drawable/ic_baseline_grid_3x3_24.xml new file mode 100644 index 0000000..bdc2232 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_grid_3x3_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_home_24.xml b/app/src/main/res/drawable/ic_baseline_home_24.xml new file mode 100644 index 0000000..3a4c7da --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_home_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_http_24.xml b/app/src/main/res/drawable/ic_baseline_http_24.xml new file mode 100644 index 0000000..b93de26 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_http_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_https_24.xml b/app/src/main/res/drawable/ic_baseline_https_24.xml new file mode 100644 index 0000000..d619102 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_https_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_import_contacts_24.xml b/app/src/main/res/drawable/ic_baseline_import_contacts_24.xml new file mode 100644 index 0000000..99a23c4 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_import_contacts_24.xml @@ -0,0 +1,10 @@ + + + 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 0000000..17255b7 --- /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_layers_24.xml b/app/src/main/res/drawable/ic_baseline_layers_24.xml new file mode 100644 index 0000000..478fe9d --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_layers_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_legend_toggle_24.xml b/app/src/main/res/drawable/ic_baseline_legend_toggle_24.xml new file mode 100644 index 0000000..ea17c5d --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_legend_toggle_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_link_24.xml b/app/src/main/res/drawable/ic_baseline_link_24.xml new file mode 100644 index 0000000..2c0a73f --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_link_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_local_bar_24.xml b/app/src/main/res/drawable/ic_baseline_local_bar_24.xml new file mode 100644 index 0000000..2a55e49 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_local_bar_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_location_on_24.xml b/app/src/main/res/drawable/ic_baseline_location_on_24.xml new file mode 100644 index 0000000..e6dfeb4 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_location_on_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_lock_24.xml b/app/src/main/res/drawable/ic_baseline_lock_24.xml new file mode 100644 index 0000000..d619102 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_lock_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_low_priority_24.xml b/app/src/main/res/drawable/ic_baseline_low_priority_24.xml new file mode 100644 index 0000000..c8fb025 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_low_priority_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_manage_search_24.xml b/app/src/main/res/drawable/ic_baseline_manage_search_24.xml new file mode 100644 index 0000000..44aed15 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_manage_search_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_more_vert_24.xml b/app/src/main/res/drawable/ic_baseline_more_vert_24.xml new file mode 100644 index 0000000..34b93ec --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_more_vert_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_multiline_chart_24.xml b/app/src/main/res/drawable/ic_baseline_multiline_chart_24.xml new file mode 100644 index 0000000..96a6a38 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_multiline_chart_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_multiple_stop_24.xml b/app/src/main/res/drawable/ic_baseline_multiple_stop_24.xml new file mode 100644 index 0000000..f8d9aa2 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_multiple_stop_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_nat_24.xml b/app/src/main/res/drawable/ic_baseline_nat_24.xml new file mode 100644 index 0000000..bc3e7ef --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_nat_24.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_baseline_nfc_24.xml b/app/src/main/res/drawable/ic_baseline_nfc_24.xml new file mode 100644 index 0000000..63435db --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_nfc_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_no_encryption_gmailerrorred_24.xml b/app/src/main/res/drawable/ic_baseline_no_encryption_gmailerrorred_24.xml new file mode 100644 index 0000000..b49f243 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_no_encryption_gmailerrorred_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_person_24.xml b/app/src/main/res/drawable/ic_baseline_person_24.xml new file mode 100644 index 0000000..6bdced2 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_person_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_push_pin_24.xml b/app/src/main/res/drawable/ic_baseline_push_pin_24.xml new file mode 100644 index 0000000..d382d38 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_push_pin_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_refresh_24.xml b/app/src/main/res/drawable/ic_baseline_refresh_24.xml new file mode 100644 index 0000000..f2be45b --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_refresh_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_rule_folder_24.xml b/app/src/main/res/drawable/ic_baseline_rule_folder_24.xml new file mode 100644 index 0000000..318cda2 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_rule_folder_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_running_with_errors_24.xml b/app/src/main/res/drawable/ic_baseline_running_with_errors_24.xml new file mode 100644 index 0000000..b3eba04 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_running_with_errors_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_sanitizer_24.xml b/app/src/main/res/drawable/ic_baseline_sanitizer_24.xml new file mode 100644 index 0000000..72c4712 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_sanitizer_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_security_24.xml b/app/src/main/res/drawable/ic_baseline_security_24.xml new file mode 100644 index 0000000..c9e27cc --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_security_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_shuffle_24.xml b/app/src/main/res/drawable/ic_baseline_shuffle_24.xml new file mode 100644 index 0000000..2469a90 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_shuffle_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_shutter_speed_24.xml b/app/src/main/res/drawable/ic_baseline_shutter_speed_24.xml new file mode 100644 index 0000000..6c2598b --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_shutter_speed_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_speed_24.xml b/app/src/main/res/drawable/ic_baseline_speed_24.xml new file mode 100644 index 0000000..7acbed3 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_speed_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_stream_24.xml b/app/src/main/res/drawable/ic_baseline_stream_24.xml new file mode 100644 index 0000000..71d35c5 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_stream_24.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_baseline_texture_24.xml b/app/src/main/res/drawable/ic_baseline_texture_24.xml new file mode 100644 index 0000000..b2741ea --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_texture_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_timelapse_24.xml b/app/src/main/res/drawable/ic_baseline_timelapse_24.xml new file mode 100644 index 0000000..b03e05f --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_timelapse_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_transform_24.xml b/app/src/main/res/drawable/ic_baseline_transform_24.xml new file mode 100644 index 0000000..c452b9f --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_transform_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_transgender_24.xml b/app/src/main/res/drawable/ic_baseline_transgender_24.xml new file mode 100644 index 0000000..864edbf --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_transgender_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_update_24.xml b/app/src/main/res/drawable/ic_baseline_update_24.xml new file mode 100644 index 0000000..3b92302 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_update_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_view_list_24.xml b/app/src/main/res/drawable/ic_baseline_view_list_24.xml new file mode 100644 index 0000000..37ffb91 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_view_list_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_vpn_key_24.xml b/app/src/main/res/drawable/ic_baseline_vpn_key_24.xml new file mode 100644 index 0000000..93276f3 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_vpn_key_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_warning_24.xml b/app/src/main/res/drawable/ic_baseline_warning_24.xml new file mode 100644 index 0000000..a02bc13 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_warning_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_wb_sunny_24.xml b/app/src/main/res/drawable/ic_baseline_wb_sunny_24.xml new file mode 100644 index 0000000..fc8da89 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_wb_sunny_24.xml @@ -0,0 +1,10 @@ + + + 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 0000000..beaf138 --- /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 0000000..6431414 --- /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 0000000..1a41f65 --- /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 0000000..28d1f89 --- /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 0000000..11e906a --- /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 0000000..d6afc52 --- /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 0000000..5dc0cea --- /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 0000000..7e3c1b6 --- /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 0000000..441f83e --- /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 0000000..722eadb --- /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_maps_360.xml b/app/src/main/res/drawable/ic_maps_360.xml new file mode 100644 index 0000000..c507034 --- /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 0000000..5b549df --- /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 0000000..4b67183 --- /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 0000000..941ab03 --- /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 0000000..7caff12 --- /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 0000000..f170e1f --- /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 0000000..b186b22 --- /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 0000000..d0a9781 --- /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 0000000..6bf8183 --- /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 0000000..f8bdfcf --- /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 100644 index 0000000..dd91b8b --- /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 0000000..3adac9f --- /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 0000000..18c8960 --- /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 0000000..45f8f15 --- /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 0000000..205b81d --- /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 0000000..3d2e28b --- /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 0000000..87f9233 --- /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 0000000..50b08a2 --- /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 0000000..77549ef --- /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 0000000..da0c4fb --- /dev/null +++ b/app/src/main/res/drawable/ic_social_share.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/terminal_scroll_shape.xml b/app/src/main/res/drawable/terminal_scroll_shape.xml new file mode 100644 index 0000000..57f644d --- /dev/null +++ b/app/src/main/res/drawable/terminal_scroll_shape.xml @@ -0,0 +1,22 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/font/jetbrains_mono.ttf b/app/src/main/res/font/jetbrains_mono.ttf new file mode 100644 index 0000000..7db854f Binary files /dev/null and b/app/src/main/res/font/jetbrains_mono.ttf differ diff --git a/app/src/main/res/layout/layout_about.xml b/app/src/main/res/layout/layout_about.xml new file mode 100644 index 0000000..2363c50 --- /dev/null +++ b/app/src/main/res/layout/layout_about.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/layout_add_entity.xml b/app/src/main/res/layout/layout_add_entity.xml new file mode 100644 index 0000000..d6e856e --- /dev/null +++ b/app/src/main/res/layout/layout_add_entity.xml @@ -0,0 +1,24 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/layout_app_list.xml b/app/src/main/res/layout/layout_app_list.xml new file mode 100644 index 0000000..f8c211b --- /dev/null +++ b/app/src/main/res/layout/layout_app_list.xml @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +