diff --git a/.github/actions/android-build/action.yml b/.github/actions/android-build/action.yml index e399e28d..b2fa0001 100644 --- a/.github/actions/android-build/action.yml +++ b/.github/actions/android-build/action.yml @@ -1,8 +1,8 @@ name: Android build description: >- Shared build steps of ci.yml, preview.yml and release.yml: JDK 17, ThroneCore AAR + sing-box schema, routeprofiles - snapshot, Gradle, then ./gradlew . With sign=true, release builds are signed with the SIGNING_* secrets from - the job environment; the build fails without them. + snapshot, sing-box dashboard, Gradle, then ./gradlew . With sign=true, release builds are signed with the + SIGNING_* secrets from the job environment; the build fails without them. inputs: tasks: description: Gradle tasks to run @@ -31,6 +31,8 @@ runs: uses: ./.github/actions/throne-core - name: Fetch routeprofiles snapshot uses: ./.github/actions/routeprofiles + - name: Fetch sing-box dashboard + uses: ./.github/actions/sing-box-dashboard - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 with: diff --git a/.github/actions/sing-box-dashboard/action.yml b/.github/actions/sing-box-dashboard/action.yml new file mode 100644 index 00000000..1666b4e1 --- /dev/null +++ b/.github/actions/sing-box-dashboard/action.yml @@ -0,0 +1,27 @@ +name: Fetch sing-box dashboard +description: >- + Downloads the gh-pages build of SagerNet/sing-box-dashboard into app/src/main/assets/sb-dashboard/ (gitignored), the + dashboard the app unpacks for the core's api service (bg/SingBoxDashboard.kt) instead of the core downloading it. +runs: + using: composite + steps: + - name: Fetch sing-box dashboard + shell: bash + run: | + set -euo pipefail + DEST=app/src/main/assets/sb-dashboard + rm -rf "$DEST" + mkdir -p "$DEST" + curl -fsSL --retry 3 https://codeload.github.com/SagerNet/sing-box-dashboard/tar.gz/refs/heads/gh-pages \ + | tar -xz --strip-components=1 -C "$DEST" + # aapt leaves out dot files and directories starting with "_" without failing the build. + find "$DEST" -type f -name '.*' -delete + if [ -n "$(find "$DEST" -type d -name '_*')" ]; then + echo "::error::the sing-box dashboard has directories starting with _, which the APK would lack" + exit 1 + fi + if [ ! -s "$DEST/index.html" ]; then + echo "::error::the sing-box dashboard has no index.html" + exit 1 + fi + du -sh "$DEST" diff --git a/.gitignore b/.gitignore index 0c005cf8..28bcdd4a 100644 --- a/.gitignore +++ b/.gitignore @@ -14,9 +14,10 @@ local.properties *.keystore *.p12 -# generated by CI (.github/actions/routeprofiles, .github/actions/throne-core) +# generated by CI (.github/actions/routeprofiles, .github/actions/throne-core, .github/actions/sing-box-dashboard) /app/src/main/assets/routeprofiles/ /app/src/main/assets/schema/ +/app/src/main/assets/sb-dashboard/ # python bytecode __pycache__/ diff --git a/README.md b/README.md index dfa56596..8dc7d575 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ https://throneproj.github.io * Cloudflare WARP: registration (WireGuard or MASQUE identity) and a built-in WARP mode for routing * URL, IP / country and speed tests, with a bulk-test panel (live progress, latency histogram, fastest servers, sort / remove unavailable / connect to fastest) +* The sing-box dashboard built in (live traffic, connections, logs), already connected to the running core +* Xray configs with geoip: / geosite: rules: the data files are downloaded when a config first needs them * JSON editor with sing-box schema checking, completion and formatting * In-app updater for GitHub builds (stable or pre-release channel, verified downloads) * Home-screen widgets, and server switching from the notification 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 00000000..34d06ff9 --- /dev/null +++ b/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/13.json @@ -0,0 +1,747 @@ +{ + "formatVersion": 1, + "database": { + "version": 13, + "identityHash": "a90bfe80ec37b678ae8cfbf19c9c4d4f", + "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 '', `network_type_json` TEXT NOT NULL DEFAULT '[]', `network_is_expensive` INTEGER NOT NULL DEFAULT 0, 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": "networkTypeJson", + "columnName": "network_type_json", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'[]'" + }, + { + "fieldPath": "networkIsExpensive", + "columnName": "network_is_expensive", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "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": "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, 'a90bfe80ec37b678ae8cfbf19c9c4d4f')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/14.json b/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/14.json new file mode 100644 index 00000000..d843edad --- /dev/null +++ b/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/14.json @@ -0,0 +1,754 @@ +{ + "formatVersion": 1, + "database": { + "version": 14, + "identityHash": "ec244503adf62091f31709a9039b6aca", + "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 '', `network_type_json` TEXT NOT NULL DEFAULT '[]', `network_is_expensive` INTEGER NOT NULL DEFAULT 0, `package_name_regex_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": "networkTypeJson", + "columnName": "network_type_json", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'[]'" + }, + { + "fieldPath": "networkIsExpensive", + "columnName": "network_is_expensive", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "packageNameRegexJson", + "columnName": "package_name_regex_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": "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, 'ec244503adf62091f31709a9039b6aca')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index aa14766d..63864f20 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -390,7 +390,7 @@ android:exported="true" android:foregroundServiceType="systemExempted" android:icon="@drawable/ic_throne_tile" - android:label="@string/tile_title" + android:label="@string/app_name" android:permission="android.permission.BIND_QUICK_SETTINGS_TILE" android:process=":bg" tools:ignore="ForegroundServicePermission" diff --git a/app/src/main/java/io/nekohasekai/sagernet/Constants.kt b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt index e39642bc..e954d621 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/Constants.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt @@ -34,12 +34,12 @@ object Key { const val HIDE_FROM_RECENT_APPS = "hideFromRecentApps" const val PREVIEW_HINT_DISMISSED_VERSION = "previewHintDismissedVersion" const val GROUP_LAYOUT_MODE = "groupLayoutMode" - const val YACD_URL = "yacdURL" const val PROFILE_DIRTY = "profileDirty" const val PROFILE_ID = "profileId" const val PROFILE_GROUP = "profileGroup" const val PROFILE_CURRENT = "profileCurrent" + const val RUNNING_PROFILES = "runningProfiles" const val SERVER_CONFIG = "serverConfig" @@ -61,6 +61,7 @@ object Key { const val WIFI_PERMISSION_ASKED = "wifiPermissionAsked" const val SERVICE_ERROR = "serviceError" const val SERVICE_ERROR_DNS = "serviceErrorDns" + const val SERVICE_ERROR_GEO = "serviceErrorGeo" const val WEBDAV_SERVER = "webdavServer" const val WEBDAV_USERNAME = "webdavUsername" 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 4d33ccb2..4b4160a6 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt @@ -469,12 +469,13 @@ class BaseService { /** * 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]. + * offers the DNS settings with it when [dnsSettings], the Xray geo asset settings when [geoSettings]. */ - fun failRunner(message: String, dnsSettings: Boolean = false) { + fun failRunner(message: String, dnsSettings: Boolean = false, geoSettings: Boolean = false) { if (data.state != State.Stopping) { DataStore.serviceError = message DataStore.serviceErrorDns = dnsSettings + DataStore.serviceErrorGeo = geoSettings } stopRunner(false, message) } @@ -586,6 +587,7 @@ class BaseService { if (DataStore.serviceError.isNotEmpty()) { DataStore.serviceError = "" DataStore.serviceErrorDns = false + DataStore.serviceErrorGeo = false } data.changeState(State.Connecting) // startForeground before anything can stop the service (see the link above). @@ -601,6 +603,7 @@ class BaseService { preInit() proxy.init() DataStore.currentProfile = profile.id + DataStore.runningProfiles = proxy.config.involvedProfileIds.map { it.toString() } startProcesses() data.changeState(State.Connected) @@ -616,6 +619,12 @@ class BaseService { else getString(R.string.local_dns_failed, exc.servers), dnsSettings = true, ) + } catch (exc: XrayGeoAssets.DownloadException) { + failRunner( + if (exc.fetchable) getString(R.string.xray_geo_start_download_failed, exc.readableMessage) + else exc.readableMessage, + geoSettings = true, + ) } catch (exc: Throwable) { // gomobile surfaces Go errors as go.Universe$proxyerror: message only, no stack worth logging if (exc.javaClass.name.endsWith("proxyerror")) { @@ -623,7 +632,12 @@ class BaseService { } else { Logs.w(exc) } - failRunner("${getString(R.string.service_failed)} ${exc.readableMessage}") + val geoFailure = XrayGeoAssets.describeFailure(exc.readableMessage, profile.displayName()) + if (geoFailure != null) { + failRunner(geoFailure, geoSettings = true) + } else { + failRunner("${getString(R.string.service_failed)} ${exc.readableMessage}") + } } finally { data.connectingJob = null } diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/CoreRuntime.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/CoreRuntime.kt index 97d4e790..2fba23fd 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/CoreRuntime.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/CoreRuntime.kt @@ -4,6 +4,8 @@ import android.app.Application import go.Seq import io.nekohasekai.sagernet.BuildConfig import io.nekohasekai.sagernet.bg.test.TestEngine +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.outbound.json.JsonInput import io.throneproj.mobile.Instance import io.throneproj.mobile.LogSink import io.throneproj.mobile.Mobile @@ -15,6 +17,9 @@ import java.io.File // Loaded only in the :bg process: everything here pulls the ThroneCore AAR (libthrone.so) in. object CoreRuntime { + /** The core's working dir under filesDir, which relative paths of its config resolve against. */ + const val WORKING_DIR = "core" + private const val LOG_QUEUE_LINES = 1024 val platform: NativeInterface by lazy { NativeInterface() } @@ -38,12 +43,20 @@ object CoreRuntime { if (detached) TestEngine.onRunningClosed() } + /** + * The sing-box level lines must reach to be written: the box level-filters only its own console and hands every + * line to the sink, so the sink applies the running config's `log.level` (unset = trace, like sing-box). + */ + @Volatile + private var logLevel = Mobile.LogLevelTrace + fun setup(app: Application) { Seq.setContext(app) CoreLog.newSession() + logLevel = runCatching { levelOf(DataStore.logLevel) }.getOrDefault(Mobile.LogLevelWarn) Mobile.setup(SetupOptions().apply { basePath = app.filesDir.absolutePath - workingPath = File(app.filesDir, "core").absolutePath + workingPath = File(app.filesDir, WORKING_DIR).absolutePath tempPath = app.cacheDir.absolutePath logMaxLines = LOG_QUEUE_LINES debug = BuildConfig.DEBUG @@ -52,8 +65,24 @@ object CoreRuntime { Mobile.setLogSink(CoreLogSink) } + /** Follows the `log.level` of [coreConfig], the config the main instance is about to run. */ + fun applyLogLevel(coreConfig: String) { + logLevel = levelOf(JsonInput.parseObject(coreConfig).obj("log").string("level")) + } + + private fun levelOf(level: String): Int = when (level.trim().lowercase()) { + "panic" -> Mobile.LogLevelPanic + "fatal" -> Mobile.LogLevelFatal + "error" -> Mobile.LogLevelError + "warn", "warning" -> Mobile.LogLevelWarn + "info" -> Mobile.LogLevelInfo + "debug" -> Mobile.LogLevelDebug + else -> Mobile.LogLevelTrace + } + private object CoreLogSink : LogSink { override fun write(level: Int, message: String) { + if (level > logLevel) return CoreLog.write("[${levelName(level)}] $message") } diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/ProfileValidator.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/ProfileValidator.kt index 1fb9c383..0a1bdf8d 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/ProfileValidator.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/ProfileValidator.kt @@ -101,7 +101,7 @@ object ProfileValidator { xrayConf.remove("inbounds") return check(xrayConf.toCompact(), xray = true) { error -> // Left to fail at test time, where the missing geo asset is named. - if (error.contains("geoip.dat") || error.contains("geosite.dat")) Verdict.VALID + if (XrayGeoAssets.isGeoError(error)) Verdict.VALID else invalid("Invalid Xray ent ${outbound.name}: $error") } } @@ -121,6 +121,22 @@ object ProfileValidator { return check(conf.toCompact(), xray = false) { error -> invalid("Invalid ent ${outbound.name}: $error") } } + /** + * IsValid's core check of a custom Xray full config (generate.cpp:2493-2510), the config as Throne runs it (without + * its inbounds): the core's error, null when it passes. Geo asset errors are returned too, for the caller to name. + */ + fun xrayFullConfigError(config: String): String? { + val xrayConf = JsonInput.parseObjectOrNull(config)?.takeIf { it.isNotEmpty() } + ?: return "Custom Xray full config is not valid JSON" + xrayConf.remove("inbounds") + return try { + Mobile.checkXrayConfig(xrayConf.toCompact()) + null + } catch (e: Exception) { + e.message.orEmpty().ifEmpty { e.javaClass.simpleName } + } + } + private inline fun check(config: String, xray: Boolean, onError: (String) -> Verdict): Verdict { try { if (xray) Mobile.checkXrayConfig(config) else Mobile.checkConfig(config) diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt index 8124e82a..b404f984 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt @@ -133,6 +133,15 @@ class ServiceNotification( update() } + /** What a start waits for (an Xray geo asset download) under the title, [percent] -1 while unknown; null clears it. */ + suspend fun postStartProgress(text: String?, percent: Int) { + useBuilder { + it.setContentText(text) + if (text == null) it.setProgress(0, 0, false) else it.setProgress(100, percent.coerceAtLeast(0), percent < 0) + } + update() + } + suspend fun postConnected() { updateActions() useBuilder { it.priority = NotificationCompat.PRIORITY_LOW } diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/SingBoxDashboard.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/SingBoxDashboard.kt new file mode 100644 index 00000000..6a2a4cbc --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/SingBoxDashboard.kt @@ -0,0 +1,86 @@ +package io.nekohasekai.sagernet.bg + +import android.content.pm.PackageManager +import android.os.Build +import io.nekohasekai.sagernet.ktx.Logs +import io.nekohasekai.sagernet.ktx.app +import io.nekohasekai.sagernet.ktx.readableMessage +import io.nekohasekai.sagernet.outbound.config.ConfigGenerator +import java.io.File +import java.io.IOException + +/** + * The sing-box dashboard (SagerNet/sing-box-dashboard) that CI bundles into the assets (`sb-dashboard/`), unpacked into + * the api service's dashboard dir once per install of the app, like the desktop's SeedDashboard + * (mainwindow_system.cpp:438-455). The core serves a dir without its `.etag` file as user-provided and never downloads + * over it (service/api/dashboard.go). + */ +object SingBoxDashboard { + + private const val ASSETS = "sb-dashboard" + private const val INDEX = "index.html" + + private val coreDir: File get() = File(app.filesDir, CoreRuntime.WORKING_DIR) + private val dir: File get() = File(coreDir, ConfigGenerator.DASHBOARD_PATH) + + /** The app install the unpacked copy comes from. */ + private val stamp: File get() = File(coreDir, "${ConfigGenerator.DASHBOARD_PATH}.stamp") + + /** Changes with every install of the app, a reinstall of the same version included; an update ends the process. */ + private val installVersion: String by lazy { + val pm = app.packageManager + val info = if (Build.VERSION.SDK_INT >= 33) { + pm.getPackageInfo(app.packageName, PackageManager.PackageInfoFlags.of(0)) + } else { + pm.getPackageInfo(app.packageName, 0) + } + info.lastUpdateTime.toString() + } + + /** + * Blocking, and never fails the start: a failure is only logged, and the dashboard screen then finds no page to + * show. + */ + fun install() { + val temp = File(coreDir, "${ConfigGenerator.DASHBOARD_PATH}.unpack") + try { + val version = installVersion + if (File(dir, INDEX).isFile && stamp.readTextOrNull() == version) return + val files = assetFiles(ASSETS, "") + if (INDEX !in files) { + Logs.w("dashboard: this build bundles no sing-box dashboard") + return + } + temp.deleteRecursively() + for (path in files) { + val target = File(temp, path) + target.parentFile?.mkdirs() + app.assets.open("$ASSETS/$path").use { input -> target.outputStream().use { input.copyTo(it) } } + } + // Also replaces a copy the core downloaded itself, whose .etag would keep the core updating it. + dir.deleteRecursively() + if (!temp.renameTo(dir)) throw IOException("cannot rename ${temp.path} to ${dir.path}") + stamp.writeText(version) + Logs.i("dashboard: unpacked ${files.size} files") + } catch (e: Exception) { + Logs.w("dashboard: unpacking failed: ${e.readableMessage}") + temp.deleteRecursively() + } + } + + /** The files below [assetDir], as paths relative to it: an entry without children is a file. */ + private fun assetFiles(assetDir: String, prefix: String): List { + val out = ArrayList() + for (name in app.assets.list(assetDir).orEmpty()) { + val children = app.assets.list("$assetDir/$name").orEmpty() + if (children.isEmpty()) out.add(prefix + name) else out.addAll(assetFiles("$assetDir/$name", "$prefix$name/")) + } + return out + } + + private fun File.readTextOrNull(): String? = try { + readText() + } catch (e: IOException) { + null + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/XrayGeoAssets.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/XrayGeoAssets.kt new file mode 100644 index 00000000..9cd4fde9 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/XrayGeoAssets.kt @@ -0,0 +1,318 @@ +package io.nekohasekai.sagernet.bg + +import android.os.Process +import android.os.SystemClock +import android.text.format.Formatter +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.SettingsRegistry +import io.nekohasekai.sagernet.ktx.Logs +import io.nekohasekai.sagernet.ktx.app +import io.nekohasekai.sagernet.ktx.appHttpClient +import io.nekohasekai.sagernet.ktx.appRequestsViaProxy +import io.nekohasekai.sagernet.ktx.appUserAgent +import io.nekohasekai.sagernet.ktx.readableMessage +import io.nekohasekai.sagernet.ktx.serviceConnected +import io.nekohasekai.sagernet.outbound.json.JsonArray +import io.nekohasekai.sagernet.outbound.json.JsonInput +import io.nekohasekai.sagernet.outbound.json.JsonObject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.Request +import okhttp3.Response +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.util.concurrent.ConcurrentHashMap + +/** + * Xray's data files (geoip.dat, geosite.dat, `ext:` files) in the core's asset directory, the app's files dir + * (XRAY_LOCATION_ASSET, core/mobile/mobile.go). The desktop asks before fetching a missing one after a start or a test + * failed on it (handleXrayGeoAssetError, mainwindow_profile_lifecycle.cpp:97-170); here geoip.dat and geosite.dat are + * fetched without asking, before an Xray config that reads them starts or is tested, from xray_geoip_url / + * xray_geosite_url. Works in both processes: the settings screen downloads from the main one. + */ +object XrayGeoAssets { + + const val GEOIP = "geoip.dat" + const val GEOSITE = "geosite.dat" + + @JvmField + val FILES = listOf(GEOIP, GEOSITE) + + class Provider(@JvmField val name: String, @JvmField val geoip: String, @JvmField val geosite: String) + + /** XrayGeoAssetProviders (Const.hpp:77-98): both files must come from one provider; v2fly's geosite is dlc.dat. */ + @JvmField + val PROVIDERS = listOf( + Provider( + "Loyalsoldier (global / China)", + "https://github.com/Loyalsoldier/v2ray-rules-dat/raw/release/geoip.dat", + "https://github.com/Loyalsoldier/v2ray-rules-dat/raw/release/geosite.dat", + ), + Provider( + "Chocolate4U (Iran)", + "https://github.com/Chocolate4U/Iran-v2ray-rules/raw/release/geoip.dat", + "https://github.com/Chocolate4U/Iran-v2ray-rules/raw/release/geosite.dat", + ), + Provider( + "runetfreedom (Russia)", + "https://github.com/runetfreedom/russia-v2ray-rules-dat/raw/release/geoip.dat", + "https://github.com/runetfreedom/russia-v2ray-rules-dat/raw/release/geosite.dat", + ), + Provider( + "v2fly (upstream)", + "https://github.com/v2fly/geoip/releases/latest/download/geoip.dat", + "https://github.com/v2fly/domain-list-community/releases/latest/download/dlc.dat", + ), + ) + + /** [bytes] of [file] so far, of [total] (-1 when the server does not say). */ + class Progress(@JvmField val file: String, @JvmField val bytes: Long, @JvmField val total: Long) { + /** -1 while the size is unknown. */ + val percent: Int get() = if (total > 0) (bytes * 100 / total).toInt().coerceIn(0, 100) else -1 + } + + /** + * A data file that could not be had; the message names the file, the source and the reason. [fetchable] is false + * when no file involved has a source (an `ext:` file), so trying again or elsewhere cannot help. + */ + class DownloadException( + message: String, + cause: Throwable? = null, + @JvmField val fetchable: Boolean = true, + ) : IOException(message, cause) + + private const val TIMEOUT_SECONDS = 30L + private const val PROGRESS_INTERVAL_MS = 500L + private const val STALE_TEMP_MS = 60 * 60 * 1000L + private const val BUFFER_SIZE = 64 * 1024 + + /** geodata's prefixes that name a file (rule_parser.go); `geoip:` / `geosite:` read the default files. */ + private val EXT_PREFIXES = listOf("ext:", "ext-ip:", "ext-domain:", "ext-site:") + + /** The category of "failed to check code X from geosite.dat" (geodat_loader.go), as the desktop reads it. */ + private val CATEGORY = Regex("""code\s+(\S+)\s+from""") + + private val locks = ConcurrentHashMap() + + fun file(name: String): File = File(app.filesDir, name) + + /** Present and not empty. */ + fun installed(name: String): Boolean = file(name).let { it.isFile && it.length() > 0 } + + fun delete(name: String) { + file(name).delete() + } + + /** The configured source of [name]; an empty setting falls back to the default provider. */ + fun urlOf(name: String): String { + val setting = if (name == GEOIP) SettingsRegistry.XRAY_GEOIP_URL else SettingsRegistry.XRAY_GEOSITE_URL + val url = if (name == GEOIP) DataStore.xrayGeoipUrl else DataStore.xrayGeositeUrl + return url.trim().ifEmpty { setting.default } + } + + fun isValidUrl(url: String): Boolean = url.toHttpUrlOrNull() != null + + fun isGeoError(error: String): Boolean = error.contains(GEOIP) || error.contains(GEOSITE) + + /** + * The data files [xrayConfigs] read: every string, and every object key (`dns.hosts`), that is a geodata rule + * (rule_parser.go): `geoip:` needs geoip.dat, `geosite:` geosite.dat, `ext::` that file. + */ + fun needed(xrayConfigs: Collection): Set { + val files = LinkedHashSet() + for (config in xrayConfigs) { + if (!config.contains("geoip:") && !config.contains("geosite:") && !config.contains("ext")) continue + collect(JsonInput.parseObjectOrNull(config) ?: continue, files) + } + return files + } + + fun missing(files: Collection): Set = files.filterTo(LinkedHashSet()) { !installed(it) } + + private fun collect(value: Any?, files: MutableSet) { + when (value) { + is String -> assetOf(value)?.let(files::add) + is JsonObject -> for ((key, child) in value) { + assetOf(key)?.let(files::add) + collect(child, files) + } + + is JsonArray -> for (child in value) collect(child, files) + } + } + + private fun assetOf(rule: String): String? { + val r = rule.trimStart('!') + if (r.startsWith("geoip:")) return GEOIP + if (r.startsWith("geosite:")) return GEOSITE + val prefix = EXT_PREFIXES.firstOrNull { r.startsWith(it) } ?: return null + return r.substring(prefix.length).substringBefore(':', "").takeIf { it.isNotEmpty() } + } + + /** + * Fetches whatever of [files] is missing, one after the other; returns the files still missing, with the reason. + * Only geoip.dat and geosite.dat have a source. [abort] is polled while a file streams in. + */ + suspend fun ensure( + files: Collection, + abort: () -> Boolean = { false }, + onProgress: (suspend (Progress) -> Unit)? = null, + ): Map { + val failures = LinkedHashMap() + for (name in files) { + if (installed(name)) continue + try { + download(name, force = false, abort = abort, onProgress = onProgress) + } catch (e: DownloadException) { + failures[name] = e.readableMessage + } + } + return failures + } + + /** + * Downloads [name] from its source into place (a temp file of this process, then a rename), unless another caller + * of this process fetched it meanwhile and [force] is off. Through the mixed inbound when a core runs connected and + * app requests use the proxy, direct otherwise. Throws [DownloadException]. + */ + suspend fun download( + name: String, + force: Boolean = false, + abort: () -> Boolean = { false }, + onProgress: (suspend (Progress) -> Unit)? = null, + ): Unit = withContext(Dispatchers.IO) { + if (name != GEOIP && name != GEOSITE) { + throw DownloadException(app.getString(R.string.xray_geo_not_downloadable, name), fetchable = false) + } + locks.computeIfAbsent(name) { Mutex() }.withLock { + if (!force && installed(name)) return@withContext + val url = urlOf(name) + Logs.i("Downloading Xray geo asset $name from $url") + try { + fetch(name, url, abort, onProgress) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // A cancelled caller surfaces here as the IOException of its cancelled call. + currentCoroutineContext().ensureActive() + Logs.w("Failed to download Xray geo asset $name: ${e.readableMessage}") + throw DownloadException(app.getString(R.string.xray_geo_download_failed, name, url, e.readableMessage), e) + } + Logs.i("Downloaded Xray geo asset $name") + } + } + + private suspend fun fetch( + name: String, + url: String, + abort: () -> Boolean, + onProgress: (suspend (Progress) -> Unit)?, + ): Unit = coroutineScope { + val target = file(name) + // Per process: the settings screen and :bg may fetch the same file at once, each renames a complete copy. + val temp = File(target.parentFile, "$name.${Process.myPid()}.tmp") + sweepStaleTemps(name, temp) + val client = appHttpClient(viaProxy = appRequestsViaProxy() && serviceConnected(), timeoutSeconds = TIMEOUT_SECONDS) + val call = client.newCall(Request.Builder().url(url).header("User-Agent", appUserAgent()).build()) + // A blocking read ignores coroutine cancellation; cancelling the call ends it. + val watcher = launch(start = CoroutineStart.UNDISPATCHED) { + try { + awaitCancellation() + } finally { + call.cancel() + } + } + try { + call.execute().use { response -> + if (downgraded(response)) throw IOException(app.getString(R.string.subs_insecure_redirect)) + if (!response.isSuccessful) throw IOException(app.getString(R.string.xray_geo_http_status, response.code)) + val body = response.body ?: throw IOException(app.getString(R.string.xray_geo_empty_response)) + val total = body.contentLength() + var bytes = 0L + var reportedAt = 0L + FileOutputStream(temp).use { out -> + val input = body.byteStream() + val buffer = ByteArray(BUFFER_SIZE) + while (true) { + if (abort()) throw IOException(app.getString(R.string.xray_geo_aborted)) + val n = input.read(buffer) + if (n < 0) break + out.write(buffer, 0, n) + bytes += n + val now = SystemClock.elapsedRealtime() + if (onProgress != null && now - reportedAt >= PROGRESS_INTERVAL_MS) { + reportedAt = now + onProgress(Progress(name, bytes, total)) + } + } + out.fd.sync() + } + if (bytes == 0L) throw IOException(app.getString(R.string.xray_geo_empty_response)) + onProgress?.invoke(Progress(name, bytes, total)) + if (!temp.renameTo(target)) throw IOException(app.getString(R.string.xray_geo_save_failed, target.path)) + } + } finally { + watcher.cancel() + temp.delete() + } + } + + /** NoLessSafeRedirectPolicy (HTTPRequestHelper.cpp:124): a redirect chain must never leave https for http. */ + private fun downgraded(response: Response): Boolean { + var hop = response + while (true) { + val prior = hop.priorResponse ?: return false + if (prior.request.url.isHttps && !hop.request.url.isHttps) return true + hop = prior + } + } + + /** Temp files a killed process left behind; a live download keeps its file fresh. */ + private fun sweepStaleTemps(name: String, own: File) { + val now = System.currentTimeMillis() + own.parentFile?.listFiles { file -> + file != own && file.name.startsWith("$name.") && file.name.endsWith(".tmp") && + now - file.lastModified() > STALE_TEMP_MS + }?.forEach { it.delete() } + } + + fun progressText(progress: Progress): String = if (progress.percent >= 0) { + app.getString(R.string.xray_geo_downloading_percent, progress.file, progress.percent) + } else { + app.getString(R.string.xray_geo_downloading_bytes, progress.file, Formatter.formatShortFileSize(app, progress.bytes)) + } + + /** + * handleXrayGeoAssetError's diagnosis of an Xray error that mentions geoip.dat / geosite.dat, as a message for the + * config [contextName]: an installed file that lacks the category (named when the error carries it), else the file + * that is not installed. Null for any other error. + */ + fun describeFailure(error: String, contextName: String): String? { + val referenced = FILES.filter { error.contains(it) } + if (referenced.isEmpty()) return null + // "failed to open " (geodat_loader.go): Xray could not read it at all, installed or not. + val lacking = if (error.contains("failed to open")) null else referenced.lastOrNull { installed(it) } + if (lacking != null) { + val category = CATEGORY.find(error)?.groupValues?.get(1).orEmpty() + val needed = if (category.isEmpty()) { + app.getString(R.string.xray_geo_some_category) + } else { + "${lacking.removeSuffix(".dat")}:${category.lowercase()}" + } + return app.getString(R.string.xray_geo_missing_category, contextName, needed, lacking) + } + return app.getString(R.string.xray_geo_missing_file, contextName, referenced.joinToString(", ")) + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt index 766cb6ad..627ced41 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt @@ -2,15 +2,21 @@ package io.nekohasekai.sagernet.bg.proto import io.nekohasekai.sagernet.bg.AbstractInstance import io.nekohasekai.sagernet.bg.CoreRuntime +import io.nekohasekai.sagernet.bg.SingBoxDashboard +import io.nekohasekai.sagernet.bg.XrayGeoAssets import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.ProxyEntity import io.nekohasekai.sagernet.ktx.Logs +import io.nekohasekai.sagernet.ktx.isLoopbackPortFree import io.nekohasekai.sagernet.ktx.mkPort import io.nekohasekai.sagernet.outbound.config.AutoSelectorBuild import io.nekohasekai.sagernet.outbound.config.GeneratedConfig import io.throneproj.mobile.Instance import io.throneproj.mobile.Mobile import io.throneproj.mobile.StartOptions +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext abstract class BoxInstance( val profile: ProxyEntity @@ -29,6 +35,13 @@ abstract class BoxInstance( protected open fun buildConfig() { // random_inbound_port: a fresh port per start, saved so the app's own requests reach it (mainwindow_setup.cpp:193-196). if (DataStore.randomInboundPort) DataStore.inboundSocksPort = mkPort() + // The dashboard is on by default, so another app holding its port must not fail the start: it moves, and the + // dashboard screen follows the saved port. + if (DataStore.apiDashboardEnabled && !isLoopbackPortFree(DataStore.coreBoxApiPort)) { + val port = mkPort() + Logs.w("sing-box API port ${DataStore.coreBoxApiPort} is in use, the dashboard moves to $port") + DataStore.coreBoxApiPort = port + } config = CoreConfigs.buildMain(profile) } @@ -39,9 +52,38 @@ abstract class BoxInstance( open suspend fun init() { buildConfig() core = CoreConfig.from(config, listOf(CoreConfig.TAG_PROXY)) + CoreRuntime.applyLogLevel(config.coreConfig) + ensureXrayAssets() + // Before the start: the core downloads the dashboard itself when it finds the dir empty. + if (DataStore.apiDashboardEnabled) withContext(Dispatchers.IO) { SingBoxDashboard.install() } loadConfig() } + /** The geo asset downloads the start waits for; null once they are over. */ + protected open suspend fun onAssetProgress(progress: XrayGeoAssets.Progress?) {} + + /** + * Fetches the data files the Xray configs about to start read (geoip: / geosite: rules) and that are missing; + * throws [XrayGeoAssets.DownloadException] naming what could not be had. + */ + private suspend fun ensureXrayAssets() { + val configs = listOfNotNull(config.xrayConfig) + config.xrayFullConfigs + if (configs.isEmpty()) return + val missing = withContext(Dispatchers.IO) { XrayGeoAssets.missing(XrayGeoAssets.needed(configs)) } + if (missing.isEmpty()) return + val failures = try { + XrayGeoAssets.ensure(missing) { onAssetProgress(it) } + } finally { + withContext(NonCancellable) { runCatching { onAssetProgress(null) } } + } + if (failures.isNotEmpty()) { + throw XrayGeoAssets.DownloadException( + failures.values.joinToString("\n"), + fetchable = failures.keys.any { it in XrayGeoAssets.FILES }, + ) + } + } + override fun launch() { try { box.start() diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/ProxyInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/ProxyInstance.kt index a9c6848b..e09c96ec 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/ProxyInstance.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/ProxyInstance.kt @@ -2,6 +2,7 @@ package io.nekohasekai.sagernet.bg.proto import io.nekohasekai.sagernet.bg.BaseService import io.nekohasekai.sagernet.bg.ServiceNotification +import io.nekohasekai.sagernet.bg.XrayGeoAssets import io.nekohasekai.sagernet.bg.autoselector.AutoSelectorRuntime import io.nekohasekai.sagernet.database.ProxyEntity import io.nekohasekai.sagernet.database.SagerDatabase @@ -51,6 +52,10 @@ class ProxyInstance(profile: ProxyEntity, var service: BaseService.Interface? = super.init() } + override suspend fun onAssetProgress(progress: XrayGeoAssets.Progress?) { + service?.data?.notification?.postStartProgress(progress?.let(XrayGeoAssets::progressText), progress?.percent ?: -1) + } + override fun launch() { super.launch() // start box AutoSelectorRuntime.onStarted(this) diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/test/SpeedTestRunner.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/test/SpeedTestRunner.kt index 5c0b67ee..2370e8e1 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/test/SpeedTestRunner.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/test/SpeedTestRunner.kt @@ -37,15 +37,21 @@ internal class SpeedTestRunner(private val session: TestSession) { suspend fun run(profiles: List) { for (slice in profiles.chunked(if (countryOnly) TEST_BATCH_SIZE else 1)) { if (session.cancelled) break + val excluded = session.screenXrayFullConfigs(slice) + for (entry in excluded) session.reportSpeedUnmeasured(entry.profileId, entry.reason) + val left = excluded.mapTo(HashSet()) { it.profileId } + val ids = slice.map { it.id }.filter { it !in left } + if (ids.isEmpty()) continue val names = slice.associate { it.id to it.displayName() } - val generated = CoreConfigs.buildTest(slice.map { it.id }) + val generated = CoreConfigs.buildTest(ids) if (!generated.ok) { val error = generated.error ?: "config generation failed" Logs.w("Failed to build batch test config: $error") - slice.forEach { session.reportSpeedUnmeasured(it.id, error) } + ids.forEach { session.reportSpeedUnmeasured(it, error) } continue } for ((id, reason) in generated.skipped) session.reportSpeedUnmeasured(id, reason) + session.ensureSharedXrayAssets(generated.xrayConfig) for (probe in TestProbe.plan(generated)) { runProbe(probe.request(), probe.profileIds, probe::profileOf, names, null) } @@ -119,7 +125,9 @@ internal class SpeedTestRunner(private val session: TestSession) { if (failure != null) { Logs.w(failure) traffic.flush() - profileIds.forEach { session.reportSpeedUnmeasured(it, failure.readableMessage) } + profileIds.forEach { + session.reportSpeedUnmeasured(it, session.startFailure(names[it].orEmpty(), failure.readableMessage)) + } return@coroutineScope } for (result in results) report(result, profileOf, names, reported) diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/test/TestNotification.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/test/TestNotification.kt index dd9c732c..d83f3ca0 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/test/TestNotification.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/test/TestNotification.kt @@ -1,5 +1,6 @@ package io.nekohasekai.sagernet.bg.test +import android.annotation.SuppressLint import android.app.Notification import android.app.PendingIntent import android.content.BroadcastReceiver @@ -7,6 +8,7 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet @@ -14,6 +16,7 @@ import io.nekohasekai.sagernet.bg.CoreForeground import io.nekohasekai.sagernet.bg.proto.SpeedTestSnapshot import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.app +import io.nekohasekai.sagernet.utils.PlatformNotifications import kotlinx.coroutines.delay /** The progress notification of a running session (" · ", n / N, Stop), shown through [CoreForeground]. */ @@ -28,6 +31,10 @@ internal class TestNotification(private val session: TestSession) { @Volatile private var speedLine = "" + /** The geo asset download in flight, empty when none. */ + @Volatile + private var assetLine = "" + private var receiver: BroadcastReceiver? = null fun start() { @@ -41,6 +48,7 @@ internal class TestNotification(private val session: TestSession) { Logs.w(e) } CoreForeground.acquire(REASON, build()) + runCatching { NotificationManagerCompat.from(app).cancel(WARNING_TAG, WARNING_ID) } } fun changed() { @@ -55,6 +63,11 @@ internal class TestNotification(private val session: TestSession) { dirty = true } + fun asset(line: String) { + assetLine = line + dirty = true + } + /** Posts the latest state at most once per [REFRESH_MS] until cancelled. */ suspend fun refreshLoop() { while (true) { @@ -65,10 +78,28 @@ internal class TestNotification(private val session: TestSession) { } } - fun finish() { + /** Ends the progress; [assetProblems] (profiles left untested for a geo asset) stay behind as a warning. */ + fun finish(assetProblems: Collection) { receiver?.let { runCatching { app.unregisterReceiver(it) } } receiver = null CoreForeground.release(REASON) + if (assetProblems.isNotEmpty()) warnAssets(assetProblems) + } + + @SuppressLint("MissingPermission") + private fun warnAssets(problems: Collection) { + val text = problems.take(MAX_WARNED_PROBLEMS).joinToString("\n\n") + val notification = NotificationCompat.Builder(app, PlatformNotifications.CHANNEL_WARNINGS) + .setSmallIcon(R.drawable.ic_notification_warning) + .setContentTitle(app.getString(R.string.xray_geo_test_warning_title)) + .setContentText(text.substringBefore('\n')) + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .setContentIntent(SagerNet.configureIntent(app)) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .build() + runCatching { NotificationManagerCompat.from(app).notify(WARNING_TAG, WARNING_ID, notification) } + .onFailure { Logs.w(it) } } private fun build(): Notification { @@ -87,7 +118,7 @@ internal class TestNotification(private val session: TestSession) { session.kind == TestSpec.KIND_SPEED -> app.getString(R.string.test_engine_progress, done, total) else -> app.getString(R.string.test_engine_progress_counts, done, total, session.okCount, session.failedCount) } - val text = listOf(speedLine, progress).filter { it.isNotEmpty() }.joinToString("\n") + val text = listOf(assetLine, speedLine, progress).filter { it.isNotEmpty() }.joinToString("\n") val stop = PendingIntent.getBroadcast( app, 0, Intent(ACTION_STOP).setPackage(app.packageName), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, @@ -112,5 +143,8 @@ internal class TestNotification(private val session: TestSession) { const val REASON = "tests" private const val ACTION_STOP = "io.nekohasekai.sagernet.TEST_STOP" private const val REFRESH_MS = 1000L + private const val WARNING_TAG = "tests" + private const val WARNING_ID = 1 + private const val MAX_WARNED_PROBLEMS = 3 } } diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/test/TestProbe.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/test/TestProbe.kt index 43284492..2e471006 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/test/TestProbe.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/test/TestProbe.kt @@ -1,5 +1,7 @@ package io.nekohasekai.sagernet.bg.test +import io.nekohasekai.sagernet.bg.ProfileValidator +import io.nekohasekai.sagernet.bg.XrayGeoAssets import io.nekohasekai.sagernet.bg.proto.CoreConfig import io.nekohasekai.sagernet.bg.proto.CoreConfigs import io.nekohasekai.sagernet.bg.proto.applyTo @@ -8,16 +10,20 @@ import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.completeWith import io.nekohasekai.sagernet.ktx.readableMessage import io.nekohasekai.sagernet.outbound.config.GeneratedConfig +import io.nekohasekai.sagernet.outbound.types.Custom import io.throneproj.mobile.Mobile import io.throneproj.mobile.TestRequest import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext import java.util.concurrent.ConcurrentHashMap /** kTestBatchSize (TestRunner.cpp:21): profiles per test build. */ @@ -107,6 +113,65 @@ internal class ProbeResult( @JvmField val connectOnly: Boolean = false, ) +/** A candidate kept out of a test build: [invalid] when its config fails the core check, else it cannot be measured. */ +internal class Excluded(@JvmField val profileId: Long, @JvmField val reason: String, @JvmField val invalid: Boolean) + +/** + * What the build of [profiles] must leave out: the custom Xray full configs among them first get the geo assets their + * rules read, then IsValid's core check (generate.cpp:2493-2510), as one config that cannot start takes the whole + * probe box down with it. A missing asset leaves its profile unmeasured, never failed: it says nothing about the + * server, and failed results feed auto_clear_unavailable. + */ +internal suspend fun TestSession.screenXrayFullConfigs(profiles: List): List { + val candidates = profiles.mapNotNull { profile -> + (profile.outbound as? Custom)?.takeIf { it.isXrayFullConfig() }?.let { profile to it } + } + if (candidates.isEmpty() || cancelled) return emptyList() + val excluded = ArrayList() + val needs = withContext(Dispatchers.IO) { + candidates.associate { (profile, custom) -> profile.id to XrayGeoAssets.needed(listOf(custom.config)) } + } + val missing = withContext(Dispatchers.IO) { XrayGeoAssets.missing(needs.values.flatten().toSet()) } + val failures = if (missing.isEmpty()) emptyMap() else ensureAssets(missing) + val checked = candidates.filter { (profile, _) -> + val failure = needs[profile.id].orEmpty().firstNotNullOfOrNull { failures[it] } ?: return@filter true + excluded.add(Excluded(profile.id, failure, invalid = false)) + false + } + if (cancelled) return excluded + val permits = Semaphore(CHECK_PARALLELISM) + val verdicts = coroutineScope { + checked.map { (profile, custom) -> + async(Dispatchers.IO) { permits.withPermit { ProfileValidator.xrayFullConfigError(custom.config) } } + }.awaitAll() + } + for ((index, error) in verdicts.withIndex()) { + if (error == null) continue + val (profile, custom) = checked[index] + val geo = XrayGeoAssets.describeFailure(error, profile.displayName()) + if (geo != null) { + assetProblem(geo) + excluded.add(Excluded(profile.id, geo, invalid = false)) + } else { + excluded.add(Excluded(profile.id, "Invalid Xray ent ${custom.name}: $error", invalid = true)) + } + } + return excluded +} + +/** + * The probe box's own Xray config reads geo assets too when a group's front proxy is a custom Xray full config; a + * failure is left to the probe, whose start then fails and is split. + */ +internal suspend fun TestSession.ensureSharedXrayAssets(xrayConfig: String?) { + if (xrayConfig == null || cancelled) return + val missing = withContext(Dispatchers.IO) { XrayGeoAssets.missing(XrayGeoAssets.needed(listOf(xrayConfig))) } + if (missing.isNotEmpty()) ensureAssets(missing) +} + +/** Parallel core checks of Xray full configs (ProfileValidator's parallelism). */ +private const val CHECK_PARALLELISM = 4 + /** * runLatencyGroup (TestRunner.cpp:307-406) for URL and IP tests: batches of [TEST_BATCH_SIZE] profiles, one test build * each, its probes at most [MAX_PARALLEL_PROBES] at a time; results are reported in arrival order, a chunk per wake-up. @@ -132,25 +197,23 @@ internal abstract class LatencySweep(protected val session: TestSession) { } private suspend fun runBatch(batch: List) = coroutineScope { - val ids = batch.map { it.id } - val generated = CoreConfigs.buildTest(ids) - if (!generated.ok) { - val error = generated.error ?: "config generation failed" - Logs.w("Failed to build test config for batch: $error") - report(ids.map { ProbeResult(it, error = error, measured = false) }) - return@coroutineScope - } - if (generated.skipped.isNotEmpty()) { - report(generated.skipped.map { (id, reason) -> - if (isSkipReason(reason)) ProbeResult(id, error = reason, measured = false) else invalid(id, reason) + val excluded = session.screenXrayFullConfigs(batch) + if (excluded.isNotEmpty()) { + report(excluded.map { + if (it.invalid) invalid(it.profileId, it.reason) else ProbeResult(it.profileId, error = it.reason, measured = false) }) } + val left = excluded.mapTo(HashSet()) { it.profileId } + val ids = batch.map { it.id }.filter { it !in left } + if (ids.isEmpty()) return@coroutineScope val results = Channel(Channel.UNLIMITED) val gate = Semaphore(MAX_PARALLEL_PROBES) launch { - TestProbe.plan(generated).map { probe -> launch { gate.withPermit { runProbe(probe, batch, results) } } } - .joinAll() - results.close() + try { + runBuild(ids, batch, results, gate) + } finally { + results.close() + } } while (true) { val chunk = arrayListOf(results.receiveCatching().getOrNull() ?: break) @@ -159,22 +222,70 @@ internal abstract class LatencySweep(protected val session: TestSession) { } } - private suspend fun runProbe(probe: TestProbe, batch: List, results: Channel) { + /** + * One test build over [ids] and its probes. A probe of several profiles whose box cannot start (one config that + * fails takes the others down) is split in halves, each built and run on its own, down to the profile at fault; a + * probe waits for [gate] only while it runs, so the halves never wait on a permit their parent holds. + */ + private suspend fun runBuild( + ids: List, + batch: List, + results: Channel, + gate: Semaphore, + ) { + val generated = try { + CoreConfigs.buildTest(ids) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + GeneratedConfig.failure(e.readableMessage) + } + if (!generated.ok) { + val error = generated.error ?: "config generation failed" + Logs.w("Failed to build test config for batch: $error") + ids.forEach { results.trySend(ProbeResult(it, error = error, measured = false)) } + return + } + for ((id, reason) in generated.skipped) { + results.trySend(if (isSkipReason(reason)) ProbeResult(id, error = reason, measured = false) else invalid(id, reason)) + } + session.ensureSharedXrayAssets(generated.xrayConfig) + coroutineScope { + for (probe in TestProbe.plan(generated)) launch { + val failure = gate.withPermit { runProbe(probe, batch, results) } ?: return@launch + val profileIds = probe.profileIds + if (profileIds.size > 1 && !session.cancelled) { + Logs.w("Test probe of ${profileIds.size} profiles could not start, testing it in halves: $failure") + val half = (profileIds.size + 1) / 2 + launch { runBuild(profileIds.subList(0, half), batch, results, gate) } + launch { runBuild(profileIds.subList(half, profileIds.size), batch, results, gate) } + } else { + for (id in profileIds) { + val name = batch.firstOrNull { it.id == id }?.displayName().orEmpty() + results.trySend(ProbeResult(id, error = session.startFailure(name, failure), measured = false)) + } + } + } + } + } + + /** Runs [probe] and reports its profiles; the core's error, nothing reported, when its box could not start. */ + private suspend fun runProbe(probe: TestProbe, batch: List, results: Channel): String? { val reported: MutableSet = ConcurrentHashMap.newKeySet() val emit: (ProbeResult) -> Unit = { if (reported.add(it.profileId) || it.connectOnly) results.trySend(it) } if (session.cancelled) { probe.profileIds.forEach { emit(ProbeResult(it, error = ERROR_ABORTED, measured = false)) } - return + return null } try { session.awaitCore { done -> start(probe, batch, emit, done) } } catch (e: CancellationException) { throw e } catch (e: Exception) { - Logs.w(e) - probe.profileIds.forEach { emit(ProbeResult(it, error = e.readableMessage, measured = false)) } - return + Logs.w("Test probe failed to start: ${e.readableMessage}") + return e.readableMessage } probe.profileIds.forEach { emit(ProbeResult(it, error = ERROR_NO_RESULT, measured = false)) } + return null } } diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/test/TestSession.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/test/TestSession.kt index e75c8240..955485d7 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/test/TestSession.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/test/TestSession.kt @@ -5,6 +5,7 @@ import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SpeedTestSettings import io.nekohasekai.sagernet.aidl.ITestSessionCallback import io.nekohasekai.sagernet.bg.CoreRuntime +import io.nekohasekai.sagernet.bg.XrayGeoAssets import io.nekohasekai.sagernet.bg.proto.SpeedTestSnapshot import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.GroupRepo @@ -19,6 +20,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicInteger /** @@ -73,6 +75,12 @@ internal class TestSession( private val notification = TestNotification(this) + /** Geo asset downloads that failed in this session, by file: each is tried once per session. */ + private val assetFailures = ConcurrentHashMap() + + /** Why profiles were left untested for a geo asset, shown as a warning once the session ends. */ + private val assetProblems: MutableSet = ConcurrentHashMap.newKeySet() + /** stop(): the core cancels the tests in flight, the sweep starts nothing new. */ fun stop() { cancelled = true @@ -96,11 +104,49 @@ internal class TestSession( } catch (e: Exception) { Logs.w(e) } finally { - notification.finish() + notification.finish(assetProblems) notifyClient { it.onDone(id, cancelled) } } } + /** + * Downloads what of [files] is missing, the progress in the notification; returns the files still missing with the + * reason (a file that already failed in this session is not tried again). + */ + suspend fun ensureAssets(files: Collection): Map { + val failures = LinkedHashMap() + val pending = ArrayList() + for (file in files) { + val known = assetFailures[file] + if (known != null) failures[file] = known else pending.add(file) + } + if (pending.isEmpty()) return failures + val fresh = try { + XrayGeoAssets.ensure(pending, abort = { cancelled }) { notification.asset(XrayGeoAssets.progressText(it)) } + } finally { + notification.asset("") + } + for ((file, error) in fresh) { + if (cancelled) { + failures[file] = ERROR_ABORTED + } else { + assetFailures[file] = error + failures[file] = error + assetProblems.add(error) + } + } + return failures + } + + /** A profile left untested for a geo asset, for the warning at the end. */ + fun assetProblem(message: String) { + assetProblems.add(message) + } + + /** The core's error for a probe of [profileName] that could not start, a geo asset problem in words. */ + fun startFailure(profileName: String, error: String): String = + XrayGeoAssets.describeFailure(error, profileName)?.also(::assetProblem) ?: error + private suspend fun runProfiles() { val profiles = resolve() if (notification.scope.isBlank()) { diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt b/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt index 621b98c2..bfa51855 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt @@ -28,6 +28,9 @@ object DataStore : OnPreferenceDataStoreChangeListener { // last used, but may not be running var currentProfile by configurationStore.long(Key.PROFILE_CURRENT) + /** The involvedProfileIds of the config [currentProfile] last started with, for ProfileManager.runningUses. */ + var runningProfiles by configurationStore.stringList(Key.RUNNING_PROFILES) + var selectedProxy by configurationStore.long(Key.PROFILE_ID) // only in bg process @@ -54,6 +57,8 @@ object DataStore : OnPreferenceDataStoreChangeListener { var serviceError by configurationStore.string(Key.SERVICE_ERROR) /** [serviceError] asks for a working Direct DNS, so it offers the DNS settings instead of the logs. */ var serviceErrorDns by configurationStore.boolean(Key.SERVICE_ERROR_DNS) + /** [serviceError] is about Xray's geoip.dat / geosite.dat, so it offers the geo asset settings instead. */ + var serviceErrorGeo by configurationStore.boolean(Key.SERVICE_ERROR_GEO) var groupLayoutMode by configurationStore.stringToInt(Key.GROUP_LAYOUT_MODE) { 0 } var networkChangeResetConnections by configurationStore.boolean(Key.NETWORK_CHANGE_RESET_CONNECTIONS) { true } @@ -81,8 +86,6 @@ object DataStore : OnPreferenceDataStoreChangeListener { var individual by configurationStore.string(Key.INDIVIDUAL) var httpProxyBypass by configurationStore.string(Key.HTTP_PROXY_BYPASS) { "" } - var yacdURL by configurationStore.string(Key.YACD_URL) { "http://127.0.0.1:9090/ui" } - var webdavServer: String? get() = configurationStore.getString(Key.WEBDAV_SERVER) set(value) = configurationStore.putString(Key.WEBDAV_SERVER, value) @@ -261,8 +264,11 @@ object DataStore : OnPreferenceDataStoreChangeListener { } set(value) = SettingsRegistry.CORE_BOX_API_SECRET.write(configurationStore, value) + var coreBoxApiPort by SettingsRegistry.CORE_BOX_API_PORT var coreDnsInPort by SettingsRegistry.CORE_DNS_IN_PORT var xrayVlessPreference by SettingsRegistry.XRAY_VLESS_PREFERENCE + var xrayGeoipUrl by SettingsRegistry.XRAY_GEOIP_URL + var xrayGeositeUrl by SettingsRegistry.XRAY_GEOSITE_URL var skipCert by SettingsRegistry.SKIP_CERT var useMozillaCerts by SettingsRegistry.USE_MOZILLA_CERTS var enableNtp by SettingsRegistry.ENABLE_NTP @@ -274,6 +280,9 @@ object DataStore : OnPreferenceDataStoreChangeListener { /** core_box_clash_api is on: a positive port. */ val clashApiEnabled: Boolean get() = coreBoxClashApi > 0 + /** core_box_api_port is on: the sing-box API serves the dashboard on that loopback port. */ + val apiDashboardEnabled: Boolean get() = coreBoxApiPort > 0 + // ------------------------------------------------------------------------------------------------ old cache, DO NOT ADD var dirty by profileCacheStore.boolean(Key.PROFILE_DIRTY) diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt b/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt index 1f8bf00e..afd9947d 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt @@ -106,6 +106,16 @@ object ProfileManager { /** The profile the service runs (the desktop's started_id), 0 when stopped. */ fun runningProfileId(): Long = if (DataStore.serviceState.started) DataStore.currentProfile else 0L + /** + * The running config was built from [profileId]: the started profile, or one of the profiles it pulled in (chain + * hops, the group's landing / front proxy, auto-selector members, route outbounds), RunningUsesProfile. + */ + fun runningUses(profileId: Long): Boolean { + val running = runningProfileId() + if (running <= 0L || profileId <= 0L) return false + return profileId == running || profileId.toString() in DataStore.runningProfiles + } + // ------------------------------------------------------------------------------------------------ add /** ProfilesRepo::AddProfile: appended to group [groupId], the current group when it is not a group id (<= 0). */ @@ -157,11 +167,17 @@ object ProfileManager { } } - /** Stores only the profile data (type, name, outbound JSON): test results and traffic written meanwhile stay. */ - suspend fun updateOutbound(profile: ProxyEntity) { + /** + * Stores only the profile data (type, name, outbound JSON): test results and traffic written meanwhile stay. + * Returns whether that data changed. + */ + suspend fun updateOutbound(profile: ProxyEntity): Boolean { + val before = dao.getById(profile.id) dao.updateOutbound(profile.id, profile.type, profile.name, profile.outboundJson) - val stored = dao.getById(profile.id) ?: return + val stored = dao.getById(profile.id) ?: return false iterator { onUpdated(stored, false) } + return before == null || before.type != stored.type || before.name != stored.name || + before.outboundJson != stored.outboundJson } suspend fun updateTraffic(profileId: Long, rx: Long, tx: Long) { 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 e1ee76f6..e791577a 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/RouteRuleEntity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/RouteRuleEntity.kt @@ -9,8 +9,9 @@ import io.nekohasekai.sagernet.outbound.json.JsonValues import io.nekohasekai.sagernet.route.RouteRule /** - * The desktop's `route_rules` row (RoutesRepo.cpp:51-94) plus `package_name_json`. List members are compact JSON - * string arrays in the `_json` columns, like the desktop stores them. + * The desktop's `route_rules` row (RoutesRepo.cpp:51-94) plus the Android-only `network_type_json`, + * `network_is_expensive` and `package_name_regex_json`. List members are compact JSON string arrays in the + * `_json` columns, like the desktop stores them. */ @Entity( tableName = RouteRuleEntity.TABLE, @@ -64,6 +65,9 @@ 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 = "", + @ColumnInfo(name = "network_type_json", defaultValue = "[]") var networkTypeJson: String = "[]", + @ColumnInfo(name = "network_is_expensive", defaultValue = "0") var networkIsExpensive: Boolean = false, + @ColumnInfo(name = "package_name_regex_json", defaultValue = "[]") var packageNameRegexJson: String = "[]", ) { fun toModel(): RouteRule = RouteRule().also { @@ -104,6 +108,9 @@ data class RouteRuleEntity( it.wifi_bssid = listFromJson(wifiBssidJson) it.tls_spoof = tlsSpoof it.tls_spoof_method = tlsSpoofMethod + it.network_type = listFromJson(networkTypeJson) + it.network_is_expensive = networkIsExpensive + it.package_name_regex = listFromJson(packageNameRegexJson) } companion object { @@ -149,6 +156,9 @@ data class RouteRuleEntity( wifiBssidJson = listToJson(r.wifi_bssid), tlsSpoof = r.tls_spoof, tlsSpoofMethod = r.tls_spoof_method, + networkTypeJson = listToJson(r.network_type), + networkIsExpensive = r.network_is_expensive, + packageNameRegexJson = listToJson(r.package_name_regex), ) /** 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 e51657e6..694928b3 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt @@ -22,9 +22,11 @@ import kotlinx.coroutines.launch ProxyGroup::class, ProxyEntity::class, RouteProfileEntity::class, RouteRuleEntity::class, SettingEntry::class, MarkerEntity::class, ], - version = 12, + version = 14, autoMigrations = [ AutoMigration(from = 8, to = 9), + AutoMigration(from = 12, to = 13), + AutoMigration(from = 13, to = 14), ] ) @TypeConverters(value = [SubscriptionOptions.Converter::class]) diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/SettingsMapper.kt b/app/src/main/java/io/nekohasekai/sagernet/database/SettingsMapper.kt index 196774e8..5ae0e00d 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/SettingsMapper.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/SettingsMapper.kt @@ -106,6 +106,7 @@ object SettingsMapper { enableDnsRouting = DataStore.enableDnsRouting, enableTunRouting = DataStore.enableTunRouting, trafficStats = DataStore.enableStats, + apiPort = DataStore.coreBoxApiPort, apiSecret = DataStore.coreBoxApiSecret, clashApiEnabled = clashApi > 0, clashApiListen = DataStore.coreBoxClashListenAddr, diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/SettingsRegistry.kt b/app/src/main/java/io/nekohasekai/sagernet/database/SettingsRegistry.kt index 74abd98f..73f6324b 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/SettingsRegistry.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/SettingsRegistry.kt @@ -307,11 +307,19 @@ object SettingsRegistry { @JvmField val CORE_BOX_CLASH_API = int("core_box_clash_api", -9090) { it in -65535..65535 } @JvmField val CORE_BOX_CLASH_LISTEN_ADDR = string("core_box_clash_listen_addr", LOOPBACK_ADDRESS) { it.isNotBlank() } @JvmField val CORE_BOX_CLASH_API_SECRET = string("core_box_clash_api_secret", "") + /** + * The sing-box API / dashboard port, sign-encoded like core_box_clash_api (at most 0 is off). Android serves the + * dashboard by default, where it stands in for the desktop's stats panels (desktop -9091). + */ + @JvmField val CORE_BOX_API_PORT = int("core_box_api_port", 9091) { it in -65535..65535 } /** Generated on first use when empty (SettingsRepo.cpp:15-19). */ @JvmField val CORE_BOX_API_SECRET = string("core_box_api_secret", "") @JvmField val CORE_DNS_IN_PORT = int("core_dns_in_port", 5533, SettingValidators::isPort) /** 0 XHTTP only, 1 XHTTP and Reality, 2 all VLESS (Const.hpp:70-75). */ @JvmField val XRAY_VLESS_PREFERENCE = int("xray_vless_preference", 1) { it in 0..2 } + /** Where geoip.dat / geosite.dat come from when an Xray config needs them (SettingsRepo.h:305-307). */ + @JvmField val XRAY_GEOIP_URL = string("xray_geoip_url", "https://github.com/Loyalsoldier/v2ray-rules-dat/raw/release/geoip.dat") + @JvmField val XRAY_GEOSITE_URL = string("xray_geosite_url", "https://github.com/Loyalsoldier/v2ray-rules-dat/raw/release/geosite.dat") @JvmField val SKIP_CERT = bool("skip_cert", false) @JvmField val USE_MOZILLA_CERTS = bool("use_mozilla_certs", false) @JvmField val ENABLE_NTP = bool("enable_ntp", false) @@ -328,19 +336,24 @@ object SettingsRegistry { Key.SHOW_DIRECT_SPEED, Key.SHOW_GROUP_IN_NOTIFICATION, Key.NOTIFICATION_ACTIONS, Key.USE_SYSTEM_THEME, Key.APP_THEME, Key.NIGHT_THEME, Key.AMOLED_THEME, Key.APP_LANGUAGE, Key.ALWAYS_SHOW_ADDRESS, Key.GROUP_LAYOUT_MODE, - Key.HIDE_FROM_RECENT_APPS, Key.LOG_BUF_SIZE, Key.APP_TLS_VERSION, Key.YACD_URL, + Key.HIDE_FROM_RECENT_APPS, Key.LOG_BUF_SIZE, Key.APP_TLS_VERSION, Key.WEBDAV_SERVER, Key.WEBDAV_USERNAME, Key.WEBDAV_PASSWORD, Key.WEBDAV_PATH, - Key.PROFILE_CURRENT, Key.PROFILE_ID, Key.PREVIEW_HINT_DISMISSED_VERSION, + Key.PROFILE_CURRENT, Key.RUNNING_PROFILES, Key.PROFILE_ID, Key.PREVIEW_HINT_DISMISSED_VERSION, Key.UPDATE_CHECK_AUTO, Key.UPDATE_SKIPPED_VERSION_CODE, Key.RESUME_AFTER_UPDATE, Key.BATTERY_PROMPT_SHOWN, Key.LOG_EXPORT_REDACT, Key.HWID_FALLBACK, Key.WIFI_PERMISSION_ASKED, Key.SERVICE_ERROR, + Key.SERVICE_ERROR_DNS, Key.SERVICE_ERROR_GEO, ) - /** Keys a backup never exports and a restore never overwrites (R10 §8.3). */ + /** + * Keys a backup never exports and a restore never overwrites (R10 §8.3). core_box_api_port is one: the desktop + * keeps it off by default and Android on, so neither side's backup should flip the other's dashboard. + */ @JvmField val DEVICE_LOCAL_KEYS: Set = setOf( Key.WEBDAV_SERVER, Key.WEBDAV_USERNAME, Key.WEBDAV_PASSWORD, Key.WEBDAV_PATH, Key.BATTERY_PROMPT_SHOWN, Key.HWID_FALLBACK, Key.RESUME_AFTER_UPDATE, Key.UPDATE_SKIPPED_VERSION_CODE, - Key.WIFI_PERMISSION_ASKED, Key.SERVICE_ERROR, + Key.WIFI_PERMISSION_ASKED, Key.SERVICE_ERROR, Key.SERVICE_ERROR_DNS, Key.SERVICE_ERROR_GEO, + Key.RUNNING_PROFILES, CORE_BOX_API_PORT.key, ) // ------------------------------------------------------------------------------------------------ lookup diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/backup/BackupRestore.kt b/app/src/main/java/io/nekohasekai/sagernet/database/backup/BackupRestore.kt index 891def9d..d08d8fc9 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/backup/BackupRestore.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/backup/BackupRestore.kt @@ -269,12 +269,59 @@ object BackupRestore { val before = rules.rows.size if (owner >= 0) rules.rows.removeAll { it[owner] !in ids } if (rules.rows.size < before) warnings.add(app.getString(R.string.backup_warn_orphan_rules, before - rules.rows.size)) + if (owner >= 0) { + skipUnsupportedRules(bak, main, rules, warnings) + // RoutesRepo numbers each profile's rules 0..n-1: close the gaps of skipped rows and of the backup itself. + val order = rules.index("rule_order") + val next = HashMap() + for (row in rules.rows) { + val n = next[row[owner]] ?: 0L + row[order] = n + next[row[owner]] = n + 1 + } + } val raw = profiles.index("is_raw").takeIf { it >= 0 }?.let { i -> profiles.rows.count { (it[i] as? Long ?: 0L) != 0L } } ?: 0 if (raw > 0) warnings.add(app.getString(R.string.backup_warn_raw_routes, raw)) staged.routeProfiles = profiles staged.routeRules = rules } + /** + * A backup column Android lacks is a rule field it does not support: a rule with a value there (anything but + * NULL, '', '[]', 0 or '0') is skipped whole, since restoring it without that field would widen what it matches. + */ + private fun skipUnsupportedRules(bak: SQLiteDatabase, main: SupportSQLiteDatabase, rules: SqlTable, warnings: MutableList) { + val known = main.tableColumns(ROUTE_RULES).mapTo(HashSet()) { it.name } + val unknown = bak.tableColumns(ROUTE_RULES).map { it.name }.filter { it !in known } + if (unknown.isEmpty()) return + val hasValue = unknown.map { sqlName(it) }.map { "($it IS NOT NULL AND $it NOT IN ('', '[]', 0, '0'))" } + val offending = HashMap, List>() + bak.rawQuery( + "SELECT `route_profile_id`, `rule_order`, ${hasValue.joinToString(",")} FROM `$ROUTE_RULES` " + + "WHERE ${hasValue.joinToString(" OR ")}", + null + ).use { c -> + while (c.moveToNext()) offending[c.value(0) to c.value(1)] = unknown.filterIndexed { i, _ -> c.getLong(i + 2) != 0L } + } + val owner = rules.index("route_profile_id") + val order = rules.index("rule_order") + val used = HashSet() + val before = rules.rows.size + rules.rows.removeAll { row -> + val columns = offending[row[owner] to row[order]] ?: return@removeAll false + used.addAll(columns) + true + } + val skipped = before - rules.rows.size + if (skipped > 0) { + val fields = unknown.filter { it in used }.joinToString(", ") + warnings.add(app.getString(R.string.backup_warn_unsupported_rules, skipped, fields)) + } + } + + /** A backup's own column name as an SQL identifier; it is data, so a backtick in it is escaped. */ + private fun sqlName(name: String): String = "`" + name.replace("`", "``") + "`" + private fun stageSettings(bak: SQLiteDatabase, main: SupportSQLiteDatabase, staged: Staged, warnings: MutableList) { require(bak, SETTINGS) val rows = LinkedHashMap() 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 a3a877f9..18cc27b6 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 @@ -4,6 +4,7 @@ package io.nekohasekai.sagernet.database.backup * The tables of a fresh desktop `throne.db` (Throne @ 31a6562b), created in DatabaseManager.cpp:65-128 order: * `entity_ids` (DatabaseManager.cpp:83-110), ProfilesRepo.cpp:17-43, GroupsRepo.cpp:19-56, RoutesRepo.cpp:13-106, * OtpProfilesRepo.cpp:10-29, SettingsRepo.cpp:222-229, MarkersRepo.cpp:8-15. Update together with the desktop. + * `route_rules` ends with Android's own rule columns, which only an Android restore reads. */ object DesktopSchema { @@ -138,6 +139,9 @@ object DesktopSchema { tls_spoof TEXT, tls_spoof_method TEXT, package_name_json TEXT, + network_type_json TEXT, + network_is_expensive INTEGER NOT NULL DEFAULT 0, + package_name_regex_json TEXT, 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/ktx/Nets.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/Nets.kt index 444476f1..2cf6a322 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ktx/Nets.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Nets.kt @@ -17,10 +17,12 @@ import okhttp3.Response import okhttp3.Route import okhttp3.TlsVersion import okio.Buffer +import java.io.IOException import java.net.Authenticator import java.net.InetSocketAddress import java.net.PasswordAuthentication import java.net.Proxy +import java.net.ServerSocket import java.net.Socket import java.security.SecureRandom import java.security.cert.X509Certificate @@ -202,6 +204,51 @@ fun httpGet(url: String, options: HttpGetOptions = HttpGetOptions()): HttpGetRes } } +/** + * A core runs connected. The :bg process asks its own service; the main process keeps the state the service + * connection reported. + */ +fun serviceConnected(): Boolean = DataStore.baseService?.data?.state?.connected ?: DataStore.serviceState.connected + +/** + * A client for app requests that stream their body: [httpGet]'s setup (net_insecure, app_tls_version, the mixed inbound + * as an HTTP proxy when [viaProxy] and it exists) with OkHttp following redirects itself. Blocking calls. + */ +fun appHttpClient(viaProxy: Boolean, timeoutSeconds: Long = 30): OkHttpClient { + val builder = OkHttpClient.Builder() + .connectTimeout(timeoutSeconds, TimeUnit.SECONDS) + .readTimeout(timeoutSeconds, TimeUnit.SECONDS) + .writeTimeout(timeoutSeconds, TimeUnit.SECONDS) + if (viaProxy && !DataStore.mixedInboundDisabled) { + val address = DataStore.inboundAddress.let { if (it == "::") LOCALHOST else it } + builder.proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(address, DataStore.inboundSocksPort))) + if (DataStore.inboundAuth) { + val credential = Credentials.basic(DataStore.inboundUser, DataStore.inboundPass) + builder.proxyAuthenticator(object : okhttp3.Authenticator { + override fun authenticate(route: Route?, response: Response): Request? { + if (response.request.header("Proxy-Authorization") != null) return null + return response.request.newBuilder().header("Proxy-Authorization", credential).build() + } + }) + } + } + if (DataStore.appTLSVersion == "1.3") { + builder.connectionSpecs( + listOf( + ConnectionSpec.Builder(ConnectionSpec.RESTRICTED_TLS).tlsVersions(TlsVersion.TLS_1_3).build(), + ConnectionSpec.CLEARTEXT, + ) + ) + } + if (DataStore.netInsecure) { + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(null, arrayOf(TrustAllManager), SecureRandom()) + builder.sslSocketFactory(sslContext.socketFactory, TrustAllManager) + builder.hostnameVerifier { _, _ -> true } + } + return builder.build() +} + /** The body, or null once it exceeds [maxBytes] (0 = no cap). */ private fun readCapped(response: Response, maxBytes: Long): ByteArray? { val body = response.body ?: return ByteArray(0) @@ -249,4 +296,15 @@ fun mkPort(): Int { return port } +/** Whether a listener could bind [port] on loopback now; SO_REUSEADDR like the core's, so TIME_WAIT does not count. */ +fun isLoopbackPortFree(port: Int): Boolean = try { + ServerSocket().use { + it.reuseAddress = true + it.bind(InetSocketAddress(LOCALHOST, port)) + } + true +} catch (_: IOException) { + false +} + const val USER_AGENT = "Throne/Android/" + BuildConfig.VERSION_NAME diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/Utils.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/Utils.kt index 394030e4..9448ea19 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ktx/Utils.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Utils.kt @@ -180,6 +180,14 @@ fun Fragment.needReload() { } } +fun ThemedActivity.needReload() { + if (DataStore.serviceState.started) { + snackbar(getString(R.string.need_reload)).setAction(R.string.apply) { + SagerNet.reloadService() + }.show() + } +} + fun Fragment.needRestart() { snackbar(R.string.need_restart).setAction(R.string.apply) { triggerFullRestart(requireContext()) diff --git a/app/src/main/java/io/nekohasekai/sagernet/outbound/config/BuildState.kt b/app/src/main/java/io/nekohasekai/sagernet/outbound/config/BuildState.kt index 7124ddb3..a94e5e06 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/outbound/config/BuildState.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/outbound/config/BuildState.kt @@ -71,6 +71,9 @@ internal class Prerequisites { var needProxyDnsRules = false val proxyDns = DomainSelectors() + /** The direct / proxy rules that depend on the network, in rule order: (DNS server tag, network conditions, sites). */ + val conditionalDns = ArrayList>() + /** The `ip:` values of the route -> direct rules; they bypass the tun only with enable_tun_routing. */ val directIpCidrs = ArrayList() diff --git a/app/src/main/java/io/nekohasekai/sagernet/outbound/config/ConfigGenerator.kt b/app/src/main/java/io/nekohasekai/sagernet/outbound/config/ConfigGenerator.kt index 4139e4bb..111a6436 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/outbound/config/ConfigGenerator.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/outbound/config/ConfigGenerator.kt @@ -21,7 +21,7 @@ import io.nekohasekai.sagernet.route.RuleType * * Not generated on Android (desktop-only or out of scope): raw route profiles, the `hijack` / `hijack-dns` * inbounds, route_exclude_address_set, TLS spoof, extra cores, auxiliary VPN endpoints and the OpenVPN / - * OpenConnect tunnel DNS servers, the L3 bridge, the api dashboard and Tailscale profiles. + * OpenConnect tunnel DNS servers, the L3 bridge and Tailscale profiles. */ class ConfigGenerator @JvmOverloads constructor( private val profiles: ProfileProvider, @@ -185,6 +185,12 @@ class ConfigGenerator @JvmOverloads constructor( val proxySites = route.proxySites() parseDomainSelectors(proxySites, pre.proxyDns) pre.needProxyDnsRules = proxySites.isNotEmpty() + for (entry in route.conditionalSites()) { + val selectors = DomainSelectors() + parseDomainSelectors(entry.sites, selectors) + val server = if (entry.outbound == OutboundIds.DIRECT) Tags.DNS_DIRECT else Tags.DNS_REMOTE + pre.conditionalDns.add(Triple(server, entry.conditions, selectors)) + } } for (id in listOf(frontProxyId, landingProxyId)) { @@ -789,6 +795,12 @@ class ConfigGenerator @JvmOverloads constructor( // Below the fakeip rule, which keeps answering A / AAAA for these sites as on the desktop (R6 §3.5). val pre = state.prerequisites + // Ahead of the site lists, so on its network a rule's DNS server wins the way its route does. + for ((server, conditions, selectors) in pre.conditionalDns) { + if (state.forTest && server == Tags.DNS_REMOTE) continue + val disableIPv6 = if (server == Tags.DNS_DIRECT) buildContext.directDnsDisableIpv6 else settings.remoteDnsDisableIpv6 + appendDnsRoutingRules(rules, selectors, server, disableIPv6, conditions) + } if (pre.needDirectDnsRules) appendDnsRoutingRules(rules, pre.directDns, Tags.DNS_DIRECT, buildContext.directDnsDisableIpv6) // A test box builds no dns-remote server at all, so its fall-through goes out direct. @@ -826,19 +838,26 @@ class ConfigGenerator @JvmOverloads constructor( // The desktop never stores a malformed duration (dialog_manage_routes.cpp:230-237) and the core rejects one. private fun validDuration(text: String): String = text.trim().takeIf { isValidDuration(it) } ?: "" - /** appendDnsRoutingRules (:386-399): one rule-set rule and one inline rule that always carries all four keys. */ - private fun appendDnsRoutingRules(rules: JsonArray, selectors: DomainSelectors, server: String, disableIPv6: Boolean) { + /** + * appendDnsRoutingRules (:386-399): one rule-set rule and one inline rule that always carries all four keys, both + * narrowed by the route rule's network [conditions] if it has any. + */ + private fun appendDnsRoutingRules( + rules: JsonArray, selectors: DomainSelectors, server: String, disableIPv6: Boolean, conditions: JsonObject = JsonObject(), + ) { if (selectors.ruleSets.isNotEmpty()) { - appendDnsRoute(rules, jsonObjectOf("rule_set" to selectors.ruleSets), server, disableIPv6) + appendDnsRoute(rules, conditions.copy().merge(jsonObjectOf("rule_set" to selectors.ruleSets)), server, disableIPv6) } if (selectors.hasInlineConditions()) { appendDnsRoute( rules, - jsonObjectOf( - "domain" to selectors.domains, - "domain_suffix" to selectors.suffixes, - "domain_keyword" to selectors.keywords, - "domain_regex" to selectors.regexes, + conditions.copy().merge( + jsonObjectOf( + "domain" to selectors.domains, + "domain_suffix" to selectors.suffixes, + "domain_keyword" to selectors.keywords, + "domain_regex" to selectors.regexes, + ) ), server, disableIPv6, ) @@ -913,7 +932,9 @@ class ConfigGenerator @JvmOverloads constructor( /** * get_route_rules(false, outboundMap) (RouteProfile.cpp:593-628) with get_rule_json (RouteRule.cpp:82-190): simple * rules without a condition are skipped and the adblock reject goes in front of the first `route` rule, else last. - * Endpoint rules are skipped (no auxiliary endpoints on Android) and rule-level TLS spoof is dropped (D8). + * Endpoint rules are skipped (no auxiliary endpoints on Android), rule-level TLS spoof is dropped (D8) and a rule + * whose apps include unidentified ones, or that has both apps and package regexes, becomes a logical rule + * ([RouteRule.toConfigJson]). */ private fun getRouteRules(state: BuildState, route: RouteProfile): JsonArray { val out = JsonArray() @@ -922,7 +943,7 @@ class ConfigGenerator @JvmOverloads constructor( val type = RuleType.ofId(rule.type) if (type == RuleType.ENDPOINT_PREFERRED_BY) continue if (type != RuleType.CUSTOM && rule.isEmpty()) continue - val json = rule.toRuleJson(false, state.prerequisites.outboundMap[rule.outbound_id]) + val json = rule.toConfigJson(state.prerequisites.outboundMap[rule.outbound_id]) if (json.isEmpty()) { state.error = "Aborted generating routing section, an error has occurred" return out @@ -979,12 +1000,27 @@ class ConfigGenerator @JvmOverloads constructor( state.coreConfig["experimental"] = experimental } - /** buildServicesSection (:2160-2185): the core builds the traffic tracker from the mere presence of an api service. */ + /** + * buildServicesSection (:2186-2212): the core builds the traffic tracker from the mere presence of an api service; + * with a port it also serves the sing-box dashboard, the copy the app bundles and unpacks into [DASHBOARD_PATH] + * before the start, like the desktop. + */ private fun buildServicesSection(state: BuildState) { - if (state.forTest || !settings.trafficStats) return - state.coreConfig["services"] = JsonArray.of( - jsonObjectOf("type" to "api", "listen" to "127.0.0.1", "listen_port" to 0, "secret" to settings.apiSecret), + if (state.forTest) return + val dashboard = settings.apiPort > 0 + if (!dashboard && !settings.trafficStats) return + val api = jsonObjectOf( + "type" to "api", + "listen" to "127.0.0.1", + "listen_port" to if (dashboard) settings.apiPort else 0, + "secret" to settings.apiSecret, ) + if (dashboard) { + // Defaults to "*", i.e. any page the user visits could reach loopback. + api["access_control_allow_origin"] = JsonArray.of("http://127.0.0.1:${settings.apiPort}") + api["dashboard"] = jsonObjectOf("enabled" to true, "path" to DASHBOARD_PATH) + } + state.coreConfig["services"] = JsonArray.of(api) } /** buildXrayConfig (:2189-2221): one socks inbound and routing rule per Xray ingress, no dns object. */ @@ -1025,6 +1061,12 @@ class ConfigGenerator @JvmOverloads constructor( } companion object { + /** + * apiDashboardDir (generate.h:10-11), the api service's `dashboard.path` relative to the core's working dir; + * not the Clash external_ui dir, which holds a different UI. + */ + const val DASHBOARD_PATH = "sb-dashboard" + private val SELECTOR_PREFIXES = listOf("ruleset:", "domain:", "suffix:", "keyword:", "regex:", "ip:") private val DURATION = Regex("^(?:\\d+(?:\\.\\d+)?(?:ns|us|ms|s|m|h|d))+$") diff --git a/app/src/main/java/io/nekohasekai/sagernet/outbound/config/GeneratorSettings.kt b/app/src/main/java/io/nekohasekai/sagernet/outbound/config/GeneratorSettings.kt index acf31718..eaf34909 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/outbound/config/GeneratorSettings.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/outbound/config/GeneratorSettings.kt @@ -114,7 +114,12 @@ data class GeneratorSettings( val enableTunRouting: Boolean = false, /** enable_stats (SettingsRepo.h:118): route.find_process and the api service. */ val trafficStats: Boolean = true, - /** core_box_api_secret (SettingsRepo.h:295): the api service's secret. */ + /** + * core_box_api_port (SettingsRepo.h:298), sign-encoded: > 0 is the loopback port the api service serves the + * dashboard on, otherwise the service is only there for the stats. Android defaults to 9091 (on). + */ + val apiPort: Int = -9091, + /** core_box_api_secret (SettingsRepo.h:299): the api service's secret. */ val apiSecret: String = "", // ---- experimental (SettingsRepo.h:290-292) diff --git a/app/src/main/java/io/nekohasekai/sagernet/route/RouteProfile.kt b/app/src/main/java/io/nekohasekai/sagernet/route/RouteProfile.kt index 336df4c8..c770ce97 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/route/RouteProfile.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/route/RouteProfile.kt @@ -1,5 +1,7 @@ package io.nekohasekai.sagernet.route +import io.nekohasekai.sagernet.outbound.json.JsonObject + /** * A RouteProfile (include/database/entities/RouteProfile.h). [id] 0 means not saved yet. The desktop's raw profile * ([is_raw], [raw_route], [prevent_modifications]) and endpoint lists are carried verbatim so backups give them @@ -64,11 +66,28 @@ class RouteProfile { fun proxySites(): List = sites(OutboundIds.PROXY) - /** get_direct_ips (RouteProfile.cpp:745-759). */ + /** A direct or proxy route rule that applies only on some networks: its [RouteRule.networkConditions] and sites. */ + class ConditionalSites(val outbound: Long, val conditions: JsonObject, val sites: List) + + /** The direct and proxy route rules that apply only on some networks, in rule order, for DNS rules that follow them. */ + fun conditionalSites(): List { + val out = ArrayList() + for (rule in rules) { + if (rule.outbound_id != OutboundIds.DIRECT && rule.outbound_id != OutboundIds.PROXY) continue + if (rule.action != "route" || rule.invert || rule.matchesByApp()) continue + val conditions = rule.networkConditions() + if (conditions.isEmpty()) continue + val sites = ruleSites(rule) + if (sites.isNotEmpty()) out.add(ConditionalSites(rule.outbound_id, conditions, sites)) + } + return out + } + + /** get_direct_ips (RouteProfile.cpp:745-759) over the [unconditional] rules only. */ fun directIps(): List { val out = ArrayList() for (rule in rules) { - if (rule.outbound_id != OutboundIds.DIRECT || rule.action != "route") continue + if (rule.outbound_id != OutboundIds.DIRECT || rule.action != "route" || !unconditional(rule)) continue for (entry in rule.rule_set) { val e = entry.trim() if (e.startsWith("geoip-")) out.add("ruleset:$e") @@ -90,23 +109,37 @@ class RouteProfile { return out } - /** get_direct_sites / get_proxy_sites (RouteProfile.cpp:697-743). */ + /** get_direct_sites / get_proxy_sites (RouteProfile.cpp:697-743) over the [unconditional] rules only. */ private fun sites(outbound: Long): List { val out = ArrayList() for (rule in rules) { - if (rule.outbound_id != outbound || rule.action != "route") continue - for (entry in rule.rule_set) { - val e = entry.trim() - if (e.startsWith("geosite-")) out.add("ruleset:$e") - } - addPrefixed(out, "domain:", rule.domain) - addPrefixed(out, "suffix:", rule.domain_suffix) - addPrefixed(out, "keyword:", rule.domain_keyword) - addPrefixed(out, "regex:", rule.domain_regex) + if (rule.outbound_id != outbound || rule.action != "route" || !unconditional(rule)) continue + out.addAll(ruleSites(rule)) } return out } + private fun ruleSites(rule: RouteRule): List { + val out = ArrayList() + for (entry in rule.rule_set) { + val e = entry.trim() + if (e.startsWith("geosite-")) out.add("ruleset:$e") + } + addPrefixed(out, "domain:", rule.domain) + addPrefixed(out, "suffix:", rule.domain_suffix) + addPrefixed(out, "keyword:", rule.domain_keyword) + addPrefixed(out, "regex:", rule.domain_regex) + return out + } + + /** + * Whether the rule's sites and addresses may become DNS rules and tun routes that hold on every network for + * every app. Unlike the desktop, inverted rules (their values are what they do NOT match), app rules and + * network-dependent rules are left out; the route rule alone decides for them. + */ + private fun unconditional(rule: RouteRule): Boolean = + !rule.invert && !rule.matchesByApp() && rule.networkConditions().isEmpty() + private fun addPrefixed(out: MutableList, prefix: String, values: List) { for (value in values) { val v = value.trim() 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 dac8b74d..8528d3ce 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/route/RouteRule.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/route/RouteRule.kt @@ -3,12 +3,15 @@ package io.nekohasekai.sagernet.route import io.nekohasekai.sagernet.outbound.QtStrings import io.nekohasekai.sagernet.outbound.json.JsonArray import io.nekohasekai.sagernet.outbound.json.JsonObject +import io.nekohasekai.sagernet.outbound.json.JsonValues +import io.nekohasekai.sagernet.outbound.json.jsonObjectOf import kotlin.reflect.KMutableProperty1 /** * RouteRule (include/database/entities/RouteRule.h, src/database/entities/RouteRule.cpp). Members carry the desktop * member names so preference bindings, backups and the desktop columns line up; [package_name] is the Android - * addition (desktop column `package_name_json`, rule key `package_name`). + * addition the desktop also stores (column `package_name_json`, rule key `package_name`). [package_name_regex], + * [network_type] and [network_is_expensive] exist only on Android: the desktop drops a rule carrying them on import. */ @Suppress("PropertyName") class RouteRule { @@ -33,7 +36,13 @@ class RouteRule { @JvmField var process_name: MutableList = mutableListOf() @JvmField var process_path: MutableList = mutableListOf() @JvmField var process_path_regex: MutableList = mutableListOf() + /** The apps the rule matches; [UNKNOWN_PACKAGE] also matches connections whose app is not identified. */ @JvmField var package_name: MutableList = mutableListOf() + /** Go regexes on the package name; an app matching one counts as listed in [package_name]. */ + @JvmField var package_name_regex: MutableList = mutableListOf() + /** [NETWORK_TYPES] values the current default network must have. */ + @JvmField var network_type: MutableList = mutableListOf() + @JvmField var network_is_expensive: Boolean = false @JvmField var wifi_ssid: MutableList = mutableListOf() @JvmField var wifi_bssid: MutableList = mutableListOf() @JvmField var rule_set: MutableList = mutableListOf() @@ -109,6 +118,9 @@ class RouteRule { putStrings(obj, "process_path", process_path) putStrings(obj, "process_path_regex", process_path_regex) putStrings(obj, "package_name", package_name) + putStrings(obj, "package_name_regex", package_name_regex) + putStrings(obj, "network_type", network_type) + if (network_is_expensive) obj["network_is_expensive"] = true putStrings(obj, "wifi_ssid", wifi_ssid) putStrings(obj, "wifi_bssid", wifi_bssid) val ruleSets = JsonArray() @@ -152,6 +164,52 @@ class RouteRule { return obj } + /** + * The rule as the core takes it. Apps and package regexes are one condition, any of them matching, where sing-box + * would require both package_name and package_name_regex; and it cannot match "no identified app", which becomes + * an inverted `.*` package_name_regex (true only while no package is known). So a rule with both, or with + * [UNKNOWN_PACKAGE] in Apps, becomes a logical `or` of those parts and-ed with the rule's other conditions; + * invert then applies to the whole. + */ + fun toConfigJson(outboundTag: String?): JsonObject { + val flat = toRuleJson(false, outboundTag) + val apps = (flat["package_name"] as? JsonArray)?.strings().orEmpty() + val regexes = (flat["package_name_regex"] as? JsonArray)?.strings().orEmpty() + val packages = apps.filter { it != UNKNOWN_PACKAGE } + val unknown = UNKNOWN_PACKAGE in apps + if (!unknown && (packages.isEmpty() || regexes.isEmpty())) return flat + flat.remove("package_name") + flat.remove("package_name_regex") + val invert = flat.remove("invert") == true + val logical = jsonObjectOf("type" to "logical", "mode" to "and") + val own = JsonObject() + for ((key, value) in flat) if (key in ACTION_KEYS) logical[key] = value else own[key] = value + val appRules = JsonArray() + if (packages.isNotEmpty()) appRules.add(jsonObjectOf("package_name" to JsonValues.stringArray(packages))) + if (regexes.isNotEmpty()) appRules.add(jsonObjectOf("package_name_regex" to JsonValues.stringArray(regexes))) + if (unknown) appRules.add(jsonObjectOf("package_name_regex" to JsonArray.of(".*"), "invert" to true)) + val appMatch = if (appRules.size == 1) appRules[0] else jsonObjectOf("type" to "logical", "mode" to "or", "rules" to appRules) + val rules = JsonArray() + if (own.isNotEmpty()) rules.add(own) + rules.add(appMatch) + logical["rules"] = rules + if (invert) logical["invert"] = true + return logical + } + + /** Whether the match depends on the connection's app, which DNS rules and tun routes cannot follow. */ + fun matchesByApp(): Boolean = !blank(package_name) || !blank(package_name_regex) + + /** The conditions on the current network (type, metering, Wi-Fi) as rule keys; DNS rules take the same keys. */ + fun networkConditions(): JsonObject { + val obj = JsonObject() + putStrings(obj, "network_type", network_type) + if (network_is_expensive) obj["network_is_expensive"] = true + putStrings(obj, "wifi_ssid", wifi_ssid) + putStrings(obj, "wifi_bssid", wifi_bssid) + return obj + } + /** RouteRule::isEmpty (RouteRule.cpp:586-604), judged on the effective action. */ fun isEmpty(): Boolean { val t = RuleType.ofId(type) @@ -207,6 +265,9 @@ class RouteRule { l("process_path", process_path) l("process_path_regex", process_path_regex) l("package_name", package_name) + l("package_name_regex", package_name_regex) + l("network_type", network_type) + b("network_is_expensive", network_is_expensive) l("wifi_ssid", wifi_ssid) l("wifi_bssid", wifi_bssid) l("rule_set", rule_set) @@ -266,13 +327,26 @@ class RouteRule { RouteRule::inbound, RouteRule::domain, RouteRule::domain_suffix, RouteRule::domain_keyword, 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::process_path, RouteRule::process_path_regex, RouteRule::package_name, + RouteRule::package_name_regex, RouteRule::network_type, RouteRule::wifi_ssid, RouteRule::wifi_bssid, + RouteRule::rule_set, RouteRule::sniffers, ) val BOOL_FIELDS: List> = listOf( RouteRule::source_ip_is_private, RouteRule::ip_is_private, RouteRule::invert, RouteRule::no_drop, - RouteRule::sniff_override_dest, + RouteRule::sniff_override_dest, RouteRule::network_is_expensive, + ) + + /** The package_name entry for connections whose app is not identified (no owner, or a uid without a package). */ + const val UNKNOWN_PACKAGE = "unknown" + + /** sing-box's network_type values (constant/network.go). */ + val NETWORK_TYPES = listOf("wifi", "cellular", "ethernet", "other") + + /** Rule JSON keys that configure the action rather than narrow the match. */ + private val ACTION_KEYS = setOf( + "action", "outbound", "reject_method", "no_drop", "override_address", "override_port", "tls_spoof", + "tls_spoof_method", "override_destination", "strategy", ) private val ADDRESS_ATTRIBUTES = setOf("domain", "domain_suffix", "domain_keyword", "domain_regex", "rule_set", "ip_cidr") diff --git a/app/src/main/java/io/nekohasekai/sagernet/route/RouteShare.kt b/app/src/main/java/io/nekohasekai/sagernet/route/RouteShare.kt index e1d63780..587273ae 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/route/RouteShare.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/route/RouteShare.kt @@ -7,7 +7,7 @@ import io.nekohasekai.sagernet.outbound.json.JsonValues import io.nekohasekai.sagernet.outbound.json.JsonWriter import io.nekohasekai.sagernet.outbound.link.Base64Strict -/** The desktop's route share formats (RouteProfile.cpp:395-580). */ +/** The desktop's route share formats (RouteProfile.cpp:438-627). */ object RouteShare { const val ROUTE_LINK_PREFIX = "throne://route/" const val REMOTE_ROUTE_LINK_PREFIX = "throne://remoteroute/" @@ -16,11 +16,21 @@ object RouteShare { /** Keys whose numbers the desktop writes (port lists, ip_version, override_port) and must read back (D12). */ private val NUMERIC_KEYS = setOf("port", "source_port", "ip_version", "override_port") + /** The keys a rule object may carry: name, type and outbound besides the ones [setField] reads. */ + private val RULE_KEYS = setOf( + "name", "type", "outbound", "action", "ip_version", "network", "protocol", "inbound", "domain", "domain_suffix", + "domain_keyword", "domain_regex", "source_ip_cidr", "source_ip_is_private", "ip_cidr", "ip_is_private", + "source_port", "source_port_range", "port", "port_range", "process_name", "process_path", "process_path_regex", + "package_name", "package_name_regex", "network_type", "network_is_expensive", "wifi_ssid", "wifi_bssid", + "rule_set", "invert", "method", "reject_method", "no_drop", "override_address", "override_port", "tls_spoof", + "tls_spoof_method", "override_destination", "strategy", "sniffers", + ) + class Imported(val profile: RouteProfile?, val fatal: String, val warnings: List, val legacyArray: Boolean) class RemoteEntry(val url: String, val name: String) - /** ToShareObject (RouteProfile.cpp:395-440) for a structured profile; rules whose server is missing are left out. */ + /** ToShareObject (RouteProfile.cpp:438-483) for a structured profile; rules whose server is missing are left out. */ fun toShareObject(p: RouteProfile, profileName: (Long) -> String?): JsonObject { val root = JsonObject() root["kind"] = KIND @@ -47,9 +57,10 @@ object RouteShare { ROUTE_LINK_PREFIX + Base64Strict.encode(JsonWriter.write(toShareObject(p, profileName)), urlSafe = true, padding = false) /** - * FromShareInput (RouteProfile.cpp:448-539): a throne://route link (any case), share JSON, base64url or standard + * FromShareInput (RouteProfile.cpp:491-586): a throne://route link (any case), share JSON, base64url or standard * base64 of it with or without padding, or a legacy rule array. Raw profiles are refused; endpoints and endpoint - * rules are dropped with a warning (D11). Rule outbound names resolve through [profileIdByName]. + * rules are dropped with a warning (D11), as are rules with fields Android does not support ([parseRuleObject]). + * Rule outbound names resolve through [profileIdByName]. */ fun fromShareInput(text: String, profileIdByName: (String) -> Long?): Imported { var input = text.trim() @@ -71,7 +82,7 @@ object RouteShare { } /** - * FromRemoteRoutesLink (RouteProfile.cpp:541-580): null when [text] is not a throne://remoteroute link; for an + * FromRemoteRoutesLink (RouteProfile.cpp:588-627): null when [text] is not a throne://remoteroute link; for an * invalid one an [IllegalArgumentException] carries the desktop's error text, so a returned list is never empty. */ fun fromRemoteRoutesLink(text: String): List? { @@ -112,7 +123,7 @@ object RouteShare { profile.default_outbound_id = OutboundIds.fromName(root.string("default_outbound")) ?: OutboundIds.PROXY if (root.array("endpoints").isNotEmpty()) warnings.add("endpoints are not supported on Android and were dropped") var fallbackNum = 1 - for (value in root.array("rules")) { + for ((i, value) in root.array("rules").withIndex()) { if (value !is JsonObject) continue val type = RuleType.ofToken(value.string("type")) val name = value.string("name") @@ -120,7 +131,7 @@ object RouteShare { warnings.add("endpoint rule \"$name\" dropped: endpoints are not supported on Android") continue } - val rule = parseRuleObject(value, warnings, profileIdByName) + val rule = parseRuleObject(value, name.ifEmpty { "#${i + 1}" }, warnings, profileIdByName) ?: continue rule.type = type.id rule.name = name.ifEmpty { "rule_" + fallbackNum++ } profile.rules.add(rule) @@ -128,31 +139,50 @@ object RouteShare { return Imported(profile, "", warnings, false) } - /** parseJsonArray (RouteProfile.cpp:224-245): the legacy bare rule array; every rule is custom. */ + /** + * parseJsonArray (RouteProfile.cpp:258-288): the legacy bare rule array; every rule is custom. An array is nothing + * but its rules, so one whose rules are all dropped fails like an empty one. + */ private fun fromLegacyArray(arr: JsonArray, warnings: MutableList, profileIdByName: (String) -> Long?): Imported { if (arr.isEmpty()) return failure("Input is not a valid json array") val profile = RouteProfile() var ruleId = 1 - for (item in arr) { + for ((i, item) in arr.withIndex()) { if (item !is JsonObject) return failure("expected array of json objects but have member of type '${qtJsonType(item)}'") - val rule = parseRuleObject(item, warnings, profileIdByName) val name = item.string("name") + val rule = parseRuleObject(item, name.ifEmpty { "#${i + 1}" }, warnings, profileIdByName) ?: continue rule.name = name.ifEmpty { "imported rule #" + ruleId++ } profile.rules.add(rule) } + if (profile.rules.isEmpty()) return failure("No rule in the array can be imported:\n" + warnings.joinToString("\n")) return Imported(profile, "", warnings, true) } - /** parse_rule_object (RouteProfile.cpp:190-222) plus the D12 fixes: numbers, warp-bypass/block names, reject_method. */ - private fun parseRuleObject(obj: JsonObject, warnings: MutableList, profileIdByName: (String) -> Long?): RouteRule { + /** + * parse_rule_object (RouteProfile.cpp:215-256) plus the D12 fixes: numbers, warp-bypass/block names, reject_method. + * Import policy: a rule with a key outside [RULE_KEYS] or a network_type sing-box does not know is dropped whole, + * with a warning naming it by [label], and null returned: importing it without that field would widen its match. + */ + private fun parseRuleObject( + obj: JsonObject, + label: String, + warnings: MutableList, + profileIdByName: (String) -> Long?, + ): RouteRule? { + val keys = obj.sortedKeys() + keys.firstOrNull { it !in RULE_KEYS }?.let { + warnings.add("rule \"$label\" dropped: unsupported field \"$it\"") + return null + } val rule = RouteRule() - for (key in obj.sortedKeys()) { + val notes = ArrayList() + for (key in keys) { if (key == "name" || key == "type") continue when (val value = obj[key]) { is JsonArray -> if (key != "outbound") setField(rule, key, value.map { itemText(key, it) }) is String -> if (key == "outbound") { rule.outbound_id = OutboundIds.fromName(value) ?: profileIdByName(value) ?: run { - warnings.add("outbound \"$value\" not found, using proxy") + notes.add("outbound \"$value\" not found, using proxy") OutboundIds.PROXY } } else { @@ -165,7 +195,7 @@ object RouteShare { rule.outbound_id = when (id) { OutboundIds.PROXY, OutboundIds.DIRECT, OutboundIds.BLOCK, OutboundIds.WARP_BYPASS -> id else -> { - warnings.add("outbound id $id not found, using proxy") + notes.add("outbound id $id not found, using proxy") OutboundIds.PROXY } } @@ -176,6 +206,11 @@ object RouteShare { else -> {} } } + rule.network_type.firstOrNull { it !in RouteRule.NETWORK_TYPES }?.let { + warnings.add("rule \"$label\" dropped: unsupported network_type \"$it\"") + return null + } + warnings.addAll(notes) return rule } @@ -191,7 +226,7 @@ object RouteShare { else -> "" } - /** set_field_value (RouteRule.cpp:472-584); `reject_method` is accepted besides the desktop's `method`. */ + /** set_field_value (RouteRule.cpp:480-600) plus Android's package_name_regex, network_type and network_is_expensive. */ private fun setField(rule: RouteRule, key: String, values: List) { val scalar = values.firstOrNull()?.trim() ?: "" val list = values.map { it.trim() }.filterTo(ArrayList()) { it.isNotEmpty() } @@ -216,8 +251,12 @@ object RouteShare { "process_path" -> rule.process_path = list "process_path_regex" -> rule.process_path_regex = list "package_name" -> rule.package_name = list + "package_name_regex" -> rule.package_name_regex = list + "network_type" -> rule.network_type = list + "network_is_expensive" -> rule.network_is_expensive = scalar == "true" "wifi_ssid" -> rule.wifi_ssid = list "wifi_bssid" -> rule.wifi_bssid = list + "sniffers" -> rule.sniffers = list "rule_set" -> rule.rule_set = list "invert" -> rule.invert = scalar == "true" "action" -> rule.action = scalar diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/AppListActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/AppListActivity.kt index cc958498..a9673879 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/AppListActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/AppListActivity.kt @@ -27,6 +27,7 @@ import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.databinding.LayoutAppListBinding import io.nekohasekai.sagernet.databinding.LayoutAppsItemBinding import io.nekohasekai.sagernet.ktx.crossFadeFrom +import io.nekohasekai.sagernet.route.RouteRule import io.nekohasekai.sagernet.utils.PackageCache import io.nekohasekai.sagernet.widget.applyListInsets import kotlinx.coroutines.Dispatchers @@ -44,20 +45,28 @@ class AppListActivity : ThemedActivity() { companion object { const val EXTRA_PACKAGES = "packages" + private const val EXTRA_UNKNOWN_ENTRY = "unknownEntry" private const val SWITCH = "switch" private const val STATE_SELECTED = "selected" private const val STATE_SYSTEM_APPS = "systemApps" } - class Contract : ActivityResultContract, List?>() { + /** [unknownEntry] lists [RouteRule.UNKNOWN_PACKAGE] first, as the row for connections without a known app. */ + class Contract(private val unknownEntry: Boolean = false) : ActivityResultContract, List?>() { override fun createIntent(context: Context, input: List) = Intent(context, AppListActivity::class.java).putStringArrayListExtra(EXTRA_PACKAGES, ArrayList(input)) + .putExtra(EXTRA_UNKNOWN_ENTRY, unknownEntry) override fun parseResult(resultCode: Int, intent: Intent?): List? = if (resultCode == RESULT_OK) intent?.getStringArrayListExtra(EXTRA_PACKAGES) else null } - private class AppItem(val packageName: String, val info: ApplicationInfo?, val label: String) { + private class AppItem( + val packageName: String, + val info: ApplicationInfo?, + val label: String, + val unknown: Boolean = false, + ) { val sys get() = info != null && (info.flags and ApplicationInfo.FLAG_SYSTEM) != 0 val uid get() = info?.uid } @@ -72,12 +81,17 @@ class AppListActivity : ThemedActivity() { fun bind(app: AppItem) { item = app - binding.itemicon.setImageDrawable(app.info?.loadIcon(packageManager) ?: packageManager.defaultActivityIcon) binding.title.text = app.label - binding.desc.text = if (app.info != null) { - "${app.packageName} (${app.uid})" + if (app.unknown) { + binding.itemicon.setImageResource(R.drawable.ic_navigation_apps) + binding.desc.setText(R.string.route_rule_unknown_apps_desc) } else { - getString(R.string.app_not_installed, app.packageName) + binding.itemicon.setImageDrawable(app.info?.loadIcon(packageManager) ?: packageManager.defaultActivityIcon) + binding.desc.text = if (app.info != null) { + "${app.packageName} (${app.uid})" + } else { + getString(R.string.app_not_installed, app.packageName) + } } handlePayload(listOf(SWITCH)) } @@ -105,8 +119,11 @@ class AppListActivity : ThemedActivity() { coroutineContext[Job]!!.ensureActive() packageInfo.applicationInfo?.let { AppItem(packageName, it, it.loadLabel(packageManager).toString()) } }.toMutableList() + if (unknownEntry) { + list.add(AppItem(RouteRule.UNKNOWN_PACKAGE, null, getString(R.string.route_rule_unknown_apps), unknown = true)) + } for (packageName in selected) { - if (packageName !in installed) { + if (packageName !in installed && !(unknownEntry && packageName == RouteRule.UNKNOWN_PACKAGE)) { val info = PackageCache.installedApps[packageName] list.add(AppItem(packageName, info, info?.loadLabel(packageManager)?.toString() ?: packageName)) } @@ -162,9 +179,10 @@ class AppListActivity : ThemedActivity() { private var apps = emptyList() private val appsAdapter = AppsAdapter() private var sysApps = false + private var unknownEntry = false private fun sorted(list: List) = - list.sortedWith(compareBy({ it.packageName !in selected }, { it.label })) + list.sortedWith(compareBy({ !it.unknown }, { it.packageName !in selected }, { it.label })) private fun refilter() = appsAdapter.filter.filter(binding.search.text?.toString() ?: "") @@ -179,7 +197,8 @@ class AppListActivity : ThemedActivity() { binding.loading.crossFadeFrom(binding.list) withContext(Dispatchers.IO) { appsAdapter.reload() } refilter() - if (apps.isEmpty()) { + // The unknown-app row alone is no app list: the permission hint still shows. + if (apps.all { it.unknown }) { binding.list.visibility = View.GONE binding.appPlaceholder.root.crossFadeFrom(binding.loading) } else { @@ -213,6 +232,7 @@ class AppListActivity : ThemedActivity() { val initial = savedInstanceState?.getStringArrayList(STATE_SELECTED) ?: intent.getStringArrayListExtra(EXTRA_PACKAGES).orEmpty() initial.map { it.trim() }.filterTo(selected) { it.isNotEmpty() } + unknownEntry = intent.getBooleanExtra(EXTRA_UNKNOWN_ENTRY, false) sysApps = savedInstanceState?.getBoolean(STATE_SYSTEM_APPS) ?: false updateSubtitle() @@ -256,6 +276,7 @@ class AppListActivity : ThemedActivity() { when (item.itemId) { R.id.action_invert_selections -> { for (app in apps) { + if (app.unknown) continue if (!selected.remove(app.packageName)) selected.add(app.packageName) } apps = sorted(apps) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/AutoSelectorStatusActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/AutoSelectorStatusActivity.kt index 338040f2..d04f0d7d 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/AutoSelectorStatusActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/AutoSelectorStatusActivity.kt @@ -9,6 +9,7 @@ import android.view.LayoutInflater import android.view.Menu import android.view.MenuItem import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.ColorInt import androidx.core.view.isVisible import androidx.lifecycle.Lifecycle @@ -30,7 +31,9 @@ import io.nekohasekai.sagernet.database.ProxyEntity import io.nekohasekai.sagernet.databinding.LayoutAutoSelectorMemberBinding import io.nekohasekai.sagernet.databinding.LayoutAutoSelectorStatusBinding import io.nekohasekai.sagernet.ktx.Logs +import io.nekohasekai.sagernet.ktx.needReload import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher +import io.nekohasekai.sagernet.ui.profile.ProfileSettingsActivity import io.nekohasekai.sagernet.ui.profile.profileSettingsIntent import io.nekohasekai.sagernet.ui.profiles.ProfilesDbWatcher import io.nekohasekai.sagernet.widget.applyInsetPadding @@ -80,6 +83,10 @@ class AutoSelectorStatusActivity : ThemedActivity(), SagerConnection.Callback { private val adapter = MemberAdapter() private val reloads = Channel(Channel.CONFLATED) + private val editor = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.data?.getBooleanExtra(ProfileSettingsActivity.EXTRA_RESTART_NEEDED, false) == true) needReload() + } + private var selectorId = -1L private var onlyProblems = false private var status = AutoSelectorStatus.IDLE @@ -167,7 +174,7 @@ class AutoSelectorStatusActivity : ThemedActivity(), SagerConnection.Callback { val id = selectorId lifecycleScope.launch { val profile = withContext(Dispatchers.IO) { ProfileManager.getProfile(id) } - if (profile != null) startActivity(profile.profileSettingsIntent(this@AutoSelectorStatusActivity, false)) + if (profile != null) editor.launch(profile.profileSettingsIntent(this@AutoSelectorStatusActivity, false)) } true } diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/DashboardFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/DashboardFragment.kt new file mode 100644 index 00000000..5b8b07bc --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/DashboardFragment.kt @@ -0,0 +1,393 @@ +package io.nekohasekai.sagernet.ui + +import android.annotation.SuppressLint +import android.graphics.Bitmap +import android.os.Build +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.webkit.RenderProcessGoneDetail +import android.webkit.WebChromeClient +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.OnBackPressedCallback +import androidx.annotation.DrawableRes +import androidx.annotation.RequiresApi +import androidx.annotation.StringRes +import androidx.appcompat.widget.Toolbar +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import io.nekohasekai.sagernet.BuildConfig +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.bg.BaseService +import io.nekohasekai.sagernet.bg.SingBoxDashboard +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.databinding.LayoutDashboardBinding +import io.nekohasekai.sagernet.ktx.Logs +import io.nekohasekai.sagernet.ktx.launchCustomTab +import io.nekohasekai.sagernet.ui.settings.CoreSettingsFragment +import io.nekohasekai.sagernet.widget.applyInsetMargin +import org.json.JSONObject + +/** + * The sing-box dashboard (SagerNet/sing-box-dashboard) served by the running core's api service, pre-connected like + * the desktop's OpenDashboard (mainwindow_system.cpp:462-530): the page stays hidden until its localStorage holds the + * `throne` server entry that res/dashboard-bootstrap.html seeds there, so the dashboard's setup screen never shows. + * The page is the copy the app bundles ([SingBoxDashboard]): a 404 means the build has none, or unpacking it failed. + */ +class DashboardFragment : ToolbarFragment(R.layout.layout_dashboard), Toolbar.OnMenuItemClickListener { + + private var binding: LayoutDashboardBinding? = null + private var webView: WebView? = null + + /** `http://127.0.0.1:` of the dashboard loaded or loading, "" when there is none. */ + private var origin = "" + private var port = 0 + + /** The page is connected to this core and on screen. */ + private var shown = false + + /** How the current main-frame load failed: its HTTP status, -1 for a network error, 0 not at all. */ + private var mainFrameStatus = 0 + private var clearHistory = false + + /** Counts documents the WebView started; a check result for an older one is stale. */ + private var pageLoad = 0 + private var checkedLoad = -1 + + /** Loads this screen starts make `throne` the active server; the page's own reloads keep the user's choice. */ + private var activate = true + private var seedReloads = 0 + + private val backCallback = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + webView?.goBack() + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + toolbar?.setTitle(R.string.menu_dashboard) + toolbar?.inflateMenu(R.menu.dashboard_menu) + toolbar?.setOnMenuItemClickListener(this) + val binding = LayoutDashboardBinding.bind(view) + this.binding = binding + binding.dashboardContent.applyInsetMargin(bottom = true, horizontal = true, ime = true) + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, backCallback) + render(reload = true) + } + + override fun onResume() { + super.onResume() + webView?.onResume() + } + + override fun onPause() { + webView?.onPause() + super.onPause() + } + + override fun onDestroyView() { + destroyWebView() + origin = "" + shown = false + binding = null + super.onDestroyView() + } + + override fun onMenuItemClick(item: MenuItem): Boolean { + if (item.itemId != R.id.action_dashboard_reload) return false + render(reload = true) + return true + } + + /** MainActivity.changeState: the core serves the dashboard only while the service is connected. */ + fun onServiceStateChanged() = render(reload = false) + + private fun render(reload: Boolean) { + if (binding == null) return + val state = DataStore.serviceState + when { + !DataStore.apiDashboardEnabled -> { + unload() + showMessage( + R.drawable.ic_baseline_transform_24, R.string.dashboard_off_title, + getString(R.string.dashboard_off), R.string.dashboard_core_settings, + ) { + (activity as? MainActivity)?.openSettingsScreen( + CoreSettingsFragment::class.java.name, getString(R.string.settings_core) + ) + } + } + + state.connected -> if (reload || origin.isEmpty()) load() + + state.started -> { + unload() + showProgress(R.string.connecting) + } + + state == BaseService.State.Stopping -> { + unload() + showProgress(R.string.stopping) + } + + else -> { + unload() + showMessage( + R.drawable.ic_baseline_transform_24, R.string.dashboard_stopped_title, + getString(R.string.dashboard_stopped), R.string.menu_configuration, + ) { + (activity as? MainActivity)?.displayFragmentWithId(R.id.nav_configuration) + } + } + } + } + + private fun load() { + val binding = binding ?: return + val webView = this.webView ?: createWebView(binding.dashboardWeb)?.also { this.webView = it } + if (webView == null) { + origin = "" + showError(getString(R.string.dashboard_no_webview)) + return + } + port = DataStore.coreBoxApiPort + origin = "http://127.0.0.1:$port" + activate = true + seedReloads = 0 + mainFrameStatus = 0 + clearHistory = true + showProgress(R.string.dashboard_loading) + webView.loadUrl("$origin/dashboard/") + } + + /** Stops the page, whose streams would otherwise keep knocking on a port nothing serves. */ + private fun unload() { + if (origin.isEmpty()) return + origin = "" + webView?.run { + stopLoading() + loadUrl("about:blank") + } + } + + private fun isOurs(url: String?): Boolean = + origin.isNotEmpty() && url != null && (url == origin || url.startsWith("$origin/")) + + /** + * Writes the `throne` entry when it is missing or stale and reloads, since the page reads its server list only + * when it starts; the page is shown once the entry is right (or cannot be written at all). + */ + private fun seed(view: WebView) { + // onPageFinished also reports the page's own hash navigations: one check per document. + val document = pageLoad + if (checkedLoad == document) return + checkedLoad = document + view.evaluateJavascript(seedScript("127.0.0.1:$port", DataStore.coreBoxApiSecret, activate)) { result -> + if (view !== webView || document != pageLoad || origin.isEmpty()) return@evaluateJavascript + if (result == "\"seeded\"" && seedReloads++ < 2) { + view.reload() + return@evaluateJavascript + } + if (result != "\"ok\"") Logs.w("dashboard: server entry check returned $result") + activate = false + seedReloads = 0 + showDashboard() + } + } + + private fun showProgress(@StringRes text: Int) { + val binding = binding ?: return + shown = false + binding.dashboardWeb.isInvisible = true + binding.dashboardMessage.isVisible = false + binding.dashboardProgress.isVisible = true + binding.dashboardProgressText.setText(text) + syncBack() + } + + private fun showMessage( + @DrawableRes icon: Int, @StringRes title: Int, text: CharSequence, @StringRes action: Int, onAction: () -> Unit, + ) { + val binding = binding ?: return + shown = false + binding.dashboardWeb.isInvisible = true + binding.dashboardProgress.isVisible = false + binding.dashboardMessage.isVisible = true + binding.dashboardMessageIcon.setImageResource(icon) + binding.dashboardMessageTitle.setText(title) + binding.dashboardMessageText.text = text + binding.dashboardMessageAction.setText(action) + binding.dashboardMessageAction.setOnClickListener { onAction() } + syncBack() + } + + private fun showError(text: String) = showMessage( + R.drawable.ic_baseline_warning_24, R.string.dashboard_error_title, text, R.string.dashboard_retry, + ) { render(reload = true) } + + private fun showDashboard() { + val binding = binding ?: return + shown = true + binding.dashboardProgress.isVisible = false + binding.dashboardMessage.isVisible = false + binding.dashboardWeb.isInvisible = false + syncBack() + } + + private fun syncBack() { + backCallback.isEnabled = shown && webView?.canGoBack() == true + } + + @SuppressLint("SetJavaScriptEnabled") + private fun createWebView(holder: ViewGroup): WebView? { + val webView = try { + WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG) + WebView(requireContext()) + } catch (e: Exception) { + // No usable WebView provider: missing, disabled or being updated. + Logs.w(e) + return null + } + webView.settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + setSupportZoom(false) + builtInZoomControls = false + displayZoomControls = false + allowFileAccess = false + allowContentAccess = false + } + webView.webViewClient = Client() + // Without a chrome client the WebView drops the page's JavaScript dialogs. + webView.webChromeClient = WebChromeClient() + holder.addView(webView, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + if (!isResumed) webView.onPause() + return webView + } + + private fun destroyWebView() { + val webView = webView ?: return + this.webView = null + (webView.parent as? ViewGroup)?.removeView(webView) + webView.stopLoading() + webView.destroy() + } + + private inner class Client : WebViewClient() { + + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + if (!request.isForMainFrame) return false + val url = request.url.toString() + if (isOurs(url) || url.startsWith("blob:$origin/")) return false + // Other sites go to the browser: this WebView holds the API secret. + val scheme = request.url.scheme?.lowercase() + if (scheme == "http" || scheme == "https") context?.launchCustomTab(url) + return true + } + + override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) { + pageLoad++ + } + + // Arrives with the response, before onPageStarted, so only onPageFinished consumes it. + override fun onReceivedHttpError(view: WebView, request: WebResourceRequest, errorResponse: WebResourceResponse) { + if (request.isForMainFrame) mainFrameStatus = errorResponse.statusCode + } + + override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) { + if (!request.isForMainFrame || view !== webView || !isOurs(request.url.toString())) return + val detail = error.description?.toString().orEmpty() + if (detail == "net::ERR_ABORTED") return + mainFrameStatus = -1 + Logs.w("dashboard: ${request.url}: $detail") + showError(getString(R.string.dashboard_unreachable, port, detail)) + } + + override fun onPageFinished(view: WebView, url: String?) { + val status = mainFrameStatus + mainFrameStatus = 0 + if (view !== webView || !isOurs(url)) return + if (clearHistory) { + // What earlier loads left behind: about:blank, pages of the previous service run. + clearHistory = false + view.clearHistory() + } + when (status) { + 0 -> seed(view) + -1 -> Unit + 404 -> showMessage( + R.drawable.ic_baseline_warning_24, R.string.dashboard_missing_title, + getString(R.string.dashboard_missing), R.string.dashboard_retry, + ) { render(reload = true) } + + else -> showError(getString(R.string.dashboard_http_error, status)) + } + } + + override fun doUpdateVisitedHistory(view: WebView, url: String?, isReload: Boolean) = syncBack() + + // Unhandled, a crashed or killed renderer takes the whole app down with it. + @RequiresApi(Build.VERSION_CODES.O) + override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean { + Logs.w("dashboard: the WebView renderer is gone") + if (view === webView) { + destroyWebView() + origin = "" + render(reload = true) + } + return true + } + } + + private companion object { + /** + * dashboard-bootstrap.html as a check: the `throne` entry of the page's server list (src/api/config.ts) gets + * this core's address and secret, other servers stay. Answers "ok" when nothing had to change, "seeded" after + * a write and "error" when the storage is unusable. [activate] also makes it the active server. + */ + fun seedScript(url: String, secret: String, activate: Boolean): String = """ + (function () { + var KEY = "sing-box-dashboard.servers", ID = "throne"; + var url = ${JSONObject.quote(url)}, secret = ${JSONObject.quote(secret)}; + try { + var state = null; + try { state = JSON.parse(localStorage.getItem(KEY)); } catch (e) {} + if (!state || typeof state !== "object" || Array.isArray(state)) state = {}; + var servers = Array.isArray(state.servers) ? state.servers : []; + var entry = null; + for (var i = 0; i < servers.length; i++) { + if (servers[i] && servers[i].id === ID) { entry = servers[i]; break; } + } + var changed = false; + if (!entry) { + servers.push({ id: ID, name: "Throne", url: url, secret: secret }); + changed = true; + } else if (entry.url !== url || entry.secret !== secret) { + entry.url = url; + entry.secret = secret; + changed = true; + } + var activeValid = servers.some(function (s) { + return s && s.id === state.activeId && typeof s.url === "string" && s.url !== ""; + }); + if ($activate ? state.activeId !== ID : !activeValid) { + state.activeId = ID; + changed = true; + } + if (!changed) return "ok"; + state.servers = servers; + localStorage.setItem(KEY, JSON.stringify(state)); + return "seeded"; + } catch (e) { + return "error"; + } + })(); + """.trimIndent() + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt index 94913db7..b9d1367c 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt @@ -46,6 +46,7 @@ import io.nekohasekai.sagernet.ktx.onMainDispatcher import io.nekohasekai.sagernet.ui.profile.ProfileTextImport import io.nekohasekai.sagernet.ui.route.RouteImports import io.nekohasekai.sagernet.ui.settings.DnsSettingsFragment +import io.nekohasekai.sagernet.ui.settings.XrayGeoSettingsFragment import io.nekohasekai.sagernet.ktx.readableMessage import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher import io.nekohasekai.sagernet.ui.MessageStore @@ -156,7 +157,7 @@ class MainActivity : ThemedActivity(), if (savedInstanceState == null) intent?.let(::handleImportIntent) SubscriptionReportDialog.observe(this) - refreshNavMenu(DataStore.clashApiEnabled) + refreshNavMenu(DataStore.apiDashboardEnabled) if (savedInstanceState == null) { firstStart.start() @@ -213,9 +214,10 @@ class MainActivity : ThemedActivity(), } } - fun refreshNavMenu(clashApi: Boolean) { + /** The dashboard item follows the sing-box API (core_box_api_port), which serves the dashboard. */ + fun refreshNavMenu(dashboard: Boolean) { if (::navigation.isInitialized) { - navigation.menu.findItem(R.id.nav_traffic)?.isVisible = clashApi + navigation.menu.findItem(R.id.nav_traffic)?.isVisible = dashboard } } @@ -383,7 +385,7 @@ class MainActivity : ThemedActivity(), R.id.nav_group -> displayFragment(GroupFragment()) R.id.nav_route -> displayFragment(RouteFragment()) R.id.nav_settings -> displayFragment(SettingsFragment()) - R.id.nav_traffic -> displayFragment(WebviewFragment()) + R.id.nav_traffic -> displayFragment(DashboardFragment()) R.id.nav_tools -> displayFragment(ToolsFragment()) R.id.nav_logcat -> displayFragment(LogcatFragment()) R.id.nav_faq -> { @@ -407,8 +409,10 @@ class MainActivity : ThemedActivity(), ) { DataStore.serviceState = state refreshConfigurationProfileState() - ((currentMainFragment ?: supportFragmentManager.findFragmentById(R.id.fragment_holder)) as? RouteFragment) - ?.onServiceStateChanged() + when (val fragment = currentMainFragment ?: supportFragmentManager.findFragmentById(R.id.fragment_holder)) { + is RouteFragment -> fragment.onServiceStateChanged() + is DashboardFragment -> fragment.onServiceStateChanged() + } binding.fab.changeState(state, DataStore.serviceState, animate) binding.stats.changeState(state) @@ -433,6 +437,10 @@ class MainActivity : ThemedActivity(), bar.setAction(R.string.settings_dns) { openSettingsScreen(DnsSettingsFragment::class.java.name, getString(R.string.settings_dns)) } + } else if (DataStore.serviceErrorGeo) { + bar.setAction(R.string.xray_geo_assets_action) { + openSettingsScreen(XrayGeoSettingsFragment::class.java.name, getString(R.string.xray_geo_assets)) + } } else { bar.setAction(R.string.menu_log) { displayFragmentWithId(R.id.nav_logcat) } } @@ -441,6 +449,7 @@ class MainActivity : ThemedActivity(), if (event == DISMISS_EVENT_SWIPE || event == DISMISS_EVENT_ACTION) { DataStore.serviceError = "" DataStore.serviceErrorDns = false + DataStore.serviceErrorGeo = false } } }).also { it.show() } diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/RouteFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/RouteFragment.kt index 26e068b2..c33a88de 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/RouteFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/RouteFragment.kt @@ -177,7 +177,10 @@ class RouteFragment : ToolbarFragment(R.layout.layout_route), Toolbar.OnMenuItem } } - /** ToShareLink; rules whose server is gone are left out, and app rules are Android-only, so both are pointed out. */ + /** + * ToShareLink; rules whose server is gone are left out, and rules on apps, network type or metered network are + * Android-only (the desktop skips or never matches them), so both are pointed out. + */ private fun share(id: Long, qr: Boolean) = runOnLifecycleDispatcher { val profile = RouteManager.get(id) ?: return@runOnLifecycleDispatcher val names = HashMap() @@ -188,14 +191,15 @@ class RouteFragment : ToolbarFragment(R.layout.layout_route), Toolbar.OnMenuItem type != RuleType.ENDPOINT_PREFERRED_BY && !(type != RuleType.CUSTOM && rule.isEmpty()) } val dropped = shared.count { it.toRuleJson(true, null, nameOf).isEmpty() } - val appRules = shared.any { rule -> - rule.package_name.any { it.isNotBlank() } && rule.toRuleJson(true, null, nameOf).isNotEmpty() + val androidOnly = shared.any { rule -> + val androidCondition = rule.matchesByApp() || rule.network_type.any { it.isNotBlank() } || rule.network_is_expensive + androidCondition && rule.toRuleJson(true, null, nameOf).isNotEmpty() } onMainDispatcher { if (!isAdded) return@onMainDispatcher val warnings = ArrayList() if (dropped > 0) warnings.add(resources.getQuantityString(R.plurals.route_export_dropped, dropped, dropped)) - if (appRules) warnings.add(getString(R.string.route_export_package_note)) + if (androidOnly) warnings.add(getString(R.string.route_export_package_note)) if (qr) { QRCodeDialog(link, profile.name).showAllowingStateLoss(parentFragmentManager) if (warnings.isNotEmpty()) snackbar(warnings.joinToString("\n")).show() diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/WebviewFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/WebviewFragment.kt deleted file mode 100644 index ed91b0e8..00000000 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/WebviewFragment.kt +++ /dev/null @@ -1,79 +0,0 @@ -package io.nekohasekai.sagernet.ui - -import android.annotation.SuppressLint -import android.os.Bundle -import android.text.InputType -import android.view.MenuItem -import android.view.View -import android.webkit.* -import android.widget.EditText -import androidx.appcompat.widget.Toolbar -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import io.nekohasekai.sagernet.BuildConfig -import io.nekohasekai.sagernet.R -import io.nekohasekai.sagernet.database.DataStore -import io.nekohasekai.sagernet.databinding.LayoutWebviewBinding -import io.nekohasekai.sagernet.widget.applyInsetMargin -import moe.matsuri.nb4a.utils.WebViewUtil - -class WebviewFragment : ToolbarFragment(R.layout.layout_webview), Toolbar.OnMenuItemClickListener { - - lateinit var mWebView: WebView - - @SuppressLint("SetJavaScriptEnabled") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - // layout - toolbar?.setTitle(R.string.menu_dashboard) - toolbar?.inflateMenu(R.menu.yacd_menu) - toolbar?.setOnMenuItemClickListener(this) - - val binding = LayoutWebviewBinding.bind(view) - - // webview - WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG) - mWebView = binding.webview - mWebView.applyInsetMargin(bottom = true, horizontal = true, ime = true) - mWebView.settings.domStorageEnabled = true - mWebView.settings.javaScriptEnabled = true - mWebView.webViewClient = object : WebViewClient() { - override fun onReceivedError( - view: WebView?, request: WebResourceRequest?, error: WebResourceError? - ) { - WebViewUtil.onReceivedError(view, request, error) - } - - override fun onPageFinished(view: WebView?, url: String?) { - super.onPageFinished(view, url) - } - } - mWebView.loadUrl(DataStore.yacdURL) - } - - @SuppressLint("CheckResult") - override fun onMenuItemClick(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_set_url -> { - val view = EditText(context).apply { - inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI - setText(DataStore.yacdURL) - } - MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.set_panel_url) - .setView(view) - .setPositiveButton(android.R.string.ok) { _, _ -> - DataStore.yacdURL = view.text.toString() - mWebView.loadUrl(DataStore.yacdURL) - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - R.id.close -> { - mWebView.onPause() - mWebView.removeAllViews() - mWebView.destroy() - } - } - return true - } -} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/profile/ProfileSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/ProfileSettingsActivity.kt index 5950545d..5ca19d3a 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/profile/ProfileSettingsActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/ProfileSettingsActivity.kt @@ -94,6 +94,9 @@ abstract class ProfileSettingsActivity( const val EXTRA_PROFILE_ID = "id" const val EXTRA_IS_SUBSCRIPTION = "sub" + /** Result extra: the saved profile is one the running config uses, so the service needs a restart. */ + const val EXTRA_RESTART_NEEDED = "restartNeeded" + /** Profile cache key holding the whole ExportToJson while the "Edit as JSON" editor is open. */ const val KEY_RAW_JSON = "serverRawJson" } @@ -180,10 +183,10 @@ abstract class ProfileSettingsActivity( finish() return } - if (entity.id == DataStore.selectedProxy) { - SagerNet.stopService() + // dialog_edit_profile.cpp:947-951: saving a profile the running config uses asks for a restart. + if (ProfileManager.updateOutbound(entity.putOutbound(outbound)) && ProfileManager.runningUses(entity.id)) { + setResult(RESULT_OK, Intent().putExtra(EXTRA_RESTART_NEEDED, true)) } - ProfileManager.updateOutbound(entity.putOutbound(outbound)) } finish() @@ -240,6 +243,9 @@ abstract class ProfileSettingsActivity( override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.profile_config_menu, menu) + // Like the list: the started profile can be edited, not deleted. + menu.findItem(R.id.action_delete)?.isVisible = + DataStore.editingId == 0L || ProfileManager.runningProfileId() != DataStore.editingId menu.findItem(R.id.action_move)?.apply { if (DataStore.editingId != 0L // not new profile && SagerDatabase.groupDao.getById(DataStore.editingGroup)?.isSubscription == false diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/profiles/ProfileItemMenu.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/profiles/ProfileItemMenu.kt index ffd660f2..a94d3346 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/profiles/ProfileItemMenu.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/profiles/ProfileItemMenu.kt @@ -1,6 +1,7 @@ package io.nekohasekai.sagernet.ui.profiles import android.view.View +import androidx.activity.result.contract.ActivityResultContracts import io.nekohasekai.sagernet.ui.SaveDocument import androidx.appcompat.widget.PopupMenu import io.nekohasekai.sagernet.R @@ -12,12 +13,14 @@ import io.nekohasekai.sagernet.database.ProxyEntity import io.nekohasekai.sagernet.database.ProxyGroup import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.confirmAction +import io.nekohasekai.sagernet.ktx.needReload import io.nekohasekai.sagernet.ktx.readableMessage import io.nekohasekai.sagernet.ktx.showAllowingStateLoss import io.nekohasekai.sagernet.ktx.snackbar import io.nekohasekai.sagernet.ktx.startFilesForResult import io.nekohasekai.sagernet.ui.ConfigurationFragment import io.nekohasekai.sagernet.ui.profile.ProfileConfigExport +import io.nekohasekai.sagernet.ui.profile.ProfileSettingsActivity import io.nekohasekai.sagernet.ui.profile.profileSettingsIntent import io.nekohasekai.sagernet.widget.QRCodeDialog @@ -40,9 +43,13 @@ class ProfileItemMenu(private val host: ConfigurationFragment) { } } + private val editor = host.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.data?.getBooleanExtra(ProfileSettingsActivity.EXTRA_RESTART_NEEDED, false) == true) host.needReload() + } + fun edit(profile: ProxyEntity, group: ProxyGroup?) { val context = host.context ?: return - context.startActivity(profile.profileSettingsIntent(context, group?.isSubscription == true)) + editor.launch(profile.profileSettingsIntent(context, group?.isSubscription == true)) } internal fun showMenu(anchor: View, profile: ProxyEntity, adapter: ProfileListAdapter) { diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/profiles/ProfileRowHolder.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/profiles/ProfileRowHolder.kt index b441945a..d41a31ed 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/profiles/ProfileRowHolder.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/profiles/ProfileRowHolder.kt @@ -302,7 +302,6 @@ internal class ProfileRowHolder(view: View, private val adapter: ProfileListAdap editButton.isVisible = buttons && !adapter.isCompact shareButton.isVisible = buttons && !adapter.isCompact && !profile.isChain() moreButton.isVisible = buttons - editButton.isEnabled = !host.isStartedProfile(id) applyCardColors(host.isSelectedProfile(id), selectCheck.isChecked) } diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/route/RouteProfileActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/route/RouteProfileActivity.kt index 2be94a20..9b6ada11 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/route/RouteProfileActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/route/RouteProfileActivity.kt @@ -133,7 +133,7 @@ class RouteProfileActivity : ThemedActivity(R.layout.layout_route_profile) { } } ?: return@withContext null nameRules(p) - Triple(p, RouteServers.names(p.rules.map { it.outbound_id }), appLabels(p.rules.flatMap { it.package_name })) + Triple(p, RouteServers.names(p.rules.map { it.outbound_id }), appLabels(labelledPackages(p.rules))) } if (loaded == null) { finish() @@ -158,6 +158,10 @@ class RouteProfileActivity : ThemedActivity(R.layout.layout_route_profile) { return packages.distinct().associateWith { PackageCache.loadLabel(it) } } + /** The packages the rules' summaries name, without the unknown-app entry. */ + private fun labelledPackages(rules: List): List = + rules.flatMap { rule -> rule.package_name.filter { it != RouteRule.UNKNOWN_PACKAGE } } + @SuppressLint("NotifyDataSetChanged") private fun refreshAll() { headerAdapter.notifyDataSetChanged() @@ -316,7 +320,7 @@ class RouteProfileActivity : ThemedActivity(R.layout.layout_route_profile) { /** Loads the server and app names a changed rule shows but the editor does not know yet. */ private fun resolveNames(rule: RouteRule) { val server = rule.outbound_id.takeIf { it > 0 && it !in model.servers } - val packages = rule.package_name.filter { it !in model.appLabels } + val packages = labelledPackages(listOf(rule)).filter { it !in model.appLabels } if (server == null && packages.isEmpty()) return lifecycleScope.launch { val (servers, labels) = withContext(Dispatchers.IO) { @@ -369,7 +373,7 @@ class RouteProfileActivity : ThemedActivity(R.layout.layout_route_profile) { if (p.name.isBlank()) p.name = copy.name nameRules(p) val (servers, labels) = withContext(Dispatchers.IO) { - RouteServers.names(p.rules.map { it.outbound_id }) to appLabels(p.rules.flatMap { it.package_name }) + RouteServers.names(p.rules.map { it.outbound_id }) to appLabels(labelledPackages(p.rules)) } model.servers = servers model.appLabels = labels 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 7c4e838a..bbbda032 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 @@ -62,18 +62,20 @@ class RouteRuleActivity : ThemedActivity(R.layout.layout_config_settings), OnPre "override_address", "override_port", ) 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", + "domain_suffix", "domain", "ip_cidr", "rule_set", "package_name", "network_type", "domain_keyword", + "domain_regex", "source_ip_cidr", "port", "port_range", "source_port", "source_port_range", "inbound", + "package_name_regex", "process_name", "process_path", "process_path_regex", "wifi_ssid", "wifi_bssid", + ) + private val BOOL_KEYS = listOf( + "sniff_override_dest", "ip_is_private", "source_ip_is_private", "invert", "no_drop", "network_is_expensive", ) - private val BOOL_KEYS = listOf("sniff_override_dest", "ip_is_private", "source_ip_is_private", "invert", "no_drop") /** The members inside the collapsed group. */ private val ADVANCED_KEYS = listOf( "domain_keyword", "domain_regex", "ip_is_private", "source_ip_cidr", "source_ip_is_private", "port", "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", + "override_address", "override_port", "no_drop", "package_name_regex", "process_name", "process_path", + "process_path_regex", "network_is_expensive", "wifi_ssid", "wifi_bssid", ) /** Android names apps by package, never by process: these show only when a desktop rule brought a value. */ @@ -99,7 +101,7 @@ class RouteRuleActivity : ThemedActivity(R.layout.layout_config_settings), OnPre if (list != null) setListValue("rule_set", list) } - private val appPicker = registerForActivityResult(AppListActivity.Contract()) { list -> + private val appPicker = registerForActivityResult(AppListActivity.Contract(unknownEntry = true)) { list -> if (list != null) setListValue("package_name", list) } @@ -161,6 +163,22 @@ class RouteRuleActivity : ThemedActivity(R.layout.layout_config_settings), OnPre fragment?.findPreference(key)?.refresh() } + /** Checks the stored network types; OK keeps the checked ones in sing-box's order, Cancel changes nothing. */ + private fun pickNetworkTypes(current: List) { + val types = RouteRule.NETWORK_TYPES + val checked = BooleanArray(types.size) { types[it] in current } + MaterialAlertDialogBuilder(this) + .setTitle(R.string.route_rule_network_type) + .setMultiChoiceItems(R.array.route_rule_network_type_entries, checked) { _, which, isChecked -> + checked[which] = isChecked + } + .setPositiveButton(android.R.string.ok) { _, _ -> + setListValue("network_type", types.filterIndexed { i, _ -> checked[i] }) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.profile_config_menu, menu) return true @@ -267,7 +285,7 @@ class RouteRuleActivity : ThemedActivity(R.layout.layout_config_settings), OnPre findPreference(key)?.ensureValue() } - val multiline = LIST_KEYS - setOf("rule_set", "package_name") + val multiline = LIST_KEYS - setOf("rule_set", "package_name", "network_type") multilineInput(*multiline.toTypedArray()) for (key in multiline) findPreference(key)?.summaryProvider = LinesSummaryProvider(maxLines = 3) refreshWifiHint() @@ -286,13 +304,26 @@ class RouteRuleActivity : ThemedActivity(R.layout.layout_config_settings), OnPre summaryProvider = Preference.SummaryProvider { p -> val packages = p.values if (packages.size > 5) getString(R.string.apps_message, packages.size) - else summarize(packages.map { PackageCache.loadLabel(it) }, 5, ", ") + else summarize(packages.map { + if (it == RouteRule.UNKNOWN_PACKAGE) getString(R.string.route_rule_unknown_apps) else PackageCache.loadLabel(it) + }, 5, ", ") } setOnPreferenceClickListener { host.appPicker.launch(values) true } } + findPreference("network_type")!!.apply { + summaryProvider = Preference.SummaryProvider { p -> + val labels = resources.getStringArray(R.array.route_rule_network_type_entries) + val names = p.values.map { labels.getOrNull(RouteRule.NETWORK_TYPES.indexOf(it)) ?: it } + if (names.isEmpty()) getString(androidx.preference.R.string.not_set) else names.joinToString(", ") + } + setOnPreferenceClickListener { + host.pickNetworkTypes(values) + true + } + } findPreference(KEY_ADVANCED_TOGGLE)!!.setOnPreferenceClickListener { host.advancedExpanded = !host.advancedExpanded refreshState() 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 5d89fb71..ecc552d6 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 @@ -115,6 +115,7 @@ internal object RouteRuleChecks { entries(R.string.route_rule_override_port, listOf(rule.override_port), ::isPort) regexes(R.string.route_rule_domain_regex, rule.domain_regex) regexes(R.string.route_rule_process_path_regex, rule.process_path_regex) + regexes(R.string.route_rule_package_name_regex, rule.package_name_regex) return out } diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/route/RouteTexts.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/route/RouteTexts.kt index a002e5a2..9352ee25 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/route/RouteTexts.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/route/RouteTexts.kt @@ -79,7 +79,10 @@ internal object RouteTexts { list("rule-set", rule.rule_set) { RuleSetLabels.shortName(it) } list("ip", rule.ip_cidr) if (rule.ip_is_private) parts.add(context.getString(R.string.route_summary_private_ip)) - list("app", rule.package_name) { appLabels[it] ?: it } + list("app", rule.package_name) { + if (it == RouteRule.UNKNOWN_PACKAGE) context.getString(R.string.route_rule_unknown_apps) else appLabels[it] ?: it + } + list("app regex", rule.package_name_regex) list("process", rule.process_name) list("path", rule.process_path) list("path regex", rule.process_path_regex) @@ -93,6 +96,8 @@ internal object RouteTexts { list("source port", rule.source_port) list("source port range", rule.source_port_range) list("inbound", rule.inbound) + list("network type", rule.network_type) + if (rule.network_is_expensive) parts.add(context.getString(R.string.route_summary_metered)) list("ssid", rule.wifi_ssid) list("bssid", rule.wifi_bssid) if (parts.isEmpty()) { diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/settings/CoreSettingsScreens.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/settings/CoreSettingsScreens.kt index 29e366a0..75b3f715 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/settings/CoreSettingsScreens.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/settings/CoreSettingsScreens.kt @@ -126,7 +126,10 @@ class TestingSettingsFragment : SettingsScreenFragment(R.xml.settings_testing) { } } -/** Logging, statistics, Clash API, Xray import preference, certificate defaults and the NTP client. */ +/** + * Logging, statistics, the Clash API, the sing-box API that serves the dashboard, Xray import preference, + * certificate defaults and the NTP client. + */ class CoreSettingsFragment : SettingsScreenFragment(R.xml.settings_core) { override fun bind() { @@ -162,6 +165,7 @@ class CoreSettingsFragment : SettingsScreenFragment(R.xml.settings_core) { ) bindClashApi() + bindApiDashboard() bindNtp() } @@ -189,7 +193,6 @@ class CoreSettingsFragment : SettingsScreenFragment(R.xml.settings_core) { val on = newValue as Boolean DataStore.coreBoxClashApi = if (on) portValue() else -portValue() sync(on) - (activity as? MainActivity)?.refreshNavMenu(on) needReload() true } @@ -207,6 +210,56 @@ class CoreSettingsFragment : SettingsScreenFragment(R.xml.settings_core) { checkText(secret.key, R.string.invalid_value) { true } } + /** + * core_box_api_port, sign-encoded like core_box_clash_api; it also shows the dashboard in the drawer. The secret is + * never stored empty (sing-box reads that as no authentication): clearing it makes a new random one. + */ + private fun bindApiDashboard() { + val enabled = pref(KEY_API_ENABLED) + val port = pref(KEY_API_PORT) + val secret = pref(SettingsRegistry.CORE_BOX_API_SECRET.key) + fun portValue() = abs(DataStore.coreBoxApiPort).takeIf { it > 0 } ?: abs(SettingsRegistry.CORE_BOX_API_PORT.default) + fun sync(on: Boolean) { + port.isEnabled = on + secret.isEnabled = on + } + + enabled.isChecked = DataStore.apiDashboardEnabled + port.text = portValue().toString() + port.summaryProvider = EditTextPreference.SimpleSummaryProvider.getInstance() + port.setOnBindEditTextListener(EditTextPreferenceModifiers.Port) + secret.text = DataStore.coreBoxApiSecret + secret.summaryProvider = GroupSettingsActivity.PasswordSummaryProvider + sync(enabled.isChecked) + + enabled.setOnPreferenceChangeListener { _, newValue -> + val on = newValue as Boolean + DataStore.coreBoxApiPort = if (on) portValue() else -portValue() + sync(on) + (activity as? MainActivity)?.refreshNavMenu(on) + needReload() + true + } + port.setOnPreferenceChangeListener { _, newValue -> + val value = newValue?.toString()?.trim()?.toIntOrNull() + if (value == null || !SettingValidators.isPort(value)) { + toast(R.string.invalid_port, newValue?.toString().orEmpty()) + return@setOnPreferenceChangeListener false + } + DataStore.coreBoxApiPort = if (enabled.isChecked) value else -value + needReload() + true + } + secret.setOnPreferenceChangeListener { _, newValue -> + val value = newValue?.toString()?.trim().orEmpty() + // Reading an empty secret back generates and saves a new one. + if (value.isEmpty()) DataStore.coreBoxApiSecret = "" + secret.text = value.ifEmpty { DataStore.coreBoxApiSecret } + needReload() + false + } + } + private fun bindNtp() { val enable = pref(SettingsRegistry.ENABLE_NTP.key) val fields = listOf( @@ -235,5 +288,7 @@ class CoreSettingsFragment : SettingsScreenFragment(R.xml.settings_core) { private companion object { const val KEY_CLASH_ENABLED = "coreClashApiEnabled" const val KEY_CLASH_PORT = "coreClashApiPort" + const val KEY_API_ENABLED = "coreApiDashboardEnabled" + const val KEY_API_PORT = "coreApiDashboardPort" } } diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/settings/XrayGeoSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/settings/XrayGeoSettingsScreen.kt new file mode 100644 index 00000000..ffce360f --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/settings/XrayGeoSettingsScreen.kt @@ -0,0 +1,193 @@ +package io.nekohasekai.sagernet.ui.settings + +import android.text.InputType +import android.text.format.Formatter +import android.view.View +import androidx.lifecycle.lifecycleScope +import androidx.preference.EditTextPreference +import androidx.preference.Preference +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.bg.XrayGeoAssets +import io.nekohasekai.sagernet.database.SettingsRegistry +import io.nekohasekai.sagernet.databinding.DialogGeoDownloadBinding +import io.nekohasekai.sagernet.ktx.needReload +import io.nekohasekai.sagernet.ktx.readableMessage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import moe.matsuri.nb4a.ui.SimpleMenuPreference +import java.text.DateFormat +import java.util.Date + +/** + * Settings › Core › Xray geo assets: where geoip.dat and geosite.dat come from (the provider combos of + * dialog_basic_settings.cpp:260-290) and the installed files with "Download now" (:510-575). A changed source drops + * the file it replaces; the next config that needs it downloads it again. + */ +class XrayGeoSettingsFragment : SettingsScreenFragment(R.xml.settings_xray_geo) { + + private var downloadJob: Job? = null + + override fun bind() { + val source = pref(KEY_SOURCE) + val providers = XrayGeoAssets.PROVIDERS + source.entries = (providers.map { it.name } + getString(R.string.xray_geo_source_custom)).toTypedArray() + source.entryValues = (providers.indices.map { it.toString() } + VALUE_CUSTOM).toTypedArray() + source.summaryProvider = Preference.SummaryProvider { it.entry } + // Both files come from the picked provider; Custom leaves the URLs to be edited. + source.setOnPreferenceChangeListener { _, newValue -> + val provider = (newValue as String).toIntOrNull()?.let(providers::getOrNull) + if (provider != null && + (setUrl(XrayGeoAssets.GEOIP, provider.geoip) or setUrl(XrayGeoAssets.GEOSITE, provider.geosite)) + ) { + sourceChanged() + } + true + } + for (file in XrayGeoAssets.FILES) { + bindUrl(file) + pref(fileKey(file)).apply { + title = file + setOnPreferenceClickListener { + download(listOf(file)) + true + } + } + } + pref(KEY_DOWNLOAD).setOnPreferenceClickListener { + download(XrayGeoAssets.FILES) + true + } + syncSource() + } + + override fun onResume() { + super.onResume() + // A start or a test in the service process may have fetched a file meanwhile. + refreshFiles() + } + + private fun bindUrl(file: String) { + val preference = pref(urlKey(file)) + preference.setOnBindEditTextListener { editText -> + editText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI + editText.setSelection(editText.text.length) + } + preference.setOnPreferenceChangeListener { _, newValue -> + val url = newValue?.toString().orEmpty().trim() + if (!XrayGeoAssets.isValidUrl(url)) { + toast(R.string.xray_geo_invalid_url, url) + } else if (setUrl(file, url)) { + sourceChanged() + } + false + } + } + + /** Stores [url] as the source of [file] and drops the installed copy; false when it is the stored one already. */ + private fun setUrl(file: String, url: String): Boolean { + val preference = pref(urlKey(file)) + if (preference.text == url) return false + preference.text = url + XrayGeoAssets.delete(file) + return true + } + + private fun sourceChanged() { + syncSource() + refreshFiles() + // A running Xray keeps the old data, and one it starts lazily would miss the file: a reload fetches it first. + needReload() + } + + /** The provider both stored URLs belong to, else Custom. */ + private fun syncSource() { + val geoip = pref(urlKey(XrayGeoAssets.GEOIP)).text.orEmpty() + val geosite = pref(urlKey(XrayGeoAssets.GEOSITE)).text.orEmpty() + val index = XrayGeoAssets.PROVIDERS.indexOfFirst { it.geoip == geoip && it.geosite == geosite } + pref(KEY_SOURCE).value = if (index >= 0) index.toString() else VALUE_CUSTOM + } + + private fun refreshFiles() { + val context = context ?: return + val dates = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT) + for (file in XrayGeoAssets.FILES) { + val installed = XrayGeoAssets.file(file) + pref(fileKey(file)).summary = if (installed.isFile && installed.length() > 0) { + getString( + R.string.xray_geo_status_installed, + Formatter.formatShortFileSize(context, installed.length()), + dates.format(Date(installed.lastModified())), + ) + } else { + getString(R.string.xray_geo_status_missing) + } + } + } + + /** Downloads [files] again, one after the other, behind a progress dialog whose Cancel stops it. */ + private fun download(files: List) { + if (downloadJob?.isActive == true) return + val context = requireContext() + val binding = DialogGeoDownloadBinding.inflate(layoutInflater) + val dialog = MaterialAlertDialogBuilder(context) + .setTitle(R.string.xray_geo_download_title) + .setView(binding.root) + .setNegativeButton(android.R.string.cancel) { _, _ -> downloadJob?.cancel() } + .setCancelable(false) + .show() + downloadJob = viewLifecycleOwner.lifecycleScope.launch { + val errors = ArrayList() + try { + for (file in files) { + showProgress(binding, file, null) + try { + XrayGeoAssets.download(file, force = true) { progress -> + withContext(Dispatchers.Main) { showProgress(binding, file, progress) } + } + } catch (e: XrayGeoAssets.DownloadException) { + errors.add(e.readableMessage) + } + } + } finally { + runCatching { dialog.dismiss() } + refreshFiles() + } + MaterialAlertDialogBuilder(context) + .setTitle(R.string.xray_geo_download_title) + .setMessage( + if (errors.isEmpty()) getString(R.string.xray_geo_download_done, files.joinToString(", ")) + else errors.joinToString("\n\n") + ) + .setPositiveButton(android.R.string.ok, null) + .show() + } + } + + private fun showProgress(binding: DialogGeoDownloadBinding, file: String, progress: XrayGeoAssets.Progress?) { + binding.text.text = progress?.let(XrayGeoAssets::progressText) ?: getString(R.string.xray_geo_downloading, file) + val percent = progress?.percent ?: -1 + val bar = binding.progress + val indeterminate = percent < 0 + // The indicator switches mode only while hidden. + if (bar.isIndeterminate != indeterminate) { + bar.visibility = View.INVISIBLE + bar.isIndeterminate = indeterminate + bar.visibility = View.VISIBLE + } + if (!indeterminate) bar.setProgressCompat(percent, true) + } + + private companion object { + const val KEY_SOURCE = "xrayGeoSource" + const val KEY_DOWNLOAD = "xrayGeoDownload" + const val VALUE_CUSTOM = "custom" + + fun urlKey(file: String): String = + if (file == XrayGeoAssets.GEOIP) SettingsRegistry.XRAY_GEOIP_URL.key else SettingsRegistry.XRAY_GEOSITE_URL.key + + fun fileKey(file: String): String = if (file == XrayGeoAssets.GEOIP) "xrayGeoFileGeoip" else "xrayGeoFileGeosite" + } +} diff --git a/app/src/main/java/moe/matsuri/nb4a/NativeInterface.kt b/app/src/main/java/moe/matsuri/nb4a/NativeInterface.kt index 18df6b90..9e4b9c6d 100644 --- a/app/src/main/java/moe/matsuri/nb4a/NativeInterface.kt +++ b/app/src/main/java/moe/matsuri/nb4a/NativeInterface.kt @@ -84,6 +84,9 @@ class NativeInterface : PlatformInterface { } } + // Below API 29 the core finds the owner's uid through procfs and asks for its packages here. + override fun packageNamesByUid(uid: Int): StringIterator = packageNamesOf(uid).toStringIterator() + private fun packageNamesOf(uid: Int): List { if (uid <= 1000) return listOf("android") PackageCache.awaitLoadSync() @@ -109,6 +112,7 @@ class NativeInterface : PlatformInterface { var name: String? = null var index: Int = Int.MIN_VALUE var network: Network? = null + var isExpensive: Boolean = false } private val ifaceReportStates = Collections.synchronizedMap( @@ -127,6 +131,7 @@ class NativeInterface : PlatformInterface { state.name = null state.index = Int.MIN_VALUE state.network = null + state.isExpensive = false listener.updateDefaultInterface("", -1, false, false) } @@ -149,19 +154,21 @@ class NativeInterface : PlatformInterface { Thread.sleep(100) return@repeat } - // Capability-change storms repeat the same interface; skip them without a JNI round trip. - if (state.name == linkProperties.interfaceName && state.index == interfaceIndex && state.network == network) { - return - } - val changed = state.name != null - state.name = linkProperties.interfaceName - state.index = interfaceIndex - state.network = network val capabilities = SagerNet.connectivity.getNetworkCapabilities(network) val isExpensive = capabilities?.let { it.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || !it.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) } ?: false + val sameInterface = state.name == linkProperties.interfaceName && state.index == interfaceIndex && + state.network == network + // Capability-change storms repeat the same interface; skip them without a JNI round trip. A metering change + // alone (a Wi-Fi marked metered) is still reported so the core re-reads its interfaces, but is no switch. + if (sameInterface && state.isExpensive == isExpensive) return + val changed = !sameInterface && state.name != null + state.name = linkProperties.interfaceName + state.index = interfaceIndex + state.network = network + state.isExpensive = isExpensive listener.updateDefaultInterface(linkProperties.interfaceName, interfaceIndex, isExpensive, false) if (changed) onDefaultInterfaceChanged(network) return diff --git a/app/src/main/java/moe/matsuri/nb4a/utils/WebViewUtil.kt b/app/src/main/java/moe/matsuri/nb4a/utils/WebViewUtil.kt deleted file mode 100644 index 7ef54eaa..00000000 --- a/app/src/main/java/moe/matsuri/nb4a/utils/WebViewUtil.kt +++ /dev/null @@ -1,18 +0,0 @@ -package moe.matsuri.nb4a.utils - -import android.os.Build -import android.webkit.WebResourceError -import android.webkit.WebResourceRequest -import android.webkit.WebView -import io.nekohasekai.sagernet.ktx.Logs - -object WebViewUtil { - fun onReceivedError( - view: WebView?, request: WebResourceRequest?, error: WebResourceError? - ) { - if (Build.VERSION.SDK_INT >= 23 && error != null) { - Logs.e("WebView error description: ${error.description}") - } - Logs.e("WebView error: ${error.toString()}") - } -} diff --git a/app/src/main/res/drawable/ic_throne_tile.png b/app/src/main/res/drawable/ic_throne_tile.png deleted file mode 100644 index 942dbe9b..00000000 Binary files a/app/src/main/res/drawable/ic_throne_tile.png and /dev/null differ diff --git a/app/src/main/res/drawable/ic_throne_tile.xml b/app/src/main/res/drawable/ic_throne_tile.xml new file mode 100644 index 00000000..b7eca6d7 --- /dev/null +++ b/app/src/main/res/drawable/ic_throne_tile.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/layout/dialog_geo_download.xml b/app/src/main/res/layout/dialog_geo_download.xml new file mode 100644 index 00000000..c3d993a7 --- /dev/null +++ b/app/src/main/res/layout/dialog_geo_download.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/app/src/main/res/layout/layout_dashboard.xml b/app/src/main/res/layout/layout_dashboard.xml new file mode 100644 index 00000000..4caf9c5d --- /dev/null +++ b/app/src/main/res/layout/layout_dashboard.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/layout_webview.xml b/app/src/main/res/layout/layout_webview.xml deleted file mode 100644 index c874645d..00000000 --- a/app/src/main/res/layout/layout_webview.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/dashboard_menu.xml b/app/src/main/res/menu/dashboard_menu.xml new file mode 100644 index 00000000..2c20468f --- /dev/null +++ b/app/src/main/res/menu/dashboard_menu.xml @@ -0,0 +1,9 @@ + + + + diff --git a/app/src/main/res/menu/yacd_menu.xml b/app/src/main/res/menu/yacd_menu.xml deleted file mode 100644 index c60ceb60..00000000 --- a/app/src/main/res/menu/yacd_menu.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index d8b235c8..bace029d 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -8,7 +8,6 @@ سلسلة أدوات البروکسی العالمية لنظام Android ، مكتوبة بلغة Kotlin. مشروع تحديث - الجلاد تبديل تمكين تعطيل @@ -1124,7 +1123,6 @@ لا يحتوي هذا الجهاز على منتقي مستندات، لذا يُحفظ الملف في:\n%1$s لا يوجد منتقي ملفات لا كاميرا: اختر صورة تحتوي على رمز QR - تعيين رابط اللوحة افتراضي افتراضي (%s) المظهر diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 64e04f0f..0d679a15 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -53,7 +53,6 @@ Толькі проксі Рэжым абслугоўвання Абнаўленне - Перамыкач Пераключыць Уключыць Выключыць @@ -1078,7 +1077,6 @@ Сэрвіс проксі Сэрвіс абнаўлення падпіскі Сэрвіс VPN - Задаць URL панэлі Прадвызначаны Прадвызначаны (%s) Налады diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 0d830ae4..c735c4d0 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -17,7 +17,6 @@ Umschalten Aktivieren Deaktivieren - Umschalter sing-box-Dashboard Kopieren Navigationsleiste öffnen @@ -247,7 +246,6 @@ Min. TLS-Version für Abonnements Port-Hopping-Intervall (Sekunden) uTLS-Fingerabdruck - Panel-URL festlegen Clash API aktivieren Protokollstufe Die Clash API für externe Controller und das Dashboard bereitstellen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 83c7c13b..9f55dd4e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -17,7 +17,6 @@ Alternar Habilitar Deshabilitar - Conmutador Panel de sing-box Copiar Abrir panel de navegación @@ -247,7 +246,6 @@ Versión mínima de TLS de suscripción Intervalo de salto de puertos (segundos) Huella uTLS - Establecer URL del panel Activar Clash API Nivel de registro Servir la Clash API para controladores externos y el panel diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index de466833..2d3ea57f 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -13,7 +13,6 @@ تغییر وضعیت فعال‌سازی غیرفعال‌سازی - سوییچر sing-box تنظیمات کپی @@ -237,7 +236,6 @@ نسخه TLS مدت زمان Port hopping (به ثانیه) اثر انگشت uTLS - تعیین لینک صفحه فعال کردن Clash API سطح ثبت آمار Flow (VLESS دستورالعمل فرعی) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 8ab958f1..75184f08 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -17,7 +17,6 @@ Basculer Activer Désactiver - Commutateur Tableau de bord sing-box Copier Ouvrir le panneau de navigation @@ -247,7 +246,6 @@ Version TLS minimale des abonnements Intervalle de saut de ports (secondes) Empreinte uTLS - Définir l\'URL du panneau Activer Clash API Niveau de journalisation Servir l\'API Clash aux contrôleurs externes et au tableau de bord diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 9fce51fc..dbcc5c7d 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -42,7 +42,6 @@ Hanya proxy Mode Layanan Perbarui - Pengalih Beralih Aktifkan Nonaktifkan @@ -244,7 +243,6 @@ Versi TLS minimum langganan Interval lompatan port (detik) Sidik jari uTLS - Tetapkan URL panel Aktifkan Clash API Tingkat log Sajikan Clash API untuk pengendali eksternal dan dasbor diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 2294dc99..c6090aaf 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -17,7 +17,6 @@ Attiva/disattiva Abilita Disabilita - Interruttore Dashboard sing-box Copia Apri riquadro di navigazione @@ -247,7 +246,6 @@ Versione TLS minima abbonamento Intervallo port hopping (secondi) Impronta uTLS - Imposta URL pannello Abilita Clash API Livello di log Servì la Clash API per controller esterni e dashboard diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index c4262db9..07fb93a5 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -10,7 +10,6 @@ プロキシのみ サービスモード アップデート - スイッチャー トグル 有効化 無効化 @@ -220,7 +219,6 @@ サブスクリプションの最小 TLS バージョン ポートホッピング間隔 (秒) uTLS フィンガープリント - パネル URL を設定 Clash API を有効化 ログレベル Flow (VLESS サブプロトコル) diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index f1cfb418..70bd0de1 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -13,7 +13,6 @@ 대하여 그룹 구성 - 스위처 sing-box 대시보드 복사 업데이트 @@ -219,7 +218,6 @@ 구독 최소 TLS 버전 포트 호핑 간격 (초) uTLS 지문 - 패널 URL 설정 Clash API 활성화 로그 레벨 Flow (VLESS 하위 프로토콜) diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 367c558f..17072f3b 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -169,7 +169,6 @@ Tillat tilkoblinger fra LAN Servicemodus Oppdater - Bytter Veksle Aktiver Deaktiver @@ -254,7 +253,6 @@ Laveste TLS-versjon for abonnement Intervall for porthopping (sekunder) uTLS-fingeravtrykk - Sett panel-URL Aktiver Clash API Loggnivå Betjen Clash API for eksterne kontrollere og oversikten diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index e7e6d80f..4255e42d 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -17,7 +17,6 @@ Omschakelen Inschakelen Uitschakelen - Schakelaar sing-box-dashboard Kopiëren Navigatiemenu openen @@ -247,7 +246,6 @@ Min. TLS-versie voor abonnement Port-hopping-interval (seconden) uTLS-vingerafdruk - Paneel-URL instellen Clash API inschakelen Logniveau De Clash API serveren voor externe controllers en het dashboard diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index d5205542..ea80efdb 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -17,7 +17,6 @@ Alternar Ativar Desativar - Interruptor Painel sing-box Copiar Abrir gaveta de navegação @@ -247,7 +246,6 @@ Versão TLS mínima da assinatura Intervalo de port hopping (segundos) Impressão digital uTLS - Definir URL do painel Ativar Clash API Nível de log Servir a Clash API para controladores externos e o painel diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 2e8ae394..d220c5af 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -185,7 +185,6 @@ Прокси-сервис Служба обновления подписки VPN-сервис -Установить URL панель Настройки Поделиться QR-код @@ -208,7 +207,6 @@ Подписки Число параллельных подключений Тема -Переключатель Настройки защиты TLS Контроллер перегрузки Отключить SNI diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 05139b98..67be9913 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -32,7 +32,6 @@ Sadece vekil sunucu Servis Modu Güncelle - Değiştirici Aç/Kapat Etkinleştir Devre Dışı Bırak @@ -258,7 +257,6 @@ Abonelik için en düşük TLS sürümü Port atlama aralığı (saniye) uTLS parmak izi - Panel URL\'si ayarla Clash API\'yi etkinleştir Günlük seviyesi Harici denetleyiciler ve panel için Clash API\'yi sun diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index c8ded38f..dfbe2ca5 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -13,7 +13,6 @@ Перемикач Увімкнути Вимкнути - Перемикач Панель керування sing-box Копіювати @@ -231,7 +230,6 @@ Мінімальна версія TLS для підписки Інтервал зміни портів (секунди) Відбиток uTLS - Встановити URL панелі Увімкнути Clash API Рівень журналу Flow (підпротокол VLESS) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 059644d6..17b56dbd 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -182,7 +182,6 @@ 清理测试结果 连接重置 超时 - 开关 HTTP 代理绕过列表 一行一个,# 开头代表注释。示例:*zhihu.com 基本 @@ -242,7 +241,6 @@ 拥塞控制 UDP 转发模式 sing-box 仪表板 - 设置面板 URL 启用 Clash API Flow(VLESS 子协议) 重新启动应用程序以应用更改 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index a78cb22f..2adfd19a 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -1405,7 +1405,6 @@ 停用憑證檢查。當啟用時,此設定檔將和純文字同等安全 允許不安全的連線 在通知中一併顯示未被代理的流量速度 - 切換器 額外標頭 代理 選取設定檔… @@ -1496,7 +1495,6 @@ 訂閱最低 TLS 版本 端口跳躍間隔(秒) uTLS 指紋 - 設定面板 URL 啟用 Clash API 日誌級別 Flow(VLESS 子協議) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index fedf186a..73a01c89 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -141,7 +141,6 @@ 在通知中一併顯示未被代理的流量速度 顯示直連速度 其他設定 - 切換器 版本 (%s) 額外標頭 主題 @@ -242,7 +241,6 @@ 訂閱最低 TLS 版本 埠跳躍間隔(秒) uTLS 指紋 - 設定面板 URL 啟用 Clash API 日誌級別 Flow(VLESS 子協議) diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index c70f7064..376aed3d 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -566,6 +566,13 @@ udp icmp + + + @string/route_rule_network_type_wifi + @string/route_rule_network_type_cellular + @string/route_rule_network_type_ethernet + @string/route_rule_network_type_other + @string/route_rule_any http diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 841554ab..45ed27a5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -19,7 +19,6 @@ Toggle Enable Disable - Switcher sing-box dashboard Copy @@ -294,10 +293,9 @@ Subscription min TLS version Port hopping interval (seconds) uTLS fingerprint - Set panel URL Enable Clash API Log level - Serve the Clash API for external controllers and the dashboard + Serve the Clash API for external controllers Flow (VLESS sub-protocol) Restart the app to apply changes ShadowTLS @@ -649,10 +647,59 @@ Listen port Listen address Secret + sing-box API / Dashboard + Enable sing-box API + Loopback only. Serves the sing-box dashboard in the side menu, with live traffic, connections and logs. + Leave empty to generate a new random secret. The dashboard in the app picks it up by itself. + Reload + Retry + Loading the dashboard… + The sing-box API is off + The dashboard is served by the core\'s sing-box API. Turn it on in Settings › Core. + Core settings + The service is not running + Start a profile first; the dashboard is served by the running core. + The dashboard is missing + This build does not include the sing-box dashboard files, or they could not be unpacked; the log says why. Reloading the service tries again. + Could not load the dashboard + Nothing answers on 127.0.0.1:%1$d (%2$s). Reload the service after changing the sing-box API settings. A profile with a custom full config serves the dashboard only when its own config has an api service on that port. + The core answered with HTTP %1$d. + The dashboard needs Android System WebView, which is missing or disabled on this device. Xray VLESS preference XHTTP only XHTTP and Reality All VLESS + Xray geo assets + geoip.dat and geosite.dat for Xray configs with geoip: or geosite: rules + Geo assets + Downloaded automatically when an Xray config with geoip: or geosite: rules starts or is tested. + Source + Custom + geoip.dat URL + geosite.dat URL + Not an http(s) URL: %s + Files + Tap a file to download it again + Installed · %1$s · %2$s + Not installed, downloaded when a config needs it + Download now + Both files from the selected source + Download geo assets + Downloading %s… + Downloaded %s. + Downloading %1$s… %2$d%% + Downloading %1$s… %2$s + Could not download %1$s from %2$s: %3$s + the server replied HTTP %d + the server returned an empty response + could not save %s + aborted + An Xray config needs %s, which cannot be downloaded automatically: only geoip.dat and geosite.dat have a source. + %s\nCheck the source in Settings › Core › Xray geo assets, or connect with another profile and use Download now there. + a category + The Xray config \"%1$s\" needs \"%2$s\", but the installed %3$s does not contain it. Downloading it again from the same source will not help: choose a source that provides it in Settings › Core › Xray geo assets. + The Xray config \"%1$s\" needs %2$s, which is not installed. Download it in Settings › Core › Xray geo assets. + Xray configs left untested Skip TLS certificate verification by default (allowInsecure) Use Mozilla certificate store NTP client @@ -706,7 +753,7 @@ %d rule was left out because its server profile no longer exists. %d rules were left out because their server profiles no longer exist. - Rules that match apps only work on Android. The desktop ignores the app condition, so there such a rule matches every connection. + Some rules use conditions only Android supports. The desktop skips rules with network type, metered network or package name regex when it imports this link, and rules that match apps never match there. Import routing profile Add this routing profile?\n\nName: %s Note: @@ -793,6 +840,7 @@ private source IP all connections inverted + metered network Route rule Drag to reorder Rule @@ -813,6 +861,15 @@ One address or CIDR per line, e.g. 10.0.0.0/8 Rule-sets Apps + Unknown apps + Connections whose app cannot be identified + Network type + Wi-Fi + Mobile data + Ethernet + Other + Metered network + Match only while the current network is metered, like mobile data or a Wi-Fi marked as metered Advanced Advanced (%d set) Show the other conditions and options @@ -842,6 +899,8 @@ Override port No drop Keep the reject method instead of switching to drop after repeated rejects + Package name regex + Apps whose package name matches one of these also count as listed in Apps. One regular expression per line (Go RE2 syntax), e.g. ^com\\.google\\. Process name (desktop only) Process path (desktop only) Process path regex (desktop only) @@ -1593,6 +1652,7 @@ %d profiles without any group were skipped. %d profiles missing from their group\'s list were added at its end. %d rules of missing routing profiles were skipped. + %1$d routing rules were skipped because they use fields this app does not support (%2$s). %d raw routing profiles from Throne desktop are kept read-only; Android cannot use them. The backup lets other devices connect (inbound address %s); this was turned off. Turn it on again in the inbound settings if you need it. diff --git a/app/src/main/res/xml/route_rule_preferences.xml b/app/src/main/res/xml/route_rule_preferences.xml index 3afba753..9e1805d5 100644 --- a/app/src/main/res/xml/route_rule_preferences.xml +++ b/app/src/main/res/xml/route_rule_preferences.xml @@ -66,6 +66,10 @@ app:icon="@drawable/ic_navigation_apps" app:key="package_name" app:title="@string/route_rule_apps" /> + + + + + + + + + + diff --git a/app/src/main/res/xml/settings_xray_geo.xml b/app/src/main/res/xml/settings_xray_geo.xml new file mode 100644 index 00000000..e622bd0b --- /dev/null +++ b/app/src/main/res/xml/settings_xray_geo.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + diff --git a/buildScript/fdroid/prebuild.sh b/buildScript/fdroid/prebuild.sh index f7828f3b..7711f0a6 100755 --- a/buildScript/fdroid/prebuild.sh +++ b/buildScript/fdroid/prebuild.sh @@ -35,5 +35,15 @@ test -s "$ROUTES/srslist.h" curl -fsSL --retry 3 https://codeload.github.com/throneproj/routeprofiles/tar.gz/refs/heads/profile \ | tar -xz --strip-components=1 -C "$ROUTES/profile" +# Bundle the sing-box dashboard (same as .github/actions/sing-box-dashboard). +DASHBOARD="$PWD/app/src/main/assets/sb-dashboard" +rm -rf "$DASHBOARD" +mkdir -p "$DASHBOARD" +curl -fsSL --retry 3 https://codeload.github.com/SagerNet/sing-box-dashboard/tar.gz/refs/heads/gh-pages \ + | tar -xz --strip-components=1 -C "$DASHBOARD" +find "$DASHBOARD" -type f -name '.*' -delete +test -z "$(find "$DASHBOARD" -type d -name '_*')" +test -s "$DASHBOARD/index.html" + # F-Droid builds stable tags: the same versionCode as the GitHub release (VERSION_CODE * 1000 + 999). grep -q '^throne.build=' gradle.properties || printf '\nthrone.build=999\n' >> gradle.properties diff --git a/nb4a.properties b/nb4a.properties index 05387d88..7b02af34 100644 --- a/nb4a.properties +++ b/nb4a.properties @@ -1,4 +1,4 @@ PACKAGE_NAME=com.nb4a.throne VERSION_NAME=2.0.1 VERSION_CODE=100 -THRONE_CORE_REF=77fad40712849e67166bb8469b527b3c8030fcc6 \ No newline at end of file +THRONE_CORE_REF=bd929e304f65897676600d81a1a3b4befe2cbe3a \ No newline at end of file