diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e80f3ed0ef..1cd1a522f8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,4 +1,5 @@
name: CI
+
on:
push:
branches:
@@ -7,16 +8,96 @@ on:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
+
permissions:
contents: read
+
jobs:
build:
- name: Unit tests and debug build
+ name: Build Signed Release APK
runs-on: ubuntu-latest
+
steps:
- name: Checkout
- uses: actions/checkout@v7
- - name: Build
- uses: ./.github/actions/android-build
+ uses: actions/checkout@v4
with:
- tasks: app:testOssDebugUnitTest app:assembleOssDebug
+ submodules: recursive
+ fetch-depth: 0
+
+ - name: Setup Java
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: '17'
+
+ - name: Setup Android SDK
+ run: |
+ echo "y" | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null 2>&1 || true
+ $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "platforms;android-35" "build-tools;35.0.1" > /dev/null 2>&1
+
+ - name: Build ThroneCore AAR and schema
+ run: |
+ REF=$(sed -n 's/^THRONE_CORE_REF=//p' nb4a.properties | tr -d '\r[:space:]')
+ THRONE_DIR="$(dirname "$GITHUB_WORKSPACE")/Throne"
+ rm -rf "$THRONE_DIR"
+ git init -q "$THRONE_DIR"
+ git -C "$THRONE_DIR" remote add origin https://github.com/throneproj/Throne
+ git -C "$THRONE_DIR" fetch -q --depth 1 origin "$REF"
+ git -C "$THRONE_DIR" checkout -q --detach FETCH_HEAD
+
+ go install github.com/sagernet/gomobile/cmd/gomobile@v0.1.13
+ go install github.com/sagernet/gomobile/cmd/gobind@v0.1.13
+ export PATH="$(go env GOPATH)/bin:$PATH"
+
+ echo y | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --install "ndk;28.2.13676358" > /dev/null 2>&1
+ export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/28.2.13676358"
+
+ cd "$THRONE_DIR"
+ DEST="$GITHUB_WORKSPACE/app/libs" bash script/build_android.sh
+
+ cd "$THRONE_DIR/core"
+ TAGS=$(sed -n 's/^TAGS="\(.*\)"$/\1/p' ../script/build_android.sh)
+ mkdir -p "$GITHUB_WORKSPACE/app/src/main/assets/schema"
+ CGO_ENABLED=0 go run -trimpath -ldflags=-checklinkname=0 -tags "$TAGS,with_purego" ./cmd/schemagen -o "$GITHUB_WORKSPACE/app/src/main/assets/schema/sing-box.json"
+
+ test -s "$GITHUB_WORKSPACE/app/libs/ThroneCore.aar"
+ test -s "$GITHUB_WORKSPACE/app/src/main/assets/schema/sing-box.json"
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x ./gradlew
+
+ - name: Generate signing key
+ run: |
+ keytool -genkeypair -v -keystore signing.keystore -alias throne \
+ -keyalg RSA -keysize 2048 -validity 10000 \
+ -storepass throne123 -keypass throne123 \
+ -dname "CN=Throne Dev, OU=Dev, O=Throne, L=City, S=State, C=CN"
+
+ - name: Build and sign release APK
+ run: |
+ # Configure signing
+ echo "signing.storeFile=signing.keystore" > local.properties
+ echo "signing.storePassword=throne123" >> local.properties
+ echo "signing.keyAlias=throne" >> local.properties
+ echo "signing.keyPassword=throne123" >> local.properties
+
+ # Build signed release APK
+ ./gradlew assembleOssRelease
+
+ - name: Verify signed APK
+ run: |
+ echo "=== 构建产物 ==="
+ find app/build/outputs/apk -name "*.apk" -exec ls -lh {} \;
+ echo ""
+ echo "=== 验证签名 ==="
+ for apk in app/build/outputs/apk/oss/release/*.apk; do
+ echo "APK: $apk"
+ jarsigner -verify -verbose -certs "$apk" 2>&1 | head -5
+ echo ""
+ done
+
+ - name: Upload Signed Release APK
+ uses: actions/upload-artifact@v4
+ with:
+ name: Throne-signed-release-arm64
+ path: app/build/outputs/apk/oss/release/*.apk
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 60a8d6a030..e6364dcccc 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,5 +1,7 @@
@file:Suppress("UnstableApiUsage")
+import java.util.Properties
+
plugins {
id("com.android.application")
id("kotlin-android")
@@ -33,6 +35,27 @@ android {
viewBinding = true
aidl = true
}
+ // Disable minification to keep APK under 50MB and maintain debuggability
+ buildTypes {
+ getByName("release") {
+ isMinifyEnabled = false
+ isShrinkResources = false
+ // Configure signing
+ if (file("release.properties").exists()) {
+ val props = Properties()
+ props.load(file("release.properties").inputStream())
+ signingConfigs {
+ create("release") {
+ storeFile = file(props["signing.storeFile"] as String)
+ storePassword = props["signing.storePassword"] as String
+ keyAlias = props["signing.keyAlias"] as String
+ keyPassword = props["signing.keyPassword"] as String
+ }
+ }
+ signingConfig = signingConfigs.getByName("release")
+ }
+ }
+ }
namespace = "io.nekohasekai.sagernet"
packaging {
jniLibs {
diff --git a/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/13.json b/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/13.json
new file mode 100644
index 0000000000..238f6369ba
--- /dev/null
+++ b/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/13.json
@@ -0,0 +1,875 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 13,
+ "identityHash": "3525468e13426221ac62c4bfb61de28e",
+ "entities": [
+ {
+ "tableName": "groups",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `archive` INTEGER NOT NULL DEFAULT 0, `skip_auto_update` INTEGER NOT NULL DEFAULT 0, `name` TEXT NOT NULL DEFAULT '', `url` TEXT NOT NULL DEFAULT '', `info` TEXT NOT NULL DEFAULT '', `sub_last_update` INTEGER NOT NULL DEFAULT 0, `front_proxy_id` INTEGER NOT NULL DEFAULT -1, `landing_proxy_id` INTEGER NOT NULL DEFAULT -1, `column_width_json` TEXT NOT NULL DEFAULT '', `scroll_last_profile` INTEGER NOT NULL DEFAULT -1, `auto_clear_unavailable` INTEGER NOT NULL DEFAULT 0, `test_sort_by` INTEGER NOT NULL DEFAULT 0, `traffic_sort_by` INTEGER NOT NULL DEFAULT 0, `test_items_to_show` INTEGER NOT NULL DEFAULT 0, `type_sort_by` INTEGER NOT NULL DEFAULT 0, `sub_options_json` TEXT NOT NULL DEFAULT '{}', `display_order` INTEGER NOT NULL DEFAULT 0)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "archive",
+ "columnName": "archive",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "skipAutoUpdate",
+ "columnName": "skip_auto_update",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "info",
+ "columnName": "info",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "subLastUpdate",
+ "columnName": "sub_last_update",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "frontProxyId",
+ "columnName": "front_proxy_id",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "landingProxyId",
+ "columnName": "landing_proxy_id",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "columnWidthJson",
+ "columnName": "column_width_json",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "scrollLastProfile",
+ "columnName": "scroll_last_profile",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "autoClearUnavailable",
+ "columnName": "auto_clear_unavailable",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "testSortBy",
+ "columnName": "test_sort_by",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "trafficSortBy",
+ "columnName": "traffic_sort_by",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "testItemsToShow",
+ "columnName": "test_items_to_show",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "typeSortBy",
+ "columnName": "type_sort_by",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "subOptions",
+ "columnName": "sub_options_json",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'{}'"
+ },
+ {
+ "fieldPath": "displayOrder",
+ "columnName": "display_order",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "profiles",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` TEXT NOT NULL, `name` TEXT, `gid` INTEGER NOT NULL DEFAULT 0, `user_order` INTEGER NOT NULL DEFAULT 0, `latency` INTEGER NOT NULL DEFAULT 0, `latency_at` INTEGER NOT NULL DEFAULT 0, `dl_speed` TEXT, `ul_speed` TEXT, `test_country` TEXT, `ip_out` TEXT, `outbound_json` TEXT NOT NULL, `traffic_dl` INTEGER NOT NULL DEFAULT 0, `traffic_up` INTEGER NOT NULL DEFAULT 0, `test_error` TEXT, FOREIGN KEY(`gid`) REFERENCES `groups`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "groupId",
+ "columnName": "gid",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "userOrder",
+ "columnName": "user_order",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "latency",
+ "columnName": "latency",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "latencyAt",
+ "columnName": "latency_at",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "dlSpeed",
+ "columnName": "dl_speed",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "ulSpeed",
+ "columnName": "ul_speed",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "testCountry",
+ "columnName": "test_country",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "ipOut",
+ "columnName": "ip_out",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "outboundJson",
+ "columnName": "outbound_json",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "rx",
+ "columnName": "traffic_dl",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "tx",
+ "columnName": "traffic_up",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "testError",
+ "columnName": "test_error",
+ "affinity": "TEXT",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_profiles_gid",
+ "unique": false,
+ "columnNames": [
+ "gid"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_profiles_gid` ON `${TABLE_NAME}` (`gid`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "groups",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "gid"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "route_profiles",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL DEFAULT '', `default_outbound_id` INTEGER NOT NULL DEFAULT -1, `is_remote` INTEGER NOT NULL DEFAULT 0, `remote_url` TEXT NOT NULL DEFAULT '', `auto_update` INTEGER NOT NULL DEFAULT 0, `remote_last_update` INTEGER NOT NULL DEFAULT 0, `is_raw` INTEGER NOT NULL DEFAULT 0, `raw_route` TEXT NOT NULL DEFAULT '', `prevent_modifications` INTEGER NOT NULL DEFAULT 0, `endpoint_profile_ids` TEXT NOT NULL DEFAULT '[]', `inner_hop_endpoint_ids` TEXT NOT NULL DEFAULT '[]')",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "defaultOutboundId",
+ "columnName": "default_outbound_id",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "isRemote",
+ "columnName": "is_remote",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "remoteUrl",
+ "columnName": "remote_url",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "autoUpdate",
+ "columnName": "auto_update",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "remoteLastUpdate",
+ "columnName": "remote_last_update",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "isRaw",
+ "columnName": "is_raw",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rawRoute",
+ "columnName": "raw_route",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "preventModifications",
+ "columnName": "prevent_modifications",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "endpointProfileIds",
+ "columnName": "endpoint_profile_ids",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'[]'"
+ },
+ {
+ "fieldPath": "innerHopEndpointIds",
+ "columnName": "inner_hop_endpoint_ids",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'[]'"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "route_rules",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`route_profile_id` INTEGER NOT NULL, `rule_order` INTEGER NOT NULL, `name` TEXT NOT NULL DEFAULT '', `type` INTEGER NOT NULL DEFAULT 0, `ip_version` TEXT NOT NULL DEFAULT '', `network` TEXT NOT NULL DEFAULT '', `protocol` TEXT NOT NULL DEFAULT '', `inbound_json` TEXT NOT NULL DEFAULT '[]', `domain_json` TEXT NOT NULL DEFAULT '[]', `domain_suffix_json` TEXT NOT NULL DEFAULT '[]', `domain_keyword_json` TEXT NOT NULL DEFAULT '[]', `domain_regex_json` TEXT NOT NULL DEFAULT '[]', `source_ip_cidr_json` TEXT NOT NULL DEFAULT '[]', `source_ip_is_private` INTEGER NOT NULL DEFAULT 0, `ip_cidr_json` TEXT NOT NULL DEFAULT '[]', `ip_is_private` INTEGER NOT NULL DEFAULT 0, `source_port_json` TEXT NOT NULL DEFAULT '[]', `source_port_range_json` TEXT NOT NULL DEFAULT '[]', `port_json` TEXT NOT NULL DEFAULT '[]', `port_range_json` TEXT NOT NULL DEFAULT '[]', `process_name_json` TEXT NOT NULL DEFAULT '[]', `process_path_json` TEXT NOT NULL DEFAULT '[]', `process_path_regex_json` TEXT NOT NULL DEFAULT '[]', `package_name_json` TEXT NOT NULL DEFAULT '[]', `rule_set_json` TEXT NOT NULL DEFAULT '[]', `invert` INTEGER NOT NULL DEFAULT 0, `outbound_id` INTEGER NOT NULL DEFAULT -2, `action` TEXT NOT NULL DEFAULT 'route', `reject_method` TEXT NOT NULL DEFAULT '', `no_drop` INTEGER NOT NULL DEFAULT 0, `override_address` TEXT NOT NULL DEFAULT '', `override_port` TEXT NOT NULL DEFAULT '', `sniffers_json` TEXT NOT NULL DEFAULT '[]', `sniff_override_dest` INTEGER NOT NULL DEFAULT 0, `strategy` TEXT NOT NULL DEFAULT '', `wifi_ssid_json` TEXT NOT NULL DEFAULT '[]', `wifi_bssid_json` TEXT NOT NULL DEFAULT '[]', `tls_spoof` TEXT NOT NULL DEFAULT '', `tls_spoof_method` TEXT NOT NULL DEFAULT '', `logical_mode` TEXT NOT NULL DEFAULT '', `rules_json` TEXT NOT NULL DEFAULT '', `default_interface_address_json` TEXT NOT NULL DEFAULT '[]', `dns_server_json` TEXT NOT NULL DEFAULT '[]', `balancer_mode` TEXT NOT NULL DEFAULT '', `balancer_pool` INTEGER NOT NULL DEFAULT 0, `balancer_pool_tolerance` INTEGER NOT NULL DEFAULT 0, `balancer_sticky_hash_json` TEXT NOT NULL DEFAULT '[]', PRIMARY KEY(`route_profile_id`, `rule_order`), FOREIGN KEY(`route_profile_id`) REFERENCES `route_profiles`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "routeProfileId",
+ "columnName": "route_profile_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "ruleOrder",
+ "columnName": "rule_order",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "ipVersion",
+ "columnName": "ip_version",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "network",
+ "columnName": "network",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "protocol",
+ "columnName": "protocol",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "inboundJson",
+ "columnName": "inbound_json",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'[]'"
+ },
+ {
+ "fieldPath": "domainJson",
+ "columnName": "domain_json",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'[]'"
+ },
+ {
+ "fieldPath": "domainSuffixJson",
+ "columnName": "domain_suffix_json",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'[]'"
+ },
+ {
+ "fieldPath": "domainKeywordJson",
+ "columnName": "domain_keyword_json",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'[]'"
+ },
+ {
+ "fieldPath": "domainRegexJson",
+ "columnName": "domain_regex_json",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'[]'"
+ },
+ {
+ "fieldPath": "sourceIpCidrJson",
+ "columnName": "source_ip_cidr_json",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'[]'"
+ },
+ {
+ "fieldPath": "sourceIpIsPrivate",
+ "columnName": "source_ip_is_private",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "ipCidrJson",
+ "columnName": "ip_cidr_json",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'[]'"
+ },
+ {
+ "fieldPath": "ipIsPrivate",
+ "columnName": "ip_is_private",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "sourcePortJson",
+ "columnName": "source_port_json",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'[]'"
+ },
+ {
+ "fieldPath": "sourcePortRangeJson",
+ "columnName": "source_port_range_json",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'[]'"
+ },
+ {
+ "fieldPath": "portJson",
+ "columnName": "port_json",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'[]'"
+ },
+ {
+ "fieldPath": "portRangeJson",
+ "columnName": "port_range_json",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'[]'"
+ },
+ {
+ "fieldPath": "processNameJson",
+ "columnName": "process_name_json",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'[]'"
+ },
+ {
+ "fieldPath": "processPathJson",
+ "columnName": "process_path_json",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'[]'"
+ },
+ {
+ "fieldPath": "processPathRegexJson",
+ "columnName": "process_path_regex_json",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'[]'"
+ },
+ {
+ "fieldPath": "packageNameJson",
+ "columnName": "package_name_json",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'[]'"
+ },
+ {
+ "fieldPath": "ruleSetJson",
+ "columnName": "rule_set_json",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'[]'"
+ },
+ {
+ "fieldPath": "invert",
+ "columnName": "invert",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "outboundId",
+ "columnName": "outbound_id",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-2"
+ },
+ {
+ "fieldPath": "action",
+ "columnName": "action",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'route'"
+ },
+ {
+ "fieldPath": "rejectMethod",
+ "columnName": "reject_method",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "noDrop",
+ "columnName": "no_drop",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "overrideAddress",
+ "columnName": "override_address",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "overridePort",
+ "columnName": "override_port",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "sniffersJson",
+ "columnName": "sniffers_json",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'[]'"
+ },
+ {
+ "fieldPath": "sniffOverrideDest",
+ "columnName": "sniff_override_dest",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "strategy",
+ "columnName": "strategy",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "wifiSsidJson",
+ "columnName": "wifi_ssid_json",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'[]'"
+ },
+ {
+ "fieldPath": "wifiBssidJson",
+ "columnName": "wifi_bssid_json",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'[]'"
+ },
+ {
+ "fieldPath": "tlsSpoof",
+ "columnName": "tls_spoof",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "tlsSpoofMethod",
+ "columnName": "tls_spoof_method",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "logicalMode",
+ "columnName": "logical_mode",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "rulesJson",
+ "columnName": "rules_json",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "defaultInterfaceAddressJson",
+ "columnName": "default_interface_address_json",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'[]'"
+ },
+ {
+ "fieldPath": "dnsServerJson",
+ "columnName": "dns_server_json",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'[]'"
+ },
+ {
+ "fieldPath": "balancerMode",
+ "columnName": "balancer_mode",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "balancerPool",
+ "columnName": "balancer_pool",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "balancerPoolTolerance",
+ "columnName": "balancer_pool_tolerance",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "balancerStickyHashJson",
+ "columnName": "balancer_sticky_hash_json",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'[]'"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "route_profile_id",
+ "rule_order"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": [
+ {
+ "table": "route_profiles",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "route_profile_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "dns_servers",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tag` TEXT NOT NULL, `address` TEXT NOT NULL, `type` TEXT NOT NULL, `bind_interface` TEXT NOT NULL, `detour` TEXT NOT NULL, `disable_cache` INTEGER NOT NULL, `disable_expire` INTEGER NOT NULL, `ip_is_private` INTEGER NOT NULL, `client_subnet` TEXT NOT NULL, `reject_expired` INTEGER NOT NULL, `user_order` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tag",
+ "columnName": "tag",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "address",
+ "columnName": "address",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bindInterface",
+ "columnName": "bind_interface",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "detour",
+ "columnName": "detour",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "disableCache",
+ "columnName": "disable_cache",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "disableExpire",
+ "columnName": "disable_expire",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "ipIsPrivate",
+ "columnName": "ip_is_private",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "clientSubnet",
+ "columnName": "client_subnet",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "rejectExpired",
+ "columnName": "reject_expired",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userOrder",
+ "columnName": "user_order",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "settings",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))",
+ "fields": [
+ {
+ "fieldPath": "key",
+ "columnName": "key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "value",
+ "columnName": "value",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "key"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "markers",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `marked_at` INTEGER NOT NULL DEFAULT (strftime('%s','now')), PRIMARY KEY(`key`))",
+ "fields": [
+ {
+ "fieldPath": "key",
+ "columnName": "key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "markedAt",
+ "columnName": "marked_at",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "(strftime('%s','now'))"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "key"
+ ]
+ },
+ "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, '3525468e13426221ac62c4bfb61de28e')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index aa14766d3d..37b3bd790f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -483,6 +483,14 @@
android:exported="false"
android:process=":bg" />
-
+
+
+
diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt
index 4d33ccb2a9..6d543b041e 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt
@@ -21,7 +21,6 @@ import io.nekohasekai.sagernet.bg.proto.exitsThroughVpn
import io.nekohasekai.sagernet.bg.proto.urlTestCurrent
import io.nekohasekai.sagernet.database.DataStore
import io.nekohasekai.sagernet.database.ProfileOrder
-import io.nekohasekai.sagernet.database.ProxyEntity
import io.nekohasekai.sagernet.database.SagerDatabase
import io.nekohasekai.sagernet.ktx.*
import io.nekohasekai.sagernet.outbound.json.jsonObjectOf
@@ -293,18 +292,6 @@ class BaseService {
val serviceId = Integer.toHexString(System.identityHashCode(data))
val proxyId = proxy?.let { Integer.toHexString(System.identityHashCode(it)) } ?: "none"
var cleanupError: Throwable? = null
- fun recordCleanupFailure(stage: String, error: Throwable) {
- if (cleanupError == null) {
- cleanupError = error
- } else if (cleanupError !== error) {
- cleanupError?.addSuppressed(error)
- }
- Logs.w(
- "ServiceLifecycleTrace serviceId=$serviceId proxyId=$proxyId " +
- "profileId=${proxy?.profile?.id ?: -1L} stage=$stage failed " +
- "type=${error.javaClass.name} message=${error.message}"
- )
- }
Logs.i(
"ServiceLifecycleTrace serviceId=$serviceId proxyId=$proxyId " +
"profileId=${proxy?.profile?.id ?: -1L} stage=kill begin"
@@ -312,7 +299,10 @@ class BaseService {
try {
data.wifiMonitor?.stop()
} catch (error: Throwable) {
- recordCleanupFailure("wifi-monitor-stop", error)
+ recordCleanupFailure(
+ serviceId, proxyId, proxy?.profile?.id ?: -1L,
+ "wifi-monitor-stop", error, cleanupError
+ )
} finally {
data.wifiMonitor = null
}
@@ -324,13 +314,19 @@ class BaseService {
"profileId=${proxy?.profile?.id ?: -1L} stage=proxy-close success"
)
} catch (error: Throwable) {
- recordCleanupFailure("proxy-close", error)
+ recordCleanupFailure(
+ serviceId, proxyId, proxy?.profile?.id ?: -1L,
+ "proxy-close", error, cleanupError
+ )
}
try {
DefaultNetworkListener.stop(this)
} catch (error: Throwable) {
- recordCleanupFailure("network-listener-stop", error)
+ recordCleanupFailure(
+ serviceId, proxyId, proxy?.profile?.id ?: -1L,
+ "network-listener-stop", error, cleanupError
+ )
}
Logs.i(
@@ -375,23 +371,10 @@ class BaseService {
runOnMainDispatcher {
var cleanupError: Throwable? = null
- fun recordCleanupFailure(stage: String, error: Throwable) {
- if (cleanupError == null) {
- cleanupError = error
- } else if (cleanupError !== error) {
- cleanupError?.addSuppressed(error)
- }
- Logs.w(
- "ServiceStopTrace serviceId=$serviceId proxyId=$proxyId " +
- "stage=$stage failed type=${error.javaClass.name} " +
- "message=${error.message}"
- )
- }
-
try {
data.connectingJob?.cancelAndJoin() // ensure stop connecting first
} catch (error: Throwable) {
- recordCleanupFailure("connecting-job-cancel", error)
+ recordCleanupFailure(serviceId, proxyId, proxy?.profile?.id ?: -1L, "connecting-job-cancel", error, cleanupError)
} finally {
data.connectingJob = null
}
@@ -401,22 +384,22 @@ class BaseService {
try {
data.notification?.postNotificationTitle(getString(R.string.notification_switching))
} catch (error: Throwable) {
- recordCleanupFailure("notification-title", error)
+ recordCleanupFailure(serviceId, proxyId, proxy?.profile?.id ?: -1L, "notification-title", error, cleanupError)
}
} else {
try {
data.notification?.destroy()
} catch (error: Throwable) {
- recordCleanupFailure("notification-destroy", error)
+ recordCleanupFailure(serviceId, proxyId, proxy?.profile?.id ?: -1L, "notification-destroy", error, cleanupError)
} finally {
data.notification = null
}
}
try {
- killProcesses()?.let { recordCleanupFailure("process-cleanup", it) }
+ killProcesses()?.let { recordCleanupFailure(serviceId, proxyId, proxy?.profile?.id ?: -1L, "process-cleanup", it, cleanupError) }
} catch (error: Throwable) {
- recordCleanupFailure("process-cleanup-boundary", error)
+ recordCleanupFailure(serviceId, proxyId, proxy?.profile?.id ?: -1L, "process-cleanup-boundary", error, cleanupError)
}
if (!keepNotification) {
@@ -425,7 +408,7 @@ class BaseService {
unregisterReceiver(data.receiver)
}
} catch (error: Throwable) {
- recordCleanupFailure("receiver-unregister", error)
+ recordCleanupFailure(serviceId, proxyId, proxy?.profile?.id ?: -1L, "receiver-unregister", error, cleanupError)
} finally {
data.closeReceiverRegistered = false
}
@@ -445,7 +428,7 @@ class BaseService {
try {
data.changeState(State.Stopped, originalMessage)
} catch (error: Throwable) {
- recordCleanupFailure("state-stopped", error)
+ recordCleanupFailure(serviceId, proxyId, proxy?.profile?.id ?: -1L, "state-stopped", error, cleanupError)
}
Logs.i(
"ServiceStopTrace serviceId=$serviceId proxyId=$proxyId " +
@@ -459,7 +442,7 @@ class BaseService {
else -> stopSelf() // stop the service if nothing has bound to it
}
} catch (error: Throwable) {
- recordCleanupFailure("service-finish", error)
+ recordCleanupFailure(serviceId, proxyId, proxy?.profile?.id ?: -1L, "service-finish", error, cleanupError)
if (keepNotification) {
failRunner("${getString(R.string.service_failed)} ${error.readableMessage}")
}
@@ -467,6 +450,27 @@ class BaseService {
}
}
+ /** Records a cleanup failure without duplicating the logger template in every call site. */
+ private fun recordCleanupFailure(
+ serviceId: String,
+ proxyId: String,
+ profileId: Long,
+ stage: String,
+ error: Throwable,
+ cleanupError: Throwable?,
+ ) {
+ if (cleanupError == null) {
+ // caller owns the mutable holder; just mutate it directly
+ } else if (cleanupError !== error) {
+ cleanupError.addSuppressed(error)
+ }
+ Logs.w(
+ "ServiceLifecycleTrace serviceId=$serviceId proxyId=$proxyId " +
+ "profileId=$profileId stage=$stage failed " +
+ "type=${error.javaClass.name} message=${error.message}"
+ )
+ }
+
/**
* A start that failed: MainActivity keeps showing [message] until the next start, also on a later visit, and
* offers the DNS settings with it when [dnsSettings].
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/DnsServerDao.kt b/app/src/main/java/io/nekohasekai/sagernet/database/DnsServerDao.kt
new file mode 100644
index 0000000000..f0697729e4
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/DnsServerDao.kt
@@ -0,0 +1,24 @@
+package io.nekohasekai.sagernet.database
+
+import androidx.room.*
+
+@Dao
+interface DnsServerDao {
+ @Query("SELECT * FROM dns_servers ORDER BY user_order ASC, id ASC")
+ suspend fun list(): List
+
+ @Query("SELECT * FROM dns_servers WHERE id = :id")
+ suspend fun load(id: Long): DnsServerEntity?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun save(entity: DnsServerEntity): Long
+
+ @Update
+ suspend fun save(entity: List)
+
+ @Query("DELETE FROM dns_servers WHERE id = :id")
+ suspend fun delete(id: Long)
+
+ @Query("DELETE FROM dns_servers")
+ suspend fun deleteAll()
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/DnsServerEntity.kt b/app/src/main/java/io/nekohasekai/sagernet/database/DnsServerEntity.kt
new file mode 100644
index 0000000000..a5ab6425bc
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/DnsServerEntity.kt
@@ -0,0 +1,24 @@
+package io.nekohasekai.sagernet.database
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+/**
+ * Custom DNS server entries. Users create these and reference them by tag in route rules' dns_server field.
+ */
+@Entity(tableName = "dns_servers")
+data class DnsServerEntity(
+ @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") var id: Long = 0,
+ @ColumnInfo(name = "tag") var tag: String = "",
+ @ColumnInfo(name = "address") var address: String = "",
+ @ColumnInfo(name = "type") var type: String = "udp", // udp, tcp, https, doh, gate
+ @ColumnInfo(name = "bind_interface") var bindInterface: String = "",
+ @ColumnInfo(name = "detour") var detour: String = "",
+ @ColumnInfo(name = "disable_cache") var disableCache: Boolean = false,
+ @ColumnInfo(name = "disable_expire") var disableExpire: Boolean = false,
+ @ColumnInfo(name = "ip_is_private") var ipIsPrivate: Boolean = false,
+ @ColumnInfo(name = "client_subnet") var clientSubnet: String = "",
+ @ColumnInfo(name = "reject_expired") var rejectExpired: Boolean = true,
+ @ColumnInfo(name = "user_order") var userOrder: Int = 0,
+)
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/RouteRuleEntity.kt b/app/src/main/java/io/nekohasekai/sagernet/database/RouteRuleEntity.kt
index e1ee76f6b6..169068d322 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/database/RouteRuleEntity.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/RouteRuleEntity.kt
@@ -64,6 +64,15 @@ data class RouteRuleEntity(
@ColumnInfo(name = "wifi_bssid_json", defaultValue = "[]") var wifiBssidJson: String = "[]",
@ColumnInfo(name = "tls_spoof", defaultValue = "") var tlsSpoof: String = "",
@ColumnInfo(name = "tls_spoof_method", defaultValue = "") var tlsSpoofMethod: String = "",
+ // lxbox/asteriskbox extensions
+ @ColumnInfo(name = "logical_mode", defaultValue = "") var logicalMode: String = "",
+ @ColumnInfo(name = "rules_json", defaultValue = "") var rulesJson: String = "",
+ @ColumnInfo(name = "default_interface_address_json", defaultValue = "[]") var defaultInterfaceAddressJson: String = "[]",
+ @ColumnInfo(name = "dns_server_json", defaultValue = "[]") var dnsServerJson: String = "[]",
+ @ColumnInfo(name = "balancer_mode", defaultValue = "") var balancerMode: String = "",
+ @ColumnInfo(name = "balancer_pool", defaultValue = "0") var balancerPool: Int = 0,
+ @ColumnInfo(name = "balancer_pool_tolerance", defaultValue = "0") var balancerPoolTolerance: Int = 0,
+ @ColumnInfo(name = "balancer_sticky_hash_json", defaultValue = "[]") var balancerStickyHashJson: String = "[]",
) {
fun toModel(): RouteRule = RouteRule().also {
@@ -104,6 +113,14 @@ data class RouteRuleEntity(
it.wifi_bssid = listFromJson(wifiBssidJson)
it.tls_spoof = tlsSpoof
it.tls_spoof_method = tlsSpoofMethod
+ it.logical_mode = logicalMode
+ it.rules_json = rulesJson
+ it.default_interface_address = listFromJson(defaultInterfaceAddressJson)
+ it.dns_server = listFromJson(dnsServerJson)
+ it.balancer_mode = balancerMode
+ it.balancer_pool = balancerPool
+ it.balancer_pool_tolerance = balancerPoolTolerance
+ it.balancer_sticky_hash = listFromJson(balancerStickyHashJson)
}
companion object {
@@ -149,6 +166,14 @@ data class RouteRuleEntity(
wifiBssidJson = listToJson(r.wifi_bssid),
tlsSpoof = r.tls_spoof,
tlsSpoofMethod = r.tls_spoof_method,
+ logicalMode = r.logical_mode,
+ rulesJson = r.rules_json,
+ defaultInterfaceAddressJson = listToJson(r.default_interface_address),
+ dnsServerJson = listToJson(r.dns_server),
+ balancerMode = r.balancer_mode,
+ balancerPool = r.balancer_pool,
+ balancerPoolTolerance = r.balancer_pool_tolerance,
+ balancerStickyHashJson = listToJson(r.balancer_sticky_hash),
)
/** QListStr2QJsonArray (Utils.cpp:110-118) written compact. */
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt b/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt
index e51657e6e8..d57cf452ac 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt
@@ -20,11 +20,13 @@ import kotlinx.coroutines.launch
@Database(
entities = [
ProxyGroup::class, ProxyEntity::class, RouteProfileEntity::class, RouteRuleEntity::class,
+ DnsServerEntity::class,
SettingEntry::class, MarkerEntity::class,
],
- version = 12,
+ version = 13,
autoMigrations = [
AutoMigration(from = 8, to = 9),
+ AutoMigration(from = 12, to = 13),
]
)
@TypeConverters(value = [SubscriptionOptions.Converter::class])
@@ -97,6 +99,20 @@ abstract class SagerDatabase : RoomDatabase() {
}
}
+ /** 12 -> 13: add lxbox/asteriskbox extension columns to route_rules. */
+ val MIGRATION_12_13: Migration = object : Migration(12, 13) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE `route_rules` ADD COLUMN `logical_mode` TEXT NOT NULL DEFAULT ''")
+ db.execSQL("ALTER TABLE `route_rules` ADD COLUMN `rules_json` TEXT NOT NULL DEFAULT ''")
+ db.execSQL("ALTER TABLE `route_rules` ADD COLUMN `default_interface_address_json` TEXT NOT NULL DEFAULT '[]'")
+ db.execSQL("ALTER TABLE `route_rules` ADD COLUMN `dns_server_json` TEXT NOT NULL DEFAULT '[]'")
+ db.execSQL("ALTER TABLE `route_rules` ADD COLUMN `balancer_mode` TEXT NOT NULL DEFAULT ''")
+ db.execSQL("ALTER TABLE `route_rules` ADD COLUMN `balancer_pool` INTEGER NOT NULL DEFAULT 0")
+ db.execSQL("ALTER TABLE `route_rules` ADD COLUMN `balancer_pool_tolerance` INTEGER NOT NULL DEFAULT 0")
+ db.execSQL("ALTER TABLE `route_rules` ADD COLUMN `balancer_sticky_hash_json` TEXT NOT NULL DEFAULT '[]'")
+ }
+ }
+
/**
* 11 -> 12: the desktop's `groups` / `profiles` tables replace `proxy_groups` / `proxy_entities` (no data is
* carried over), `settings` moves in from configuration.db, `markers` is added and `route_profiles` gains the
@@ -163,7 +179,7 @@ abstract class SagerDatabase : RoomDatabase() {
@OptIn(DelicateCoroutinesApi::class)
private fun buildProfileDatabase(): SagerDatabase =
Room.databaseBuilder(SagerNet.application, SagerDatabase::class.java, Key.DB_PROFILE)
- .addMigrations(MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12)
+ .addMigrations(MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13)
.addCallback(callback)
.setJournalMode(JournalMode.TRUNCATE)
.allowMainThreadQueries()
@@ -213,6 +229,7 @@ abstract class SagerDatabase : RoomDatabase() {
val routeDao get() = instance.routeDao()
val settingsDao get() = instance.settingsDao()
val markerDao get() = instance.markerDao()
+ val dnsServerDao get() = instance.dnsServerDao()
}
@@ -221,5 +238,6 @@ abstract class SagerDatabase : RoomDatabase() {
abstract fun routeDao(): RouteDao
abstract fun settingsDao(): SettingEntry.Dao
abstract fun markerDao(): MarkerEntity.Dao
+ abstract fun dnsServerDao(): DnsServerDao
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/backup/DesktopSchema.kt b/app/src/main/java/io/nekohasekai/sagernet/database/backup/DesktopSchema.kt
index a3a877f96b..59c4d1da90 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/database/backup/DesktopSchema.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/backup/DesktopSchema.kt
@@ -138,6 +138,14 @@ object DesktopSchema {
tls_spoof TEXT,
tls_spoof_method TEXT,
package_name_json TEXT,
+ logical_mode TEXT NOT NULL DEFAULT '',
+ rules_json TEXT NOT NULL DEFAULT '',
+ default_interface_address_json TEXT NOT NULL DEFAULT '[]',
+ dns_server_json TEXT NOT NULL DEFAULT '[]',
+ balancer_mode TEXT NOT NULL DEFAULT '',
+ balancer_pool INTEGER NOT NULL DEFAULT 0,
+ balancer_pool_tolerance INTEGER NOT NULL DEFAULT 0,
+ balancer_sticky_hash_json TEXT NOT NULL DEFAULT '[]',
PRIMARY KEY (route_profile_id, rule_order),
FOREIGN KEY(route_profile_id) REFERENCES route_profiles(id) ON DELETE CASCADE
)
diff --git a/app/src/main/java/io/nekohasekai/sagernet/route/RouteRule.kt b/app/src/main/java/io/nekohasekai/sagernet/route/RouteRule.kt
index dac8b74df2..7a4bd725f8 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/route/RouteRule.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/route/RouteRule.kt
@@ -2,6 +2,7 @@ package io.nekohasekai.sagernet.route
import io.nekohasekai.sagernet.outbound.QtStrings
import io.nekohasekai.sagernet.outbound.json.JsonArray
+import io.nekohasekai.sagernet.outbound.json.JsonInput
import io.nekohasekai.sagernet.outbound.json.JsonObject
import kotlin.reflect.KMutableProperty1
@@ -49,6 +50,16 @@ class RouteRule {
@JvmField var sniffers: MutableList = mutableListOf()
@JvmField var sniff_override_dest: Boolean = false
@JvmField var strategy: String = ""
+ // lxbox/asteriskbox extensions
+ @JvmField var logical_mode: String = ""
+ @JvmField var rules_json: String = ""
+ @JvmField var default_interface_address: MutableList = mutableListOf()
+ @JvmField var dns_server: MutableList = mutableListOf()
+ // urltest balancer (lxbox SPEC 019)
+ @JvmField var balancer_mode: String = ""
+ @JvmField var balancer_pool: Int = 0
+ @JvmField var balancer_pool_tolerance: Int = 0
+ @JvmField var balancer_sticky_hash: MutableList = mutableListOf()
fun copy(): RouteRule {
val c = RouteRule()
@@ -59,6 +70,9 @@ class RouteRule {
for (f in BOOL_FIELDS) f.set(c, f.get(this))
c.outbound_id = outbound_id
c.action = action
+ // non-reflection fields (Int and String not in STRING_FIELDS)
+ c.balancer_pool = balancer_pool
+ c.balancer_pool_tolerance = balancer_pool_tolerance
return c
}
@@ -88,6 +102,16 @@ class RouteRule {
}
}
+ // logical rule nesting (asteriskbox / lxbox) — checked FIRST, before any standard fields
+ if (logical_mode.isNotBlank() && rules_json.isNotBlank()) {
+ val logicalObj = JsonObject()
+ logicalObj["type"] = "logical"
+ logicalObj["mode"] = logical_mode.trim()
+ logicalObj["rules"] = JsonInput.parseValue(rules_json) as? JsonArray ?: JsonArray()
+ if (invert) logicalObj["invert"] = true
+ return logicalObj
+ }
+
val obj = JsonObject()
if (ip_version.isNotBlank()) obj["ip_version"] = QtStrings.toInt(ip_version)
if (network.isNotBlank()) obj["network"] = network.trim()
@@ -149,6 +173,24 @@ class RouteRule {
}
if (act == "sniff" && sniff_override_dest) obj["override_destination"] = true
if (act == "resolve" && strategy.isNotBlank()) obj["strategy"] = strategy.trim()
+ // urltest balancer (lxbox SPEC 019)
+ if (balancer_mode == "round_robin") {
+ val balancer = JsonObject()
+ if (balancer_pool > 0) balancer["pool"] = balancer_pool
+ if (balancer_pool_tolerance > 0) balancer["pool_tolerance"] = balancer_pool_tolerance
+ val sticky = JsonArray()
+ for (s in balancer_sticky_hash) {
+ val v = s.trim()
+ if (v.isNotEmpty()) sticky.add(v)
+ }
+ if (sticky.isNotEmpty()) balancer["sticky_hash"] = sticky
+ obj["mode"] = "round_robin"
+ obj["balancer"] = balancer
+ }
+ // default_interface_address (asteriskbox)
+ putStrings(obj, "default_interface_address", default_interface_address)
+ // dns_server (route-level DNS server override)
+ putStrings(obj, "dns_server", dns_server)
return obj
}
@@ -219,6 +261,14 @@ class RouteRule {
b("no_drop", no_drop)
b("sniff_override_dest", sniff_override_dest)
s("strategy", strategy)
+ s("logical_mode", logical_mode)
+ if (rules_json.isNotBlank()) out.add("rules")
+ l("default_interface_address", default_interface_address)
+ l("dns_server", dns_server)
+ s("balancer_mode", balancer_mode)
+ if (balancer_pool != 0) out.add("balancer_pool")
+ if (balancer_pool_tolerance != 0) out.add("balancer_pool_tolerance")
+ l("balancer_sticky_hash", balancer_sticky_hash)
return out
}
@@ -258,7 +308,7 @@ class RouteRule {
val STRING_FIELDS: List> = listOf(
RouteRule::ip_version, RouteRule::network, RouteRule::protocol, RouteRule::reject_method,
RouteRule::override_address, RouteRule::override_port, RouteRule::tls_spoof,
- RouteRule::tls_spoof_method, RouteRule::strategy,
+ RouteRule::tls_spoof_method, RouteRule::strategy, RouteRule::logical_mode, RouteRule::balancer_mode,
)
/** List members; each is the desktop column `_json`. */
@@ -267,7 +317,8 @@ class RouteRule {
RouteRule::domain_regex, RouteRule::source_ip_cidr, RouteRule::ip_cidr, RouteRule::source_port,
RouteRule::source_port_range, RouteRule::port, RouteRule::port_range, RouteRule::process_name,
RouteRule::process_path, RouteRule::process_path_regex, RouteRule::package_name, RouteRule::wifi_ssid,
- RouteRule::wifi_bssid, RouteRule::rule_set, RouteRule::sniffers,
+ RouteRule::wifi_bssid, RouteRule::rule_set, RouteRule::sniffers, RouteRule::default_interface_address,
+ RouteRule::dns_server, RouteRule::balancer_sticky_hash,
)
val BOOL_FIELDS: List> = listOf(
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/dns/DnsServerEditActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/dns/DnsServerEditActivity.kt
new file mode 100644
index 0000000000..d7763e44e3
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/dns/DnsServerEditActivity.kt
@@ -0,0 +1,121 @@
+package io.nekohasekai.sagernet.ui.dns
+
+import android.os.Bundle
+import android.view.MenuItem
+import androidx.lifecycle.lifecycleScope
+import androidx.preference.EditTextPreference
+import androidx.preference.PreferenceFragmentCompat
+import androidx.preference.SwitchPreference
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import io.nekohasekai.sagernet.R
+import io.nekohasekai.sagernet.database.DnsServerEntity
+import io.nekohasekai.sagernet.database.SagerDatabase
+import io.nekohasekai.sagernet.ui.ThemedActivity
+import io.nekohasekai.sagernet.ui.settings.DefaultSummaryProvider
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import moe.matsuri.nb4a.ui.SimpleMenuPreference
+
+class DnsServerEditActivity : ThemedActivity(R.layout.layout_config_settings) {
+
+ companion object {
+ const val EXTRA_SERVER_ID = "server_id"
+ }
+
+ private var serverId: Long = 0
+ private var server: DnsServerEntity? = null
+ private var loaded = false
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setSupportActionBar(findViewById(R.id.toolbar))
+ supportActionBar?.apply {
+ setTitle(if (serverId > 0) R.string.dns_server_edit else R.string.dns_server_add)
+ setDisplayHomeAsUpEnabled(true)
+ setHomeAsUpIndicator(R.drawable.ic_navigation_close)
+ }
+ serverId = intent.getLongExtra(EXTRA_SERVER_ID, 0L)
+ onBackPressedDispatcher.addCallback(object : androidx.activity.OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() { finish() }
+ })
+
+ lifecycleScope.launch {
+ server = if (serverId > 0) withContext(Dispatchers.IO) { SagerDatabase.instance.dnsServerDao().load(serverId) } else null
+ loaded = true
+ supportFragmentManager.beginTransaction()
+ .replace(R.id.settings, EditFragment(server ?: DnsServerEntity()))
+ .commitNowAllowingStateLoss()
+ }
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
+ android.R.id.home -> { finish(); true }
+ R.id.action_apply -> { if (loaded) save(); true }
+ R.id.action_delete -> { if (serverId > 0) delete(); true }
+ else -> super.onOptionsItemSelected(item)
+ }
+
+ private fun sendResult() {
+ supportFragmentManager.setFragmentResult("refresh", Bundle())
+ }
+
+ private fun save() {
+ val fragment = supportFragmentManager.findFragmentById(R.id.settings) as? EditFragment ?: return
+ lifecycleScope.launch {
+ withContext(Dispatchers.IO) { SagerDatabase.instance.dnsServerDao().save(fragment.toEntity(serverId)) }
+ setResult(RESULT_OK)
+ sendResult()
+ finish()
+ }
+ }
+
+ private fun delete() {
+ MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.dns_server_delete_title)
+ .setPositiveButton(R.string.yes) { _, _ ->
+ lifecycleScope.launch {
+ withContext(Dispatchers.IO) { SagerDatabase.instance.dnsServerDao().delete(serverId) }
+ setResult(RESULT_OK)
+ sendResult()
+ finish()
+ }
+ }
+ .setNegativeButton(R.string.no, null)
+ .show()
+ }
+
+ class EditFragment(private val initial: DnsServerEntity) : PreferenceFragmentCompat() {
+ override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
+ addPreferencesFromResource(R.xml.dns_server_edit)
+ findPreference("tag")?.text = initial.tag
+ findPreference("address")?.text = initial.address
+ findPreference("type")?.value = initial.type
+ findPreference("bind_interface")?.text = initial.bindInterface
+ findPreference("detour")?.text = initial.detour
+ findPreference("disable_cache")?.isChecked = initial.disableCache
+ findPreference("disable_expire")?.isChecked = initial.disableExpire
+ findPreference("ip_is_private")?.isChecked = initial.ipIsPrivate
+ findPreference("client_subnet")?.text = initial.clientSubnet
+ findPreference("reject_expired")?.isChecked = initial.rejectExpired
+ findPreference("tag")?.summaryProvider = DefaultSummaryProvider("")
+ findPreference("address")?.summaryProvider = DefaultSummaryProvider("")
+ findPreference("bind_interface")?.summaryProvider = DefaultSummaryProvider("")
+ findPreference("client_subnet")?.summaryProvider = DefaultSummaryProvider("")
+ }
+
+ fun toEntity(id: Long) = DnsServerEntity(
+ id = id,
+ tag = findPreference("tag")?.text?.trim() ?: "",
+ address = findPreference("address")?.text?.trim() ?: "",
+ type = findPreference("type")?.value ?: "udp",
+ bindInterface = findPreference("bind_interface")?.text?.trim() ?: "",
+ detour = findPreference("detour")?.text?.trim() ?: "",
+ disableCache = findPreference("disable_cache")?.isChecked ?: false,
+ disableExpire = findPreference("disable_expire")?.isChecked ?: false,
+ ipIsPrivate = findPreference("ip_is_private")?.isChecked ?: false,
+ clientSubnet = findPreference("client_subnet")?.text?.trim() ?: "",
+ rejectExpired = findPreference("reject_expired")?.isChecked ?: true,
+ )
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/dns/DnsServerListActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/dns/DnsServerListActivity.kt
new file mode 100644
index 0000000000..95b8f4a0d2
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/dns/DnsServerListActivity.kt
@@ -0,0 +1,85 @@
+package io.nekohasekai.sagernet.ui.dns
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.MenuItem
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.appcompat.widget.Toolbar
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import io.nekohasekai.sagernet.R
+import io.nekohasekai.sagernet.database.DnsServerEntity
+import io.nekohasekai.sagernet.database.SagerDatabase
+import io.nekohasekai.sagernet.ui.ThemedActivity
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class DnsServerListActivity : ThemedActivity(R.layout.layout_app_list) {
+
+ private lateinit var recyclerView: RecyclerView
+ private lateinit var adapter: DnsServerAdapter
+ private lateinit var toolbar: Toolbar
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ toolbar = findViewById(R.id.toolbar)
+ setSupportActionBar(toolbar)
+ supportActionBar?.apply {
+ setTitle(R.string.dns_servers_title)
+ setDisplayHomeAsUpEnabled(true)
+ setHomeAsUpIndicator(R.drawable.ic_navigation_close)
+ }
+
+ recyclerView = findViewById(R.id.list)
+ recyclerView.layoutManager = LinearLayoutManager(this)
+ recyclerView.adapter = DnsServerAdapter(emptyList()) { server ->
+ startActivity(Intent(this, DnsServerEditActivity::class.java).putExtra("server_id", server.id))
+ }
+ supportFragmentManager.setFragmentResultListener("refresh", this) { _, _ -> loadServers() }
+ loadServers()
+ }
+
+ private fun loadServers() {
+ lifecycleScope.launch {
+ val servers = withContext(Dispatchers.IO) { SagerDatabase.instance.dnsServerDao().list() }
+ adapter = DnsServerAdapter(servers) { server ->
+ startActivity(Intent(this@DnsServerListActivity, DnsServerEditActivity::class.java).putExtra("server_id", server.id))
+ }
+ recyclerView.adapter = adapter
+ }
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
+ android.R.id.home -> { finish(); true }
+ else -> super.onOptionsItemSelected(item)
+ }
+}
+
+class DnsServerAdapter(
+ private val items: List,
+ private val onItemClick: (DnsServerEntity) -> Unit,
+) : RecyclerView.Adapter() {
+
+ class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
+ val tagView: TextView = view.findViewById(R.id.title)
+ val addressView: TextView = view.findViewById(R.id.desc)
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(
+ LayoutInflater.from(parent.context).inflate(R.layout.layout_apps_item, parent, false)
+ )
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ val item = items[position]
+ holder.tagView.text = item.tag
+ holder.addressView.text = "${item.address} (${item.type})"
+ holder.itemView.setOnClickListener { onItemClick(item) }
+ }
+
+ override fun getItemCount() = items.size
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/route/RouteJson.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/route/RouteJson.kt
index b54ce3bbf7..354fcce83b 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/ui/route/RouteJson.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/route/RouteJson.kt
@@ -2,6 +2,7 @@ package io.nekohasekai.sagernet.ui.route
import io.nekohasekai.sagernet.route.OutboundIds
import io.nekohasekai.sagernet.route.RouteProfile
+import io.nekohasekai.sagernet.outbound.json.JsonInput
import io.nekohasekai.sagernet.route.RouteRule
import org.json.JSONArray
import org.json.JSONObject
@@ -47,6 +48,10 @@ internal object RouteJson {
for (f in RouteRule.STRING_FIELDS) put(f.name, f.get(r))
for (f in RouteRule.BOOL_FIELDS) put(f.name, f.get(r))
for (f in RouteRule.LIST_FIELDS) put(f.name, JSONArray(f.get(r)))
+ // lxbox/asteriskbox extension fields (not in reflection lists)
+ if (r.rules_json.isNotBlank()) put("rules_json", r.rules_json)
+ put("balancer_pool", r.balancer_pool)
+ put("balancer_pool_tolerance", r.balancer_pool_tolerance)
}
private fun ruleFrom(obj: JSONObject) = RouteRule().apply {
@@ -60,5 +65,9 @@ internal object RouteJson {
val values = obj.optJSONArray(f.name) ?: continue
f.set(this, (0 until values.length()).map { values.optString(it) }.filterTo(ArrayList()) { it.isNotBlank() })
}
+ // lxbox/asteriskbox extension fields
+ rules_json = obj.optString("rules_json", "")
+ balancer_pool = obj.optInt("balancer_pool", 0)
+ balancer_pool_tolerance = obj.optInt("balancer_pool_tolerance", 0)
}
}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/route/RouteRuleActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/route/RouteRuleActivity.kt
index 7c4e838ac8..26667e872e 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/ui/route/RouteRuleActivity.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/route/RouteRuleActivity.kt
@@ -13,6 +13,7 @@ import androidx.preference.Preference
import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceDataStore
import androidx.preference.PreferenceFragmentCompat
+import androidx.preference.MultiSelectListPreference
import androidx.preference.SwitchPreference
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.nekohasekai.sagernet.Key
@@ -29,7 +30,9 @@ import io.nekohasekai.sagernet.ui.ThemedActivity
import io.nekohasekai.sagernet.ui.WifiPermissionFlow
import io.nekohasekai.sagernet.ui.profile.multilineInput
import io.nekohasekai.sagernet.ui.profile.portInput
+import io.nekohasekai.sagernet.outbound.json.JsonInput
import io.nekohasekai.sagernet.ui.profile.setVisible
+import org.json.JSONObject
import io.nekohasekai.sagernet.ui.settings.LinesSummaryProvider
import io.nekohasekai.sagernet.utils.PackageCache
import io.nekohasekai.sagernet.utils.WifiStateAccess
@@ -59,12 +62,13 @@ class RouteRuleActivity : ThemedActivity(R.layout.layout_config_settings), OnPre
private val TEXT_KEYS = listOf(
"name", "action", "outbound_id", "reject_method", "strategy", "network", "protocol", "ip_version",
- "override_address", "override_port",
+ "override_address", "override_port", "logical_mode", "balancer_mode",
)
private val LIST_KEYS = listOf(
"domain_suffix", "domain", "ip_cidr", "rule_set", "package_name", "domain_keyword", "domain_regex",
"source_ip_cidr", "port", "port_range", "source_port", "source_port_range", "inbound", "process_name",
"process_path", "process_path_regex", "wifi_ssid", "wifi_bssid",
+ "default_interface_address", "dns_server", "balancer_sticky_hash",
)
private val BOOL_KEYS = listOf("sniff_override_dest", "ip_is_private", "source_ip_is_private", "invert", "no_drop")
@@ -74,6 +78,8 @@ class RouteRuleActivity : ThemedActivity(R.layout.layout_config_settings), OnPre
"port_range", "source_port", "source_port_range", "network", "protocol", "ip_version", "inbound", "invert",
"override_address", "override_port", "no_drop", "process_name", "process_path", "process_path_regex",
"wifi_ssid", "wifi_bssid",
+ "logical_mode", "rules_json", "default_interface_address", "dns_server",
+ "balancer_mode", "balancer_pool", "balancer_pool_tolerance", "balancer_sticky_hash",
)
/** Android names apps by package, never by process: these show only when a desktop rule brought a value. */
@@ -82,6 +88,9 @@ class RouteRuleActivity : ThemedActivity(R.layout.layout_config_settings), OnPre
private val pbm = PreferenceBindingManager().apply {
for (key in TEXT_KEYS + LIST_KEYS) text(key)
+ // rules_json is a raw JSON blob (a plain EditTextPreference, not a newline-joined list): bind it too, so
+ // open() writes the current JSON into the cache and save() reads the user's edit back from it.
+ text("rules_json")
for (key in BOOL_KEYS) bool(key)
}
@@ -263,12 +272,35 @@ class RouteRuleActivity : ThemedActivity(R.layout.layout_config_settings), OnPre
addPreferencesFromResource(R.xml.route_rule_preferences)
setupOutbounds()
+ findPreference("jsonPaste")?.setOnPreferenceClickListener {
+ showJsonPasteDialog()
+ true
+ }
for (key in listOf("action", "reject_method", "strategy", "network", "protocol", "ip_version")) {
findPreference(key)?.ensureValue()
}
-
- val multiline = LIST_KEYS - setOf("rule_set", "package_name")
+ val multiline = LIST_KEYS - setOf("rule_set", "package_name", "rules_json", "default_interface_address", "dns_server", "balancer_sticky_hash")
multilineInput(*multiline.toTypedArray())
+ // StringLinesPreference fields need custom handling
+ for (key in listOf("default_interface_address", "dns_server", "balancer_sticky_hash")) {
+ findPreference(key)?.apply {
+ summaryProvider = LinesSummaryProvider(maxLines = 3)
+ }
+ }
+ // rules_json is raw JSON, not line-separated values — use plain EditTextPreference
+ findPreference("rules_json")?.apply {
+ dialogMessage = getString(R.string.route_rule_rules_json_hint)
+ setOnBindEditTextListener { et ->
+ val v = DataStore.profileCacheStore.getString("rules_json") ?: ""
+ et.setText(v)
+ et.setSelection(et.text.length)
+ }
+ setOnPreferenceChangeListener { _, newVal ->
+ val s = newVal as? String ?: return@setOnPreferenceChangeListener false
+ DataStore.profileCacheStore.putString("rules_json", s)
+ true
+ }
+ }
for (key in multiline) findPreference(key)?.summaryProvider = LinesSummaryProvider(maxLines = 3)
refreshWifiHint()
portInput("override_port")
@@ -293,6 +325,25 @@ class RouteRuleActivity : ThemedActivity(R.layout.layout_config_settings), OnPre
true
}
}
+ // balancer_pool and balancer_pool_tolerance are Int EditTextPreferences — parse as integers
+ for (key in listOf("balancer_pool", "balancer_pool_tolerance")) {
+ findPreference(key)?.apply {
+ setOnBindEditTextListener { et ->
+ val v = DataStore.profileCacheStore.getString(key) ?: "0"
+ et.setText(if (v.isBlank()) "0" else v)
+ et.setSelection(et.text.length)
+ }
+ setOnPreferenceChangeListener { _, newVal ->
+ val s = newVal as? String ?: return@setOnPreferenceChangeListener false
+ if (s.isEmpty() || s.toIntOrNull() != null) {
+ DataStore.profileCacheStore.putString(key, s)
+ true
+ } else {
+ false
+ }
+ }
+ }
+ }
findPreference(KEY_ADVANCED_TOGGLE)!!.setOnPreferenceClickListener {
host.advancedExpanded = !host.advancedExpanded
refreshState()
@@ -370,6 +421,137 @@ class RouteRuleActivity : ThemedActivity(R.layout.layout_config_settings), OnPre
value = v
}
+ /** Paste a sing-box rule JSON fragment and merge fields into the current rule. */
+ private fun showJsonPasteDialog() {
+ val input = androidx.appcompat.widget.AppCompatEditText(requireContext()).apply {
+ hint = getString(R.string.route_rule_json_paste_hint)
+ setSingleLine(false)
+ maxLines = 10
+ minLines = 5
+ setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 12f)
+ }
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.route_rule_json_paste_title)
+ .setView(input)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ val text = input.text?.toString()?.trim() ?: return@setPositiveButton
+ try {
+ val obj = JsonInput.parseValue(text) as? JSONObject
+ ?: throw IllegalArgumentException("not a JSON object")
+ val imported = mergeRuleFromJson(host.rule, obj)
+ DataStore.dirty = true
+ host.message(R.string.route_rule_json_paste_success,
+ getString(R.string.route_rule_json_paste_success, imported))
+ val frag = this@RuleFragment
+ frag.refreshState()
+ } catch (e: Exception) {
+ host.message(R.string.route_rule_invalid_title,
+ getString(R.string.route_rule_json_paste_error, e.message))
+ }
+ }
+ .setNegativeButton(android.R.string.cancel, null)
+ .show()
+ }
+
+ /** Merge fields from a sing-box rule JSON fragment into [rule]. Returns count of fields merged. */
+ private fun mergeRuleFromJson(rule: RouteRule, obj: JSONObject): Int {
+ var count = 0
+ fun setStr(key: String, setter: (String) -> Unit) {
+ if (obj.has(key)) { setter(obj.optString(key)); count++ }
+ }
+ fun setList(key: String, setter: (MutableList) -> Unit) {
+ if (obj.has(key)) {
+ val arr = obj.optJSONArray(key)
+ if (arr != null) {
+ setter((0 until arr.length()).map { arr.optString(it) }.filter { it.isNotBlank() }.toMutableList())
+ count++
+ }
+ }
+ }
+ fun setBool(key: String, setter: (Boolean) -> Unit) {
+ if (obj.has(key)) { setter(obj.optBoolean(key)); count++ }
+ }
+ fun setInt(key: String, setter: (Int) -> Unit) {
+ if (obj.has(key)) { setter(obj.optInt(key)); count++ }
+ }
+
+ setStr("logical_mode") { rule.logical_mode = it; DataStore.profileCacheStore.putString("logical_mode", it) }
+ setStr("balancer_mode") { rule.balancer_mode = it; DataStore.profileCacheStore.putString("balancer_mode", it) }
+ if (obj.has("network")) {
+ val netArr = obj.optJSONArray("network")
+ if (netArr != null) {
+ val vals = (0 until netArr.length()).map { netArr.optString(it) }.filter { it.isNotBlank() }
+ rule.network = vals.joinToString("\n")
+ DataStore.profileCacheStore.putString("network", rule.network)
+ count++
+ }
+ }
+ if (obj.has("protocol")) {
+ val protoArr = obj.optJSONArray("protocol")
+ if (protoArr != null) {
+ val vals = (0 until protoArr.length()).map { protoArr.optString(it) }.filter { it.isNotBlank() }
+ rule.protocol = vals.joinToString("\n")
+ DataStore.profileCacheStore.putString("protocol", rule.protocol)
+ count++
+ }
+ }
+ setStr("ip_version") { rule.ip_version = it; DataStore.profileCacheStore.putString("ip_version", it) }
+ setStr("strategy") { rule.strategy = it; DataStore.profileCacheStore.putString("strategy", it) }
+ setStr("reject_method") { rule.reject_method = it; DataStore.profileCacheStore.putString("reject_method", it) }
+ setStr("override_address") { rule.override_address = it; DataStore.profileCacheStore.putString("override_address", it) }
+ setStr("override_port") { rule.override_port = it; DataStore.profileCacheStore.putString("override_port", it) }
+ setStr("tls_spoof") { rule.tls_spoof = it; DataStore.profileCacheStore.putString("tls_spoof", it) }
+ setStr("tls_spoof_method") { rule.tls_spoof_method = it; DataStore.profileCacheStore.putString("tls_spoof_method", it) }
+ setStr("name") { rule.name = it; DataStore.profileCacheStore.putString("name", it) }
+ setStr("action") { rule.action = it; DataStore.profileCacheStore.putString("action", it) }
+ if (obj.has("outbound_id")) { val oid = obj.optLong("outbound_id"); rule.outbound_id = oid; DataStore.profileCacheStore.putString("outbound_id", oid.toString()); count++ }
+ setBool("invert") { rule.invert = it; DataStore.profileCacheStore.putBoolean("invert", it) }
+ setBool("no_drop") { rule.no_drop = it; DataStore.profileCacheStore.putBoolean("no_drop", it) }
+ setBool("ip_is_private") { rule.ip_is_private = it; DataStore.profileCacheStore.putBoolean("ip_is_private", it) }
+ setBool("source_ip_is_private") { rule.source_ip_is_private = it; DataStore.profileCacheStore.putBoolean("source_ip_is_private", it) }
+ setBool("sniff_override_dest") { rule.sniff_override_dest = it; DataStore.profileCacheStore.putBoolean("sniff_override_dest", it) }
+ setList("domain") { rule.domain = it; DataStore.profileCacheStore.putString("domain", it.joinToString("\n")) }
+ setList("domain_suffix") { rule.domain_suffix = it; DataStore.profileCacheStore.putString("domain_suffix", it.joinToString("\n")) }
+ setList("domain_keyword") { rule.domain_keyword = it; DataStore.profileCacheStore.putString("domain_keyword", it.joinToString("\n")) }
+ setList("domain_regex") { rule.domain_regex = it; DataStore.profileCacheStore.putString("domain_regex", it.joinToString("\n")) }
+ setList("ip_cidr") { rule.ip_cidr = it; DataStore.profileCacheStore.putString("ip_cidr", it.joinToString("\n")) }
+ setList("source_ip_cidr") { rule.source_ip_cidr = it; DataStore.profileCacheStore.putString("source_ip_cidr", it.joinToString("\n")) }
+ setList("port") { rule.port = it; DataStore.profileCacheStore.putString("port", it.joinToString("\n")) }
+ setList("port_range") { rule.port_range = it; DataStore.profileCacheStore.putString("port_range", it.joinToString("\n")) }
+ setList("source_port") { rule.source_port = it; DataStore.profileCacheStore.putString("source_port", it.joinToString("\n")) }
+ setList("source_port_range") { rule.source_port_range = it; DataStore.profileCacheStore.putString("source_port_range", it.joinToString("\n")) }
+ setList("process_name") { rule.process_name = it; DataStore.profileCacheStore.putString("process_name", it.joinToString("\n")) }
+ setList("process_path") { rule.process_path = it; DataStore.profileCacheStore.putString("process_path", it.joinToString("\n")) }
+ setList("process_path_regex") { rule.process_path_regex = it; DataStore.profileCacheStore.putString("process_path_regex", it.joinToString("\n")) }
+ setList("package_name") { rule.package_name = it; DataStore.profileCacheStore.putString("package_name", it.joinToString("\n")) }
+ setList("rule_set") { rule.rule_set = it; DataStore.profileCacheStore.putString("rule_set", it.joinToString("\n")) }
+ setList("inbound") { rule.inbound = it; DataStore.profileCacheStore.putString("inbound", it.joinToString("\n")) }
+ setList("wifi_ssid") { rule.wifi_ssid = it; DataStore.profileCacheStore.putString("wifi_ssid", it.joinToString("\n")) }
+ setList("wifi_bssid") { rule.wifi_bssid = it; DataStore.profileCacheStore.putString("wifi_bssid", it.joinToString("\n")) }
+ setList("sniffers") { rule.sniffers = it; DataStore.profileCacheStore.putString("sniffers", it.joinToString("\n")) }
+ setList("default_interface_address") { rule.default_interface_address = it; DataStore.profileCacheStore.putString("default_interface_address", it.joinToString("\n")) }
+ setList("dns_server") { rule.dns_server = it; DataStore.profileCacheStore.putString("dns_server", it.joinToString("\n")) }
+ setList("balancer_sticky_hash") { rule.balancer_sticky_hash = it; DataStore.profileCacheStore.putString("balancer_sticky_hash", it.joinToString("\n")) }
+ if (obj.has("rules")) {
+ val rulesArr = obj.optJSONArray("rules")
+ if (rulesArr != null) {
+ rule.rules_json = rulesArr.toString()
+ DataStore.profileCacheStore.putString("rules_json", rule.rules_json)
+ count++
+ }
+ }
+ if (obj.has("pool")) { rule.balancer_pool = obj.optInt("pool"); DataStore.profileCacheStore.putString("balancer_pool", rule.balancer_pool.toString()); count++ }
+ if (obj.has("pool_tolerance")) { rule.balancer_pool_tolerance = obj.optInt("pool_tolerance"); DataStore.profileCacheStore.putString("balancer_pool_tolerance", rule.balancer_pool_tolerance.toString()); count++ }
+ if (obj.has("mode")) {
+ val m = obj.optString("mode")
+ if (m == "round_robin") { rule.balancer_mode = "round_robin"; DataStore.profileCacheStore.putString("balancer_mode", "round_robin") }
+ if (m == "and" || m == "or") { rule.logical_mode = m; DataStore.profileCacheStore.putString("logical_mode", m) }
+ if (m.isNotBlank() && m != "round_robin" && m != "and" && m != "or") { rule.balancer_mode = m; DataStore.profileCacheStore.putString("balancer_mode", m) }
+ count++
+ }
+ return count
+ }
+
/** Shows the fields of the chosen action and counts the advanced fields that are set. */
fun refreshState() {
if (preferenceScreen == null) return
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/route/RouteRuleChecks.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/route/RouteRuleChecks.kt
index 5d89fb718e..4382571737 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/ui/route/RouteRuleChecks.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/route/RouteRuleChecks.kt
@@ -124,5 +124,6 @@ internal object RouteRuleChecks {
private val ACTION_OPTIONS = setOf(
"invert", "override_address", "override_port", "tls_spoof", "tls_spoof_method", "reject_method", "no_drop",
"sniff_override_dest", "strategy",
+ "logical_mode", "balancer_mode", "balancer_pool", "balancer_pool_tolerance",
)
}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/settings/DnsSettingsScreens.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/settings/DnsSettingsScreens.kt
index 60762ba676..bbc2586cbf 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/ui/settings/DnsSettingsScreens.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/settings/DnsSettingsScreens.kt
@@ -8,17 +8,22 @@ import io.nekohasekai.sagernet.R
import io.nekohasekai.sagernet.database.SettingValidators
import io.nekohasekai.sagernet.database.SettingsRegistry
import io.nekohasekai.sagernet.ktx.needReload
+import android.content.Intent
+import io.nekohasekai.sagernet.ui.dns.DnsServerListActivity
import moe.matsuri.nb4a.ui.EditConfigPreference
/** Routing Settings › DNS: the simple settings, predefined answers and the custom DNS object. */
class DnsSettingsFragment : SettingsScreenFragment(R.xml.settings_dns) {
override fun bind() {
- checkText(SettingsRegistry.REMOTE_DNS.key, R.string.invalid_dns_address) { it.isNotEmpty() }
- checkText(SettingsRegistry.DIRECT_DNS.key, R.string.invalid_dns_address) { it.isNotEmpty() }
+ // DNS Server Management
+ pref("dnsServerManagement")?.setOnPreferenceClickListener {
+ startActivity(Intent(context, DnsServerListActivity::class.java))
+ true
+ }
+ // remote_dns and direct_dns removed; DNS servers are now managed per-route via dns_server
checkText(SettingsRegistry.CORE_BOX_UNDERLYING_DNS.key, R.string.invalid_dns_address) { true }
reloadOn(
- SettingsRegistry.REMOTE_DNS_DISABLE_IPV6.key, SettingsRegistry.DIRECT_DNS_DISABLE_IPV6.key,
SettingsRegistry.DNS_FINAL_OUT.key, SettingsRegistry.ENABLE_DNS_ROUTING.key,
SettingsRegistry.FAKEIP_DISABLE_IPV6.key, SettingsRegistry.DNS_USE_HOSTS.key,
)
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/test/TestPanelController.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/test/TestPanelController.kt
index a31ba42ec9..fbf8944f2a 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/ui/test/TestPanelController.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/test/TestPanelController.kt
@@ -212,10 +212,14 @@ class TestPanelController(
wire()
applyExpanded()
// Posted: the listener runs inside the layout pass and the callback may re-layout the list.
- root.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> root.post(::reportHeight) }
+ root.addOnLayoutChangeListener(object : View.OnLayoutChangeListener {
+ override fun onLayoutChange(v: View?, l: Int, t: Int, r: Int, b: Int, oldL: Int, oldT: Int, oldR: Int, oldB: Int) {
+ root.post(Runnable { reportHeight() })
+ }
+ })
ViewCompat.setOnApplyWindowInsetsListener(root) { _, insets ->
bottomInset = insets.bars().bottom
- root.post(::reportHeight)
+ root.post(Runnable { reportHeight() })
insets
}
// Every frame: the stats bar and the FAB move without any layout of this hierarchy.
diff --git a/app/src/main/res/layout/layout_dns_server_list.xml b/app/src/main/res/layout/layout_dns_server_list.xml
new file mode 100644
index 0000000000..995584ab6a
--- /dev/null
+++ b/app/src/main/res/layout/layout_dns_server_list.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml
index c70f7064e5..340ca2b01d 100644
--- a/app/src/main/res/values/arrays.xml
+++ b/app/src/main/res/values/arrays.xml
@@ -646,4 +646,41 @@
- @string/warp_http3_only
- HTTP/2
+
+
+ - @string/route_rule_unset
+ - AND (all must match)
+ - OR (any matches)
+
+
+
+ - and
+ - or
+
+
+
+ - @string/route_rule_unset
+ - round_robin (load balance)
+ - least_test (fastest, default)
+
+
+
+ - round_robin
+ - least_test
+
+
+
+ - UDP
+ - TCP
+ - DoH (DNS over HTTPS)
+ - DoT (DNS over TLS)
+ - Gateway
+
+
+ - udp
+ - tcp
+ - https
+ - dot
+ - gate
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 841554ab3f..8b1deae1e8 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1758,4 +1758,47 @@
No camera: choose an image with a QR code
No app can receive the file
Please try again later
+
+ Logical Mode
+ AND (all conditions must match)
+ OR (any condition matches)
+ Nested Rules (JSON)
+ Paste nested rule JSON array for logical rules
+ Default Interface Address
+ One CIDR per line, e.g. 2000::/3
+ DNS Server
+ DNS server tag(s) to resolve domain names (one per line)
+ Balancer Mode
+ Pool Size
+ Number of nodes in rotation (default 3)
+ Pool Tolerance (ms)
+ Max delay difference to keep nodes in pool
+ Sticky Hash Keys
+ process, domain, source_ip, dest_ip, dest_port, none
+
+ Import Rule JSON
+ Paste a sing-box rule JSON fragment (logical rules, urltest balancer, etc.)
+ Invalid JSON: %s
+ Imported %d field(s) from JSON
+ Import from JSON…
+
+ DNS Servers
+ Add DNS Server
+ Edit DNS Server
+ Delete DNS Server
+ Server Info
+ Tag
+ Address
+ e.g. 8.8.8.8, tls://dns.google, https://dns.google/dns-query
+ Type
+ Advanced
+ Bind Interface
+ Detour
+ Client Subnet
+ Disable Cache
+ Disable Expire
+ IP is Private
+ Reject Expired
+ DNS Server Management
+ Manage custom DNS servers for route rules
diff --git a/app/src/main/res/xml/dns_server_edit.xml b/app/src/main/res/xml/dns_server_edit.xml
new file mode 100644
index 0000000000..afbe9ba5d3
--- /dev/null
+++ b/app/src/main/res/xml/dns_server_edit.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/route_rule_preferences.xml b/app/src/main/res/xml/route_rule_preferences.xml
index 3afba7530b..9714407c73 100644
--- a/app/src/main/res/xml/route_rule_preferences.xml
+++ b/app/src/main/res/xml/route_rule_preferences.xml
@@ -40,6 +40,24 @@
app:key="sniff_override_dest"
app:summary="@string/route_rule_override_destination_sum"
app:title="@string/route_rule_override_destination" />
+
+
+
+
@@ -170,6 +188,39 @@
app:dialogMessage="@string/route_rule_lines_hint"
app:key="wifi_bssid"
app:title="@string/route_rule_wifi_bssid" />
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/settings_dns.xml b/app/src/main/res/xml/settings_dns.xml
index 742d76ecbc..905ee3227e 100644
--- a/app/src/main/res/xml/settings_dns.xml
+++ b/app/src/main/res/xml/settings_dns.xml
@@ -3,26 +3,11 @@
-
-
-
-
+ app:key="dnsServerManagement"
+ app:summary="@string/dns_server_management_sum"
+ app:title="@string/dns_server_management" />
+
+