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" /> + +