diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
new file mode 100644
index 0000000000..0ba3740f88
--- /dev/null
+++ b/.github/workflows/checks.yml
@@ -0,0 +1,63 @@
+name: Regression checks
+on:
+ push:
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: checks-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ regression:
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: '17'
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.25.5'
+ cache-dependency-path: libcore/go.sum
+ - uses: android-actions/setup-android@v3
+ - uses: gradle/actions/setup-gradle@v4
+ - name: Install Android toolchain
+ run: sdkmanager 'platforms;android-35' 'build-tools;35.0.1' 'ndk;25.0.8775105'
+ - name: Build libcore from verified sources
+ run: ./run lib core
+ - name: Prepare Geo assets
+ run: ./run init action gradle
+ - name: Android JVM regressions and compile classpath
+ run: |
+ ./gradlew -I app/src/test/phase2.init.gradle -I app/src/test/fixtures/classpath.init.gradle :app:testOssDebugUnitTest :app:writeFixtureClasspath
+ - name: Generate actual ConfigBuilder fixtures
+ run: |
+ python3 app/src/test/fixtures/legacy_geo_fixture.py \
+ --repo "$PWD" --classpath-file app/build/fixture-classpath.txt \
+ --android-jar "$ANDROID_HOME/platforms/android-35/android.jar" \
+ --jdk "$JAVA_HOME" --output "$RUNNER_TEMP/neko-fixtures"
+ - name: Go core, DNS callbacks, Geo and generated config regressions
+ run: |
+ mkdir -p "$RUNNER_TEMP/neko-geo"
+ xz -dc app/src/main/assets/sing-box/geoip.db.xz > "$RUNNER_TEMP/neko-geo/geoip.db"
+ xz -dc app/src/main/assets/sing-box/geosite.db.xz > "$RUNNER_TEMP/neko-geo/geosite.db"
+ export NEKO_GEO_ASSETS="$RUNNER_TEMP/neko-geo"
+ export NEKO_CONFIG_FIXTURES="$RUNNER_TEMP/neko-fixtures/config-fixture/configs"
+ export NEKO_REMOTE_CONFIG_FIXTURES="$RUNNER_TEMP/neko-fixtures/config-fixture/remotes/configs"
+ export NEKO_LEGACY_GEO_FIXTURES="$RUNNER_TEMP/neko-fixtures/config-fixture/geo-configs"
+ bash buildScript/test/core.sh
+ - name: Upload test evidence
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: regression-results
+ path: |
+ app/build/reports/tests/
+ app/build/test-results/
+ ${{ runner.temp }}/neko-fixtures/*.log
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 7aecc1c058..e54505d53c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -60,13 +60,23 @@ jobs:
with:
path: ~/.gradle
key: gradle-oss-${{ hashFiles('**/*.gradle.kts') }}
+ - name: Prepare fork release signing
+ env:
+ RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
+ run: |
+ test -n "$RELEASE_KEYSTORE_BASE64"
+ umask 077
+ printf '%s' "$RELEASE_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/nekobox-release.p12"
+ echo "KEYSTORE_PATH=$RUNNER_TEMP/nekobox-release.p12" >> "$GITHUB_ENV"
- name: Gradle Build
env:
BUILD_PLUGIN: none
+ KEYSTORE_PASS: ${{ secrets.KEYSTORE_PASS }}
+ ALIAS_NAME: ${{ secrets.ALIAS_NAME }}
+ ALIAS_PASS: ${{ secrets.ALIAS_PASS }}
run: |
echo "sdk.dir=${ANDROID_HOME}" > local.properties
echo "ndk.dir=${ANDROID_HOME}/ndk/25.0.8775105" >> local.properties
- export LOCAL_PROPERTIES="${{ secrets.LOCAL_PROPERTIES }}"
./run init action gradle
./gradlew app:assembleOssRelease
APK=$(find app/build/outputs/apk -name '*arm64-v8a*.apk')
@@ -90,13 +100,13 @@ jobs:
name: APKs
path: artifacts
- name: Release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RELEASE_TAG: ${{ github.event.inputs.tag }}
run: |
- wget -O ghr.tar.gz https://github.com/tcnksm/ghr/releases/download/v0.13.0/ghr_v0.13.0_linux_amd64.tar.gz
- tar -xvf ghr.tar.gz
- mv ghr*linux_amd64/ghr .
mkdir apks
find artifacts -name "*.apk" -exec cp {} apks \;
- ./ghr -delete -t "${{ github.token }}" -n "${{ github.event.inputs.tag }}" "${{ github.event.inputs.tag }}" apks
+ gh release create "$RELEASE_TAG" apks/*.apk --verify-tag --draft --title "$RELEASE_TAG"
play:
name: Build Play Bundle
if: github.event.inputs.play != 'y'
@@ -124,14 +134,25 @@ jobs:
- name: Checkout Library
run: |
git submodule update --init 'app/*'
+ - name: Prepare fork release signing
+ env:
+ RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
+ run: |
+ test -n "$RELEASE_KEYSTORE_BASE64"
+ umask 077
+ printf '%s' "$RELEASE_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/nekobox-release.p12"
+ echo "KEYSTORE_PATH=$RUNNER_TEMP/nekobox-release.p12" >> "$GITHUB_ENV"
- name: Gradle Build
+ env:
+ KEYSTORE_PASS: ${{ secrets.KEYSTORE_PASS }}
+ ALIAS_NAME: ${{ secrets.ALIAS_NAME }}
+ ALIAS_PASS: ${{ secrets.ALIAS_PASS }}
run: |
echo "sdk.dir=${ANDROID_HOME}" > local.properties
echo "ndk.dir=${ANDROID_HOME}/ndk/25.0.8775105" >> local.properties
- export LOCAL_PROPERTIES="${{ secrets.LOCAL_PROPERTIES }}"
./run init action gradle
./gradlew bundlePlayRelease
- - uses: actions/upload-artifact@v3
+ - uses: actions/upload-artifact@v4
with:
name: AAB
path: app/build/outputs/bundle/playRelease/app-play-release.aab
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index a8ba7b0e2d..b7b3b017db 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -66,7 +66,7 @@ dependencies {
implementation("com.blacksquircle.ui:language-json:2.6.0")
implementation("com.squareup.okhttp3:okhttp:5.0.0-alpha.3")
- implementation("org.yaml:snakeyaml:1.30")
+ implementation("org.yaml:snakeyaml:2.7")
implementation("com.github.daniel-stoneuk:material-about-library:3.2.0-rc01")
implementation("com.jakewharton:process-phoenix:2.1.2")
implementation("com.esotericsoftware:kryo:5.2.1")
diff --git a/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/7.json b/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/7.json
new file mode 100644
index 0000000000..6137f9df48
--- /dev/null
+++ b/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/7.json
@@ -0,0 +1,446 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 7,
+ "identityHash": "efdec71bbf810033c968aa3275c0592d",
+ "entities": [
+ {
+ "tableName": "proxy_groups",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userOrder` INTEGER NOT NULL, `ungrouped` INTEGER NOT NULL, `name` TEXT, `type` INTEGER NOT NULL, `subscription` BLOB, `order` INTEGER NOT NULL, `isSelector` INTEGER NOT NULL, `frontProxy` INTEGER NOT NULL, `landingProxy` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userOrder",
+ "columnName": "userOrder",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "ungrouped",
+ "columnName": "ungrouped",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "subscription",
+ "columnName": "subscription",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "order",
+ "columnName": "order",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isSelector",
+ "columnName": "isSelector",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "frontProxy",
+ "columnName": "frontProxy",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "landingProxy",
+ "columnName": "landingProxy",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "proxy_entities",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL, `type` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL, `tx` INTEGER NOT NULL, `rx` INTEGER NOT NULL, `status` INTEGER NOT NULL, `ping` INTEGER NOT NULL, `uuid` TEXT NOT NULL, `error` TEXT, `socksBean` BLOB, `httpBean` BLOB, `ssBean` BLOB, `vmessBean` BLOB, `trojanBean` BLOB, `trojanGoBean` BLOB, `mieruBean` BLOB, `naiveBean` BLOB, `hysteriaBean` BLOB, `tuicBean` BLOB, `sshBean` BLOB, `wgBean` BLOB, `shadowTLSBean` BLOB, `anyTLSBean` BLOB, `chainBean` BLOB, `nekoBean` BLOB, `configBean` BLOB)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "groupId",
+ "columnName": "groupId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userOrder",
+ "columnName": "userOrder",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tx",
+ "columnName": "tx",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "rx",
+ "columnName": "rx",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "ping",
+ "columnName": "ping",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "error",
+ "columnName": "error",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "socksBean",
+ "columnName": "socksBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "httpBean",
+ "columnName": "httpBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "ssBean",
+ "columnName": "ssBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "vmessBean",
+ "columnName": "vmessBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "trojanBean",
+ "columnName": "trojanBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "trojanGoBean",
+ "columnName": "trojanGoBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "mieruBean",
+ "columnName": "mieruBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "naiveBean",
+ "columnName": "naiveBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "hysteriaBean",
+ "columnName": "hysteriaBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "tuicBean",
+ "columnName": "tuicBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "sshBean",
+ "columnName": "sshBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "wgBean",
+ "columnName": "wgBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "shadowTLSBean",
+ "columnName": "shadowTLSBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "anyTLSBean",
+ "columnName": "anyTLSBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "chainBean",
+ "columnName": "chainBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "nekoBean",
+ "columnName": "nekoBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "configBean",
+ "columnName": "configBean",
+ "affinity": "BLOB",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "groupId",
+ "unique": false,
+ "columnNames": [
+ "groupId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `groupId` ON `${TABLE_NAME}` (`groupId`)"
+ }
+ ],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "rules",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `config` TEXT NOT NULL DEFAULT '', `userOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `domains` TEXT NOT NULL, `ip` TEXT NOT NULL, `port` TEXT NOT NULL, `sourcePort` TEXT NOT NULL, `network` TEXT NOT NULL, `source` TEXT NOT NULL, `protocol` TEXT NOT NULL, `outbound` INTEGER NOT NULL, `packages` TEXT NOT NULL, `remoteRuleSetTags` TEXT NOT NULL DEFAULT '')",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "config",
+ "columnName": "config",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "userOrder",
+ "columnName": "userOrder",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "enabled",
+ "columnName": "enabled",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "domains",
+ "columnName": "domains",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "ip",
+ "columnName": "ip",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "port",
+ "columnName": "port",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sourcePort",
+ "columnName": "sourcePort",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "network",
+ "columnName": "network",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "source",
+ "columnName": "source",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "protocol",
+ "columnName": "protocol",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "outbound",
+ "columnName": "outbound",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "packages",
+ "columnName": "packages",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "remoteRuleSetTags",
+ "columnName": "remoteRuleSetTags",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "remote_rule_sets",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `tag` TEXT NOT NULL, `url` TEXT NOT NULL, `format` TEXT NOT NULL, `updateIntervalMinutes` INTEGER NOT NULL, `enabled` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tag",
+ "columnName": "tag",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "format",
+ "columnName": "format",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updateIntervalMinutes",
+ "columnName": "updateIntervalMinutes",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "enabled",
+ "columnName": "enabled",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_remote_rule_sets_tag",
+ "unique": true,
+ "columnNames": [
+ "tag"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_remote_rule_sets_tag` ON `${TABLE_NAME}` (`tag`)"
+ }
+ ],
+ "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, 'efdec71bbf810033c968aa3275c0592d')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 69818c852e..1789a22bdc 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -14,6 +14,7 @@
+
@@ -275,9 +276,13 @@
+ tools:ignore="ForegroundServicePermission">
+
+
@@ -344,4 +349,4 @@
-
\ No newline at end of file
+
diff --git a/app/src/main/java/io/nekohasekai/sagernet/BootReceiver.kt b/app/src/main/java/io/nekohasekai/sagernet/BootReceiver.kt
index fd037ad49b..b9319244f3 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/BootReceiver.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/BootReceiver.kt
@@ -23,6 +23,9 @@ class BootReceiver : BroadcastReceiver() {
}
override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action !in setOf(Intent.ACTION_BOOT_COMPLETED,
+ Intent.ACTION_LOCKED_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED)) return
+
runOnDefaultDispatcher {
SubscriptionUpdater.reconfigureUpdater()
}
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 e760983dcd..fa86e3a9f8 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt
@@ -317,7 +317,11 @@ class BaseService {
val profile = SagerDatabase.proxyDao.getById(DataStore.selectedProxy)
this as Context
if (profile == null) { // gracefully shutdown: https://stackoverflow.com/q/47337857/2245107
- data.notification = createNotification("")
+ try {
+ data.notification = createNotification("")
+ } catch (e: Exception) {
+ Logs.w(e)
+ }
stopRunner(false, getString(R.string.profile_empty))
return Service.START_NOT_STICKY
}
@@ -400,4 +404,4 @@ class BaseService {
}
}
-}
\ No newline at end of file
+}
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 0ac84ac6a3..eb1902b8a9 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt
@@ -7,9 +7,9 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED
+import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
import android.os.Build
import android.text.format.Formatter
-import android.widget.Toast
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import io.nekohasekai.sagernet.Action
@@ -137,6 +137,8 @@ class ServiceNotification(
Theme.apply(service)
builder.color = service.getColorAttr(R.attr.colorPrimary)
+ // Enter the foreground before starting the proxy. Let startup handle failures.
+ show()
service.registerReceiver(this, IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_SCREEN_OFF)
@@ -144,7 +146,7 @@ class ServiceNotification(
runOnMainDispatcher {
updateActions()
- show()
+ update()
}
}
@@ -184,26 +186,18 @@ class ServiceNotification(
}
- private suspend fun show() =
- useBuilder {
- try {
- if (Build.VERSION.SDK_INT >= 34) {
- (service as Service).startForeground(
- notificationId,
- it.build(),
- FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED
- )
- } else {
- (service as Service).startForeground(notificationId, it.build())
- }
- } catch (e: Exception) {
- Toast.makeText(
- SagerNet.application,
- "startForeground: $e",
- Toast.LENGTH_LONG
- ).show()
- }
+ private fun show() {
+ val notification = builder.build()
+ if (Build.VERSION.SDK_INT >= 34) {
+ (service as Service).startForeground(
+ notificationId, notification,
+ if (service is VpnService) FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED
+ else FOREGROUND_SERVICE_TYPE_SPECIAL_USE
+ )
+ } else {
+ (service as Service).startForeground(notificationId, notification)
}
+ }
private suspend fun update() = useBuilder {
NotificationManagerCompat.from(service as Service).notify(notificationId, it.build())
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 ba21dde44d..e23c64f1c8 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt
@@ -154,8 +154,7 @@ object ProfileManager {
}
suspend fun createRule(rule: RuleEntity, post: Boolean = true): RuleEntity {
- rule.userOrder = SagerDatabase.rulesDao.nextOrder() ?: 1
- rule.id = SagerDatabase.rulesDao.createRule(rule)
+ RemoteRuleSetManager.saveRoute(rule, insert = true)
if (post) {
ruleIterator { onAdd(rule) }
}
@@ -163,7 +162,7 @@ object ProfileManager {
}
suspend fun updateRule(rule: RuleEntity) {
- SagerDatabase.rulesDao.updateRule(rule)
+ RemoteRuleSetManager.saveRoute(rule, insert = false)
ruleIterator { onUpdated(rule) }
}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/RemoteRuleSetDownload.kt b/app/src/main/java/io/nekohasekai/sagernet/database/RemoteRuleSetDownload.kt
new file mode 100644
index 0000000000..ff08246302
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/RemoteRuleSetDownload.kt
@@ -0,0 +1,17 @@
+package io.nekohasekai.sagernet.database
+
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.io.File
+
+internal object RemoteRuleSetDownload {
+ fun fetch(client: OkHttpClient, url: String, cache: File, validate: (File) -> Unit, rename: (File, File) -> Unit) {
+ // A subscription response must not redirect a direct-network request to another target.
+ val direct = client.newBuilder().followRedirects(false).followSslRedirects(false).build()
+ direct.newCall(Request.Builder().url(url).build()).execute().use { response ->
+ if (!response.isSuccessful) throw RemoteRuleSetManager.DownloadFailure("HTTP ${response.code}")
+ val body = response.body ?: throw RemoteRuleSetManager.DownloadFailure("Empty HTTP response")
+ body.byteStream().use { RemoteRuleSetStore.replace(cache, it, validate, rename) }
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/RemoteRuleSetEntity.kt b/app/src/main/java/io/nekohasekai/sagernet/database/RemoteRuleSetEntity.kt
new file mode 100644
index 0000000000..cd11a12e88
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/RemoteRuleSetEntity.kt
@@ -0,0 +1,27 @@
+package io.nekohasekai.sagernet.database
+
+import androidx.room.*
+
+@Entity(tableName = "remote_rule_sets", indices = [Index(value = ["tag"], unique = true)])
+data class RemoteRuleSetEntity(
+ @PrimaryKey(autoGenerate = true) var id: Long = 0,
+ var name: String = "",
+ var tag: String = "",
+ var url: String = "",
+ var format: String = "source",
+ var updateIntervalMinutes: Long = 1440,
+ var enabled: Boolean = true,
+) {
+ @androidx.room.Dao
+ interface Dao {
+ @Query("SELECT * FROM remote_rule_sets ORDER BY id")
+ fun all(): List
+ @Query("SELECT * FROM remote_rule_sets WHERE id = :id")
+ fun get(id: Long): RemoteRuleSetEntity?
+ @Insert fun insert(entity: RemoteRuleSetEntity): Long
+ @Update fun update(entity: RemoteRuleSetEntity)
+ @Query("DELETE FROM remote_rule_sets WHERE id = :id") fun delete(id: Long)
+ @Query("DELETE FROM remote_rule_sets") fun reset()
+ @Insert fun insertAll(entities: List)
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/RemoteRuleSetManager.kt b/app/src/main/java/io/nekohasekai/sagernet/database/RemoteRuleSetManager.kt
new file mode 100644
index 0000000000..0ae8cf1c79
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/RemoteRuleSetManager.kt
@@ -0,0 +1,214 @@
+package io.nekohasekai.sagernet.database
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import android.system.Os
+import androidx.work.*
+import androidx.work.multiprocess.RemoteWorkManager
+import io.nekohasekai.sagernet.SagerNet
+import io.nekohasekai.sagernet.ktx.Logs
+import libcore.Libcore
+import moe.matsuri.nb4a.SingBoxOptions.RuleSet
+import okhttp3.OkHttpClient
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.File
+import java.io.RandomAccessFile
+import java.net.Proxy
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.ConcurrentHashMap
+
+object RemoteRuleSetManager {
+ const val DOWNLOAD_CLIENT = "ruleset-fetch-direct"
+ private val root get() = File(SagerNet.application.filesDir, "remote-rule-sets")
+ private val dao get() = SagerDatabase.remoteRuleSetsDao
+ private fun cache(e: RemoteRuleSetEntity) = File(root, "${e.id}.${e.format}")
+ private val errors = ConcurrentHashMap()
+ private val downloadLock = Any()
+ fun updated(e: RemoteRuleSetEntity): Long = cache(e).lastModified()
+ fun error(id: Long): String {
+ errors[id]?.let { return it }
+ val info = try {
+ RemoteWorkManager.getInstance(SagerNet.application)
+ .getWorkInfos(WorkQuery.fromUniqueWorkNames(listOf("rule-set-$id"))).get(10, TimeUnit.SECONDS)
+ .maxByOrNull { it.outputData.getLong("completed", 0) }
+ } catch (_: Exception) { return "Background update status unavailable" } ?: return ""
+ val e = dao.get(id) ?: return ""
+ return if (info.outputData.getLong("completed", 0) > updated(e)) info.outputData.getString("error") ?: "" else ""
+ }
+
+ // Main UI and VPN processes share the same files. Serialize updates, edits and deletion.
+ @Synchronized
+ private fun locked(block: () -> T): T {
+ check(root.isDirectory || root.mkdirs())
+ return RandomAccessFile(File(root, ".lock"), "rw").use { file ->
+ file.channel.lock().use { block() }
+ }
+ }
+
+ fun save(value: RemoteRuleSetEntity): Long = locked {
+ RemoteRuleSetStore.validate(value)
+ val old = if (value.id == 0L) null else dao.get(value.id) ?: error("Rule-set no longer exists")
+ require(dao.all().none { it.id != value.id && it.tag == value.tag }) { "Duplicate rule-set tag" }
+ if (old != null && old.tag != value.tag) ensureUnreferenced(old.tag)
+ if (!value.enabled) {
+ require(SagerDatabase.rulesDao.enabledRules().none { value.tag in RemoteRuleSetStore.tags(it.remoteRuleSetTags) }) {
+ "Rule-set is used by an enabled route; disable the route first"
+ }
+ }
+ SagerDatabase.instance.runInTransaction {
+ if (old == null) value.id = dao.insert(value) else dao.update(value)
+ }
+ errors.remove(value.id)
+ schedule(value, ExistingWorkPolicy.REPLACE, 0)
+ value.id
+ }
+
+ private fun ensureUnreferenced(tag: String) {
+ require(SagerDatabase.rulesDao.allRules().none { tag in RemoteRuleSetStore.tags(it.remoteRuleSetTags) }) {
+ "Rule-set is referenced; remove route references first"
+ }
+ }
+
+ fun delete(id: Long) = locked {
+ val entity = dao.get(id) ?: return@locked
+ ensureUnreferenced(entity.tag)
+ dao.delete(id)
+ RemoteWorkManager.getInstance(SagerNet.application).cancelUniqueWork("rule-set-$id")
+ listOf("source", "binary").forEach { format ->
+ val file = File(root, "$id.$format")
+ check(!file.exists() || file.delete()) { "Cannot remove rule-set cache" }
+ }
+ errors.remove(id)
+ }
+
+ private fun schedule(e: RemoteRuleSetEntity, policy: ExistingWorkPolicy, delay: Long) {
+ val manager = RemoteWorkManager.getInstance(SagerNet.application)
+ if (!e.enabled) {
+ manager.cancelUniqueWork("rule-set-${e.id}")
+ return
+ }
+ val request = OneTimeWorkRequestBuilder()
+ .setInputData(workDataOf("id" to e.id))
+ .setInitialDelay(delay, TimeUnit.MINUTES)
+ .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
+ .build()
+ manager.enqueueUniqueWork("rule-set-${e.id}", policy, request).get(10, TimeUnit.SECONDS)
+ }
+
+ fun update(id: Long) = synchronized(downloadLock) {
+ check(root.isDirectory || root.mkdirs())
+ RandomAccessFile(File(root, ".download.lock"), "rw").use { file ->
+ file.channel.lock().use {
+ root.listFiles { entry -> entry.name.startsWith("candidate-") && entry.name.endsWith(".tmp") }
+ ?.forEach { it.delete() }
+ performUpdate(id)
+ }
+ }
+ }
+
+ private fun performUpdate(id: Long) {
+ val e = locked { dao.get(id) ?: error("Rule-set no longer exists") }
+ try {
+ download(e)
+ errors.remove(id)
+ } catch (exception: Exception) {
+ // Only controlled messages are stored/displayed; HTTP URLs and Core parse errors may contain secrets.
+ val message = if (exception is DownloadFailure) exception.message!! else "Rule-set update failed"
+ errors[id] = message
+ throw DownloadFailure(message)
+ }
+ }
+
+ fun backgroundUpdate(id: Long, active: () -> Boolean): String {
+ if (!active() || dao.get(id)?.enabled != true) return ""
+ try { update(id) } catch (_: Exception) { /* The manager keeps a visible, sanitized error. */ }
+ locked { if (active()) dao.get(id)?.let { schedule(it, ExistingWorkPolicy.APPEND_OR_REPLACE, it.updateIntervalMinutes) } }
+ return errors[id] ?: ""
+ }
+
+ private fun download(e: RemoteRuleSetEntity) {
+ val cm = SagerNet.application.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ val network = (listOfNotNull(SagerNet.underlyingNetwork) + cm.allNetworks).distinct().firstOrNull {
+ cm.getNetworkCapabilities(it)?.let { caps ->
+ caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
+ caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
+ } == true
+ } ?: throw DownloadFailure("No direct network available")
+ val client = OkHttpClient.Builder().proxy(Proxy.NO_PROXY)
+ .socketFactory(network.socketFactory).dns { network.getAllByName(it).toList() }
+ .connectTimeout(20, TimeUnit.SECONDS).readTimeout(30, TimeUnit.SECONDS)
+ .callTimeout(90, TimeUnit.SECONDS).followRedirects(false).followSslRedirects(false).build()
+ try {
+ RemoteRuleSetDownload.fetch(client, e.url, cache(e), { candidate -> validateContent(e, candidate) }) { from, to ->
+ locked {
+ val current = dao.get(e.id)
+ require(current != null && current.url == e.url && current.format == e.format) { "Subscription changed during update" }
+ Os.rename(from.absolutePath, to.absolutePath)
+ }
+ }
+ } finally {
+ client.connectionPool.evictAll()
+ client.dispatcher.executorService.shutdown()
+ }
+ }
+
+ private fun validateContent(e: RemoteRuleSetEntity, candidate: File) {
+ val config = JSONObject().put("log", JSONObject().put("disabled", true))
+ .put("route", JSONObject().put("rule_set", JSONArray().put(JSONObject()
+ .put("type", "local").put("tag", e.tag).put("format", e.format).put("path", candidate.absolutePath))))
+ try {
+ Libcore.newSingBoxInstance(config.toString(), null).close()
+ } catch (_: Exception) {
+ throw DownloadFailure("Invalid rule-set content")
+ }
+ }
+
+ fun config(rules: List): List = locked {
+ RemoteRuleSetStore.required(dao.all(), rules).map { e ->
+ require(cache(e).isFile) { "Rule-set ${e.tag} has no valid cache; update it first" }
+ RuleSet().apply { type = "local"; tag = e.tag; format = e.format; path = cache(e).absolutePath }
+ }
+ }
+
+ fun saveRoute(rule: RuleEntity, insert: Boolean) = locked {
+ RemoteRuleSetStore.required(dao.all(), listOf(rule))
+ if (insert) {
+ rule.userOrder = SagerDatabase.rulesDao.nextOrder() ?: 1
+ rule.id = SagerDatabase.rulesDao.createRule(rule)
+ } else {
+ SagerDatabase.rulesDao.updateRule(rule)
+ }
+ }
+
+ fun restore(entities: List) = restore(entities) {}
+
+ fun restore(entities: List, replaceDatabase: () -> Unit) = locked {
+ val previous = dao.all()
+ SagerDatabase.instance.runInTransaction {
+ dao.reset()
+ dao.insertAll(entities)
+ replaceDatabase()
+ }
+ // Files and WorkManager must only change after the database transaction commits.
+ previous.forEach { e ->
+ runCatching { RemoteWorkManager.getInstance(SagerNet.application).cancelUniqueWork("rule-set-${e.id}") }
+ .onFailure { Logs.w(it) }
+ listOf("source", "binary").forEach { File(root, "${e.id}.$it").delete() }
+ errors.remove(e.id)
+ }
+ entities.forEach { e ->
+ runCatching { schedule(e, ExistingWorkPolicy.REPLACE, 0) }.onFailure { Logs.w(it) }
+ }
+ }
+
+ class DownloadFailure(message: String) : Exception(message)
+}
+
+class RemoteRuleSetWorker(context: Context, parameters: WorkerParameters) : Worker(context, parameters) {
+ override fun doWork(): Result {
+ val error = RemoteRuleSetManager.backgroundUpdate(inputData.getLong("id", 0)) { !isStopped }
+ return Result.success(workDataOf("error" to error, "completed" to System.currentTimeMillis()))
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/RemoteRuleSetStore.kt b/app/src/main/java/io/nekohasekai/sagernet/database/RemoteRuleSetStore.kt
new file mode 100644
index 0000000000..36e1a4f4fd
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/RemoteRuleSetStore.kt
@@ -0,0 +1,86 @@
+package io.nekohasekai.sagernet.database
+
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.io.InputStream
+import java.net.URI
+import org.json.JSONArray
+import org.json.JSONObject
+
+/** Subscription data and cache operations, independent of Android networking. */
+object RemoteRuleSetStore {
+ fun tags(value: String): List = value.lineSequence().filter { it.isNotBlank() }.distinct().toList()
+
+ fun validate(entity: RemoteRuleSetEntity) {
+ require(entity.id >= 0 && entity.name.isNotBlank()) { "Invalid rule-set name or ID" }
+ require(entity.tag.matches(Regex("[A-Za-z0-9_.-]+"))) { "Invalid rule-set tag" }
+ require(entity.format == "source" || entity.format == "binary") { "Invalid rule-set format" }
+ require(entity.updateIntervalMinutes in 1..(Long.MAX_VALUE / 60000)) { "Invalid update interval" }
+ val uri = try { URI(entity.url) } catch (_: Exception) { null }
+ require(uri != null && uri.scheme in listOf("https", "http") && !uri.host.isNullOrBlank()
+ && uri.userInfo == null && uri.fragment == null) { "Invalid rule-set URL" }
+ }
+
+ fun validateReferences(entities: List, rules: List) {
+ entities.forEach(::validate)
+ require(entities.map { it.tag }.distinct().size == entities.size) { "Duplicate rule-set tag" }
+ val byTag = entities.associateBy { it.tag }
+ rules.forEach { rule -> tags(rule.remoteRuleSetTags).forEach { tag ->
+ val entity = byTag[tag] ?: error("Missing rule-set: $tag")
+ require(!rule.enabled || entity.enabled) { "Enabled rule references disabled rule-set: $tag" }
+ } }
+ }
+
+ /** Only subscriptions used by enabled routes are required to start the proxy. */
+ fun required(entities: List, rules: List): List {
+ val active = rules.filter { it.enabled }
+ val referenced = active.flatMap { tags(it.remoteRuleSetTags) }.toSet()
+ val required = entities.filter { it.tag in referenced }
+ validateReferences(required, active)
+ return required
+ }
+
+ fun export(entities: List): JSONArray = JSONArray().apply {
+ entities.forEach { e -> put(JSONObject().apply {
+ put("id", e.id); put("name", e.name); put("tag", e.tag); put("url", e.url)
+ put("format", e.format); put("updateIntervalMinutes", e.updateIntervalMinutes); put("enabled", e.enabled)
+ }) }
+ }
+
+ fun parse(array: JSONArray?): List {
+ val result = (0 until (array?.length() ?: 0)).map { index ->
+ val o = array!!.getJSONObject(index)
+ RemoteRuleSetEntity(o.getLong("id"), o.getString("name"), o.getString("tag"),
+ o.getString("url"), o.getString("format"), o.getLong("updateIntervalMinutes"), o.getBoolean("enabled"))
+ .also { validate(it); require(it.id > 0) { "Invalid rule-set ID" } }
+ }
+ require(result.map { it.id }.distinct().size == result.size) { "Duplicate rule-set ID" }
+ validateReferences(result, emptyList())
+ return result
+ }
+
+ /** The old valid file stays in place until validation and a same-directory rename succeed. */
+ fun replace(cache: File, input: InputStream, validate: (File) -> Unit, rename: (File, File) -> Unit) {
+ check(cache.parentFile!!.isDirectory || cache.parentFile!!.mkdirs()) { "Cannot create rule-set cache" }
+ val candidate = File.createTempFile("candidate-", ".tmp", cache.parentFile)
+ try {
+ FileOutputStream(candidate).use { out ->
+ val buffer = ByteArray(32768)
+ var total = 0L
+ while (true) {
+ val count = input.read(buffer)
+ if (count < 0) break
+ total += count
+ if (total > 64L * 1024 * 1024) throw IOException("Rule-set exceeds 64 MiB")
+ out.write(buffer, 0, count)
+ }
+ out.fd.sync()
+ }
+ validate(candidate)
+ rename(candidate, cache)
+ } finally {
+ candidate.delete()
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/RuleEntity.kt b/app/src/main/java/io/nekohasekai/sagernet/database/RuleEntity.kt
index 7d610c5e40..59bd01fd72 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/database/RuleEntity.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/RuleEntity.kt
@@ -5,6 +5,7 @@ import androidx.room.*
import io.nekohasekai.sagernet.R
import io.nekohasekai.sagernet.ktx.app
import kotlinx.parcelize.Parcelize
+import kotlinx.parcelize.IgnoredOnParcel
@Entity(tableName = "rules")
@Parcelize
@@ -25,6 +26,10 @@ data class RuleEntity(
var protocol: String = "",
var outbound: Long = 0,
var packages: Set = emptySet(),
+ // Keep the backup v1 Parcel byte layout unchanged; references use an optional JSON block.
+ @IgnoredOnParcel
+ @ColumnInfo(defaultValue = "''")
+ var remoteRuleSetTags: String = "",
) : Parcelable {
fun displayName(): String {
@@ -34,6 +39,7 @@ data class RuleEntity(
fun mkSummary(): String {
var summary = ""
if (config.isNotBlank()) summary += "[config]\n"
+ if (remoteRuleSetTags.isNotBlank()) summary += "rule-set: ${remoteRuleSetTags.replace('\n', ',')}\n"
if (domains.isNotBlank()) summary += "$domains\n"
if (ip.isNotBlank()) summary += "$ip\n"
if (source.isNotBlank()) summary += "src ip: $source\n"
@@ -107,4 +113,4 @@ data class RuleEntity(
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt b/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt
index ececc106eb..ea0ee18ae7 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt
@@ -15,12 +15,13 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@Database(
- entities = [ProxyGroup::class, ProxyEntity::class, RuleEntity::class],
- version = 6,
+ entities = [ProxyGroup::class, ProxyEntity::class, RuleEntity::class, RemoteRuleSetEntity::class],
+ version = 7,
autoMigrations = [
AutoMigration(from = 3, to = 4),
AutoMigration(from = 4, to = 5),
- AutoMigration(from = 5, to = 6)
+ AutoMigration(from = 5, to = 6),
+ AutoMigration(from = 6, to = 7)
]
)
@TypeConverters(value = [KryoConverters::class, GsonConverters::class])
@@ -45,11 +46,13 @@ abstract class SagerDatabase : RoomDatabase() {
val groupDao get() = instance.groupDao()
val proxyDao get() = instance.proxyDao()
val rulesDao get() = instance.rulesDao()
+ val remoteRuleSetsDao get() = instance.remoteRuleSetsDao()
}
abstract fun groupDao(): ProxyGroup.Dao
abstract fun proxyDao(): ProxyEntity.Dao
abstract fun rulesDao(): RuleEntity.Dao
+ abstract fun remoteRuleSetsDao(): RemoteRuleSetEntity.Dao
}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/BuiltInConfigMigration.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/BuiltInConfigMigration.kt
new file mode 100644
index 0000000000..0ff71d9497
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/BuiltInConfigMigration.kt
@@ -0,0 +1,175 @@
+package io.nekohasekai.sagernet.fmt
+
+import moe.matsuri.nb4a.SingBoxOptions.*
+import moe.matsuri.nb4a.utils.Util
+import java.net.URI
+
+/** Converts only generated objects, before their existing custom JSON overlays. */
+internal fun migrateBuiltInConfig(options: MyOptions) {
+ val earlyRules = mutableListOf()
+ fun inboundActions(tag: String, sniff: Boolean?, override: Boolean?, strategy: String?) {
+ if (sniff == true) earlyRules += Rule_DefaultOptions().apply {
+ inbound = listOf(tag)
+ action = "sniff"
+ if (override == true) _neko_sniff_override = true
+ }
+ if (!strategy.isNullOrEmpty()) earlyRules += Rule_DefaultOptions().apply {
+ inbound = listOf(tag)
+ action = "resolve"
+ this.strategy = strategy
+ }
+ }
+ options.inbounds.forEach { inbound ->
+ when (inbound) {
+ is Inbound_TunOptions -> {
+ inboundActions(inbound.tag, inbound.sniff, inbound.sniff_override_destination, inbound.domain_strategy)
+ inbound.sniff = null
+ inbound.sniff_override_destination = null
+ inbound.domain_strategy = null
+ inbound.address = inbound.inet4_address.orEmpty() + inbound.inet6_address.orEmpty()
+ inbound.inet4_address = null
+ inbound.inet6_address = null
+ if (inbound.endpoint_independent_nat == true) {
+ inbound.udp_mapping = "endpoint_independent"
+ inbound.udp_filtering = "endpoint_independent"
+ }
+ inbound.endpoint_independent_nat = null
+ }
+ is Inbound_MixedOptions -> {
+ inboundActions(inbound.tag, inbound.sniff, inbound.sniff_override_destination, inbound.domain_strategy)
+ inbound.sniff = null
+ inbound.sniff_override_destination = null
+ inbound.domain_strategy = null
+ }
+ }
+ }
+ options.route.rules = earlyRules + options.route.rules
+
+ val strategies = options.dns.servers.associate { it.tag to it.strategy }
+ val rcodes = mutableMapOf()
+ options.dns.servers = options.dns.servers.filter { server ->
+ val address = server.address
+ if (address.startsWith("rcode://")) {
+ rcodes[server.tag] = when (address.removePrefix("rcode://")) {
+ "success" -> "NOERROR"
+ "format_error" -> "FORMERR"
+ "server_failure" -> "SERVFAIL"
+ "name_error" -> "NXDOMAIN"
+ "not_implemented" -> "NOTIMP"
+ "refused" -> "REFUSED"
+ else -> error("Unknown DNS rcode: $address")
+ }
+ false
+ } else {
+ migrateDnsServer(server, options.dns.fakeip)
+ true
+ }
+ }
+ options.dns.fakeip = null
+ val rules = options.dns.rules.filterNot {
+ it is DNSRule_DefaultOptions && it.outbound == listOf("any") && it.server == "dns-direct"
+ }.toMutableList()
+ rules.forEach { rule ->
+ if (rule is DNSRule_DefaultOptions) {
+ val rcode = rcodes[rule.server]
+ if (rcode != null) {
+ rule.action = "predefined"
+ rule.rcode = rcode
+ rule.server = null
+ rule.disable_cache = null
+ } else {
+ rule.strategy = strategies[rule.server]?.takeIf { it.isNotEmpty() }
+ }
+ }
+ }
+ // Legacy transport strategy also applied when the final transport was used.
+ strategies[options.dns.final_]?.takeIf { it.isNotEmpty() }?.let { strategy ->
+ rules += DNSRule_DefaultOptions().apply {
+ server = options.dns.final_
+ this.strategy = strategy
+ }
+ }
+ options.dns.rules = rules
+ val directResolver = mutableMapOf("server" to "dns-direct")
+ strategies["dns-direct"]?.takeIf { it.isNotEmpty() }?.let { directResolver["strategy"] = it }
+ options.route.default_domain_resolver = directResolver
+
+ val outbounds = mutableListOf()
+ val endpoints = mutableListOf()
+ options.outbounds.forEachIndexed { index, outbound ->
+ val generated = outbound._hack_config_map
+ if (generated.containsKey("domain_strategy")) {
+ val strategy = generated.remove("domain_strategy") as String
+ generated["domain_resolver"] = directResolver.toMutableMap().apply {
+ if (strategy.isNotEmpty()) put("strategy", strategy)
+ }
+ } else if (outbound is Outbound && outbound.type == "direct") {
+ // The removed outbound:any DNS rule covered direct/bypass too.
+ generated["domain_resolver"] = directResolver.toMutableMap()
+ }
+ if (outbound is Outbound_WireGuardOptions) {
+ val endpoint = Endpoint_WireGuardOptions().apply {
+ type = "wireguard"
+ tag = outbound.tag
+ address = outbound.local_address
+ private_key = outbound.private_key
+ mtu = outbound.mtu
+ peers = listOf(mutableMapOf(
+ "address" to outbound.server,
+ "port" to outbound.server_port,
+ "public_key" to outbound.peer_public_key,
+ "allowed_ips" to listOf("0.0.0.0/0", "::/0")
+ ).apply {
+ outbound.pre_shared_key?.takeIf { it.isNotEmpty() }?.let { put("pre_shared_key", it) }
+ outbound.reserved?.takeIf { it.isNotEmpty() }?.let {
+ put("reserved", Util.b64Decode(it).map { byte -> byte.toInt() and 255 })
+ }
+ })
+ _hack_config_map = generated
+ _hack_custom_config = outbound._hack_custom_config
+ }
+ endpoints += endpoint
+ if (index == 0 && options.route.final_.isNullOrEmpty()) {
+ options.route.final_ = generated["tag"] as? String ?: outbound.tag
+ }
+ } else outbounds += outbound
+ }
+ options.outbounds = outbounds
+ if (endpoints.isNotEmpty()) options.endpoints = endpoints
+}
+
+private fun migrateDnsServer(server: DNSServerOptions, fakeIP: DNSFakeIPOptions?) {
+ val address = server.address
+ server.address = null
+ server.strategy = null
+ server.address_resolver?.takeIf { it.isNotEmpty() }?.let { resolver ->
+ server.domain_resolver = mutableMapOf("server" to resolver).apply {
+ server.address_strategy?.takeIf { it.isNotEmpty() }?.let { put("strategy", it) }
+ }
+ }
+ server.address_resolver = null
+ server.address_strategy = null
+ // The legacy remote DNS default dialer used the App's default outbound.
+ if (server.tag == "dns-remote" && server.detour.isNullOrEmpty()) server.detour = "proxy"
+ if (address == "local" || address.startsWith("local://")) {
+ server.type = "local"
+ return
+ }
+ if (address == "fakeip") {
+ server.type = "fakeip"
+ server.detour = null
+ server.domain_resolver = null
+ server.inet4_range = fakeIP?.inet4_range
+ server.inet6_range = fakeIP?.inet6_range
+ return
+ }
+ // Legacy UDP accepts a bare IPv6 literal as well as host:port.
+ val authority = if (!address.startsWith("[") && address.count { it == ':' } > 1) "[$address]" else address
+ val uri = URI(if (address.contains("://")) address else "udp://$authority")
+ server.type = uri.scheme
+ require(server.type in setOf("udp", "tcp", "tls", "https", "quic", "h3")) { "Unsupported DNS transport: $address" }
+ server.server = requireNotNull(uri.host) { "Invalid DNS address: $address" }.removePrefix("[").removeSuffix("]")
+ if (uri.port >= 0) server.server_port = uri.port
+ if (server.type in setOf("tls", "https", "quic", "h3")) server.tls = mapOf("enabled" to true)
+ if (server.type in setOf("https", "h3") && !uri.path.isNullOrEmpty()) server.path = uri.path
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt
index 8fe0a2944c..f35919f952 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt
@@ -7,6 +7,8 @@ import io.nekohasekai.sagernet.database.DataStore
import io.nekohasekai.sagernet.database.ProxyEntity
import io.nekohasekai.sagernet.database.ProxyEntity.Companion.TYPE_CONFIG
import io.nekohasekai.sagernet.database.SagerDatabase
+import io.nekohasekai.sagernet.database.RemoteRuleSetManager
+import io.nekohasekai.sagernet.database.RemoteRuleSetStore
import io.nekohasekai.sagernet.fmt.ConfigBuildResult.IndexEntity
import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean
import io.nekohasekai.sagernet.fmt.hysteria.buildSingBoxOutboundHysteriaBean
@@ -124,6 +126,7 @@ fun buildConfig(
}
val extraRules = if (forTest) listOf() else SagerDatabase.rulesDao.enabledRules()
+ val remoteRuleSets = if (forTest) emptyList() else RemoteRuleSetManager.config(extraRules)
val extraProxies =
if (forTest) mapOf() else SagerDatabase.proxyDao.getEntities(extraRules.mapNotNull { rule ->
rule.outbound.takeIf { it > 0 && it != proxy.id }
@@ -507,10 +510,17 @@ fun buildConfig(
makeSingBoxRule(domainList, false)
}
if (rule.ip.isNotBlank()) {
+ val domainRuleSets = rule_set.orEmpty()
makeSingBoxRule(rule.ip.listByLineOrComma(), true)
+ // Both converters reset rule_set; retain the domain address alternatives.
+ if (domainRuleSets.isNotEmpty()) {
+ rule_set = (domainRuleSets + rule_set.orEmpty()).distinct()
+ }
}
if (rule_set != null) generateRuleSet(rule_set, ruleSets)
+ val remoteTags = RemoteRuleSetStore.tags(rule.remoteRuleSetTags)
+ if (remoteTags.isNotEmpty()) rule_set = ((rule_set ?: emptyList()) + remoteTags).distinct()
if (rule.port.isNotBlank()) {
port = mutableListOf()
@@ -548,6 +558,9 @@ fun buildConfig(
return DNSRule_DefaultOptions().apply {
if (uidList.isNotEmpty()) user_id = uidList
domainList?.let { makeSingBoxRule(it) }
+ if (remoteTags.isNotEmpty()) {
+ rule_set = (rule_set.orEmpty() + remoteTags).distinct()
+ }
}
}
@@ -596,6 +609,8 @@ fun buildConfig(
if (ruleObj.outbound == TAG_BLOCK) {
ruleObj.outbound = null
ruleObj.action = "reject"
+ } else if (rule.remoteRuleSetTags.isNotBlank()) {
+ ruleObj.action = "route"
}
route.rules.add(ruleObj)
route.rule_set.addAll(ruleSets)
@@ -603,6 +618,7 @@ fun buildConfig(
}
}
+ route.rule_set.addAll(remoteRuleSets)
// 对 rule_set tag 去重
if (route.rule_set != null) {
route.rule_set = route.rule_set.distinctBy { it.tag }
@@ -740,6 +756,7 @@ fun buildConfig(
if (!forTest) _hack_custom_config = DataStore.globalCustomConfig
}.let {
+ migrateBuiltInConfig(it)
val configMap = it.asMap()
Util.mergeJSON(configMap, proxy.requireBean().customConfigJson)
ConfigBuildResult(
diff --git a/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt b/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt
index a7597999e6..452aaa1107 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt
@@ -28,8 +28,10 @@ import org.ini4j.Ini
import org.json.JSONArray
import org.json.JSONObject
import org.json.JSONTokener
+import org.yaml.snakeyaml.LoaderOptions
import org.yaml.snakeyaml.TypeDescription
import org.yaml.snakeyaml.Yaml
+import org.yaml.snakeyaml.constructor.SafeConstructor
import org.yaml.snakeyaml.error.YAMLException
import java.io.StringReader
import androidx.core.net.toUri
@@ -234,7 +236,7 @@ object RawUpdater : GroupUpdater() {
try {
- val yaml = Yaml().apply {
+ val yaml = Yaml(SafeConstructor(LoaderOptions())).apply {
addTypeDescription(TypeDescription(String::class.java, "str"))
}.loadAs(text, Map::class.java)
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/BackupFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/BackupFragment.kt
index b439225f83..2a004738a3 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/ui/BackupFragment.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/BackupFragment.kt
@@ -151,6 +151,12 @@ class BackupFragment : NamedFragment(R.layout.layout_backup) {
})
}
if (rule) {
+ put("remoteRuleSets", RemoteRuleSetStore.export(SagerDatabase.remoteRuleSetsDao.all()))
+ put("remoteRuleSetReferences", JSONObject().apply {
+ SagerDatabase.rulesDao.allRules().forEach {
+ if (it.remoteRuleSetTags.isNotBlank()) put(it.id.toString(), JSONArray(RemoteRuleSetStore.tags(it.remoteRuleSetTags)))
+ }
+ })
put("rules", JSONArray().apply {
SagerDatabase.rulesDao.allRules().forEach {
put(it.toBase64Str())
@@ -204,7 +210,8 @@ class BackupFragment : NamedFragment(R.layout.layout_backup) {
it.bufferedReader().readText()
})
} catch (e: Exception) {
- Logs.w(e)
+ // JSON parser errors can embed the backup, including subscription URL tokens.
+ Logs.w("Invalid backup JSON")
invalid()
return
}
@@ -265,61 +272,84 @@ class BackupFragment : NamedFragment(R.layout.layout_backup) {
fun finishImport(
content: JSONObject, profile: Boolean, rule: Boolean, setting: Boolean
) {
- if (profile && content.has("profiles")) {
- val profiles = mutableListOf()
- val jsonProfiles = content.getJSONArray("profiles")
- for (i in 0 until jsonProfiles.length()) {
- val data = Util.b64Decode(jsonProfiles[i] as String)
- val parcel = Parcel.obtain()
- parcel.unmarshall(data, 0, data.size)
- parcel.setDataPosition(0)
- profiles.add(ProxyEntity.CREATOR.createFromParcel(parcel))
- parcel.recycle()
- }
- SagerDatabase.proxyDao.reset()
- SagerDatabase.proxyDao.insert(profiles)
-
- val groups = mutableListOf()
- val jsonGroups = content.getJSONArray("groups")
- for (i in 0 until jsonGroups.length()) {
- val data = Util.b64Decode(jsonGroups[i] as String)
- val parcel = Parcel.obtain()
- parcel.unmarshall(data, 0, data.size)
- parcel.setDataPosition(0)
- groups.add(ProxyGroup.CREATOR.createFromParcel(parcel))
- parcel.recycle()
+ val (remoteSets, parsedReferences) = try {
+ val sets = if (rule && content.has("rules")) {
+ RemoteRuleSetStore.parse(if (content.has("remoteRuleSets")) content.getJSONArray("remoteRuleSets") else null)
+ } else emptyList()
+ val references = if (rule && content.has("remoteRuleSetReferences")) content.getJSONObject("remoteRuleSetReferences") else JSONObject()
+ val parsed = references.keys().asSequence().associateWith { key ->
+ require(key.toLongOrNull() != null)
+ val array = references.getJSONArray(key)
+ (0 until array.length()).map { array.getString(it) }.also { tags ->
+ require(tags.all { tag -> sets.any { it.tag == tag } })
+ }.joinToString("\n")
}
- SagerDatabase.groupDao.reset()
- SagerDatabase.groupDao.insert(groups)
+ sets to parsed
+ } catch (_: Exception) {
+ throw IllegalArgumentException("Invalid remote rule-set backup data")
}
- if (rule && content.has("rules")) {
- val rules = mutableListOf()
- val jsonRules = content.getJSONArray("rules")
- for (i in 0 until jsonRules.length()) {
- val data = Util.b64Decode(jsonRules[i] as String)
+ fun parse(name: String, create: (Parcel) -> T): List {
+ val values = content.getJSONArray(name)
+ return (0 until values.length()).map { index ->
+ val data = Util.b64Decode(values.getString(index))
val parcel = Parcel.obtain()
- parcel.unmarshall(data, 0, data.size)
- parcel.setDataPosition(0)
- rules.add(ParcelizeBridge.createRule(parcel))
- parcel.recycle()
+ try {
+ parcel.unmarshall(data, 0, data.size)
+ parcel.setDataPosition(0)
+ create(parcel)
+ } finally {
+ parcel.recycle()
+ }
}
- SagerDatabase.rulesDao.reset()
- SagerDatabase.rulesDao.insert(rules)
}
- if (setting && content.has("settings")) {
- val settings = mutableListOf()
- val jsonSettings = content.getJSONArray("settings")
- for (i in 0 until jsonSettings.length()) {
- val data = Util.b64Decode(jsonSettings[i] as String)
- val parcel = Parcel.obtain()
- parcel.unmarshall(data, 0, data.size)
- parcel.setDataPosition(0)
- settings.add(KeyValuePair.CREATOR.createFromParcel(parcel))
- parcel.recycle()
+
+ // Parse every selected section before changing either database or any cache.
+ val profiles = if (profile && content.has("profiles")) parse("profiles", ProxyEntity.CREATOR::createFromParcel) else null
+ val groups = if (profiles != null) parse("groups", ProxyGroup.CREATOR::createFromParcel) else null
+ val rules = if (rule && content.has("rules")) parse("rules") { parcel ->
+ ParcelizeBridge.createRule(parcel).apply { remoteRuleSetTags = parsedReferences[id.toString()] ?: "" }
+ } else null
+ val settings = if (setting && content.has("settings")) parse("settings", KeyValuePair.CREATOR::createFromParcel) else null
+ if (rules != null) {
+ require(parsedReferences.keys.all { id -> rules.any { it.id.toString() == id } }) { "Missing referenced route" }
+ RemoteRuleSetStore.validateReferences(remoteSets, rules)
+ }
+
+ val database = SagerDatabase.instance
+ val sqlite = database.openHelper.writableDatabase
+ // Both Room databases use rollback journals. ATTACH makes their replacement one
+ // SQLite transaction, including a rollback if inserting settings fails.
+ if (settings != null) {
+ val settingsPath = PublicDatabase.instance.openHelper.writableDatabase.path
+ sqlite.execSQL("ATTACH DATABASE ? AS imported_settings", arrayOf(settingsPath))
+ }
+ try {
+ val replace: () -> Unit = {
+ if (profiles != null) {
+ SagerDatabase.proxyDao.reset()
+ SagerDatabase.proxyDao.insert(profiles)
+ SagerDatabase.groupDao.reset()
+ SagerDatabase.groupDao.insert(groups!!)
+ }
+ if (rules != null) {
+ SagerDatabase.rulesDao.reset()
+ SagerDatabase.rulesDao.insert(rules)
+ }
+ if (settings != null) {
+ sqlite.execSQL("DELETE FROM imported_settings.KeyValuePair")
+ settings.forEach { value ->
+ sqlite.execSQL(
+ "INSERT INTO imported_settings.KeyValuePair (`key`, valueType, value) VALUES (?, ?, ?)",
+ arrayOf(value.key, value.valueType, value.value)
+ )
+ }
+ }
}
- PublicDatabase.kvPairDao.reset()
- PublicDatabase.kvPairDao.insert(settings)
+ if (rules != null) RemoteRuleSetManager.restore(remoteSets, replace)
+ else database.runInTransaction { replace() }
+ } finally {
+ if (settings != null) sqlite.execSQL("DETACH DATABASE imported_settings")
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/RemoteRuleSetsDialog.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/RemoteRuleSetsDialog.kt
new file mode 100644
index 0000000000..1856e46dc4
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/RemoteRuleSetsDialog.kt
@@ -0,0 +1,121 @@
+package io.nekohasekai.sagernet.ui
+
+import android.app.Dialog
+import android.os.Bundle
+import android.widget.*
+import androidx.appcompat.app.AlertDialog
+import androidx.fragment.app.DialogFragment
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import io.nekohasekai.sagernet.R
+import io.nekohasekai.sagernet.database.*
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.text.DateFormat
+import java.util.Date
+
+class RemoteRuleSetsDialog : DialogFragment() {
+ private var items = emptyList()
+ private lateinit var adapter: ArrayAdapter
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ adapter = ArrayAdapter(requireContext(), android.R.layout.simple_list_item_1)
+ parentFragmentManager.setFragmentResultListener("rule-set-saved", this) { _, _ -> refresh() }
+ return MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.remote_rule_sets)
+ .setAdapter(adapter) { _, index -> actions(items[index]) }
+ .setPositiveButton(R.string.ruleset_add) { _, _ -> editor(0) }
+ .setNegativeButton(android.R.string.cancel, null).create()
+ }
+
+ override fun onStart() {
+ super.onStart()
+ val alert = dialog as AlertDialog
+ alert.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { editor(0) }
+ // AlertDialog item clicks normally dismiss; keep the management list available.
+ alert.listView.setOnItemClickListener { _, _, position, _ -> actions(items[position]) }
+ refresh()
+ }
+
+ private fun refresh() {
+ lifecycleScope.launch {
+ val result = withContext(Dispatchers.IO) {
+ SagerDatabase.remoteRuleSetsDao.all().map { e ->
+ val updated = RemoteRuleSetManager.updated(e)
+ val state = if (updated == 0L) getString(R.string.ruleset_no_cache)
+ else DateFormat.getDateTimeInstance().format(Date(updated))
+ e to "${e.name} (${e.tag})\n${getString(if (e.enabled) R.string.ruleset_enabled else R.string.ruleset_disabled)} · $state\n${RemoteRuleSetManager.error(e.id)}"
+ }
+ }
+ items = result.map { it.first }
+ adapter.clear(); adapter.addAll(result.map { it.second })
+ }
+ }
+
+ private fun editor(id: Long) {
+ RemoteRuleSetEditDialog().apply { arguments = Bundle().apply { putLong("id", id) } }
+ .show(parentFragmentManager, "rule-set-editor")
+ }
+
+ private fun actions(e: RemoteRuleSetEntity) {
+ val choices = arrayOf(getString(R.string.ruleset_edit), getString(R.string.ruleset_update),
+ getString(if (e.enabled) R.string.ruleset_disable else R.string.ruleset_enable), getString(R.string.ruleset_delete))
+ MaterialAlertDialogBuilder(requireContext()).setTitle(e.name).setItems(choices) { _, index ->
+ when (index) {
+ 0 -> editor(e.id)
+ 1 -> operation { RemoteRuleSetManager.update(e.id) }
+ 2 -> operation { RemoteRuleSetManager.save(e.copy(enabled = !e.enabled)) }
+ 3 -> MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.ruleset_delete)
+ .setMessage(e.name).setPositiveButton(android.R.string.ok) { _, _ -> operation { RemoteRuleSetManager.delete(e.id) } }
+ .setNegativeButton(android.R.string.cancel, null).show()
+ }
+ }.show()
+ }
+
+ private fun operation(block: () -> Unit) {
+ lifecycleScope.launch {
+ val failure = withContext(Dispatchers.IO) { runCatching(block).exceptionOrNull() }
+ if (isAdded) {
+ Toast.makeText(requireContext(), failure?.message ?: getString(R.string.ruleset_done), Toast.LENGTH_LONG).show()
+ refresh()
+ }
+ }
+ }
+}
+
+class RemoteRuleSetEditDialog : DialogFragment() {
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val view = layoutInflater.inflate(R.layout.layout_remote_rule_set_edit, null)
+ val id = requireArguments().getLong("id")
+ val entity = if (id == 0L) RemoteRuleSetEntity() else SagerDatabase.remoteRuleSetsDao.get(id) ?: RemoteRuleSetEntity()
+ val name = view.findViewById(R.id.ruleset_name)
+ val tag = view.findViewById(R.id.ruleset_tag)
+ val url = view.findViewById(R.id.ruleset_url)
+ val interval = view.findViewById(R.id.ruleset_interval)
+ val enabled = view.findViewById(R.id.ruleset_enabled)
+ val format = view.findViewById(R.id.ruleset_format)
+ name.setText(entity.name); tag.setText(entity.tag); url.setText(entity.url)
+ interval.setText(entity.updateIntervalMinutes.toString()); enabled.isChecked = entity.enabled
+ format.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, arrayOf("source", "binary"))
+ format.setSelection(if (entity.format == "binary") 1 else 0)
+ val alert = MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.remote_rule_sets)
+ .setView(view).setPositiveButton(android.R.string.ok, null).setNegativeButton(android.R.string.cancel, null).create()
+ alert.setOnShowListener {
+ alert.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
+ val value = RemoteRuleSetEntity(id, name.text.toString().trim(), tag.text.toString().trim(),
+ url.text.toString().trim(), format.selectedItem.toString(), interval.text.toString().toLongOrNull() ?: 0, enabled.isChecked)
+ alert.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
+ lifecycleScope.launch {
+ val failure = withContext(Dispatchers.IO) { runCatching { RemoteRuleSetManager.save(value) }.exceptionOrNull() }
+ if (failure == null) {
+ parentFragmentManager.setFragmentResult("rule-set-saved", Bundle())
+ dismiss()
+ } else if (isAdded) {
+ Toast.makeText(requireContext(), failure.message, Toast.LENGTH_LONG).show()
+ alert.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = true
+ }
+ }
+ }
+ }
+ return alert
+ }
+}
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 9fcf93e4aa..54ac0a91e2 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/ui/RouteFragment.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/RouteFragment.kt
@@ -102,6 +102,7 @@ class RouteFragment : ToolbarFragment(R.layout.layout_route), Toolbar.OnMenuItem
override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) {
+ R.id.action_remote_rule_sets -> RemoteRuleSetsDialog().show(parentFragmentManager, "remote-rule-sets")
R.id.action_new_route -> {
startActivity(Intent(context, RouteSettingsActivity::class.java))
}
@@ -287,13 +288,22 @@ class RouteFragment : ToolbarFragment(R.layout.layout_route), Toolbar.OnMenuItem
itemView.setOnClickListener {
enableSwitch.performClick()
}
+ enableSwitch.setOnCheckedChangeListener(null)
+ enableSwitch.isEnabled = true
enableSwitch.isChecked = rule.enabled
enableSwitch.setOnCheckedChangeListener { _, isChecked ->
+ val original = rule
+ val changed = original.copy(enabled = isChecked)
+ enableSwitch.isEnabled = false
runOnDefaultDispatcher {
- rule.enabled = isChecked
- SagerDatabase.rulesDao.updateRule(rule)
+ val failure = runCatching { ProfileManager.updateRule(changed) }.exceptionOrNull()
onMainDispatcher {
- needReload()
+ if (rule.id == changed.id) bind(if (failure == null) changed else original)
+ if (failure != null) {
+ snackbar(failure.readableMessage).show()
+ } else {
+ needReload()
+ }
}
}
}
@@ -307,4 +317,4 @@ class RouteFragment : ToolbarFragment(R.layout.layout_route), Toolbar.OnMenuItem
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/RouteSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/RouteSettingsActivity.kt
index aaa062de30..1a56f363bf 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/ui/RouteSettingsActivity.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/RouteSettingsActivity.kt
@@ -19,6 +19,7 @@ import androidx.preference.EditTextPreference
import androidx.preference.Preference
import androidx.preference.PreferenceDataStore
import androidx.preference.PreferenceFragmentCompat
+import androidx.preference.MultiSelectListPreference
import com.github.shadowsocks.plugin.Empty
import com.github.shadowsocks.plugin.fragment.AlertDialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -74,6 +75,7 @@ class RouteSettingsActivity(
else -> 3
}
DataStore.routePackages = packages.joinToString("\n")
+ DataStore.profileCacheStore.putStringSet("routeRemoteRuleSets", io.nekohasekai.sagernet.database.RemoteRuleSetStore.tags(remoteRuleSetTags).toMutableSet())
}
fun RuleEntity.serialize() {
@@ -93,6 +95,7 @@ class RouteSettingsActivity(
else -> DataStore.routeOutboundRule
}
packages = DataStore.routePackages.split("\n").filter { it.isNotBlank() }.toSet()
+ remoteRuleSetTags = DataStore.profileCacheStore.getStringSet("routeRemoteRuleSets", mutableSetOf())!!.sorted().joinToString("\n")
if (DataStore.editingId == 0L) {
enabled = true
@@ -112,6 +115,13 @@ class RouteSettingsActivity(
addPreferencesFromResource(R.xml.route_preferences)
editConfigPreference = findPreference(Key.SERVER_CONFIG)!!
+ val sets = SagerDatabase.remoteRuleSetsDao.all()
+ val selection = findPreference("routeRemoteRuleSets")!!
+ val selected = DataStore.profileCacheStore.getStringSet("routeRemoteRuleSets", mutableSetOf())!!
+ val tags = (sets.filter { it.enabled }.map { it.tag } + selected).distinct()
+ selection.entryValues = tags.toTypedArray()
+ selection.entries = tags.map { tag -> sets.find { it.tag == tag }?.let { "${it.name} ($tag)" } ?: tag }.toTypedArray()
+ selection.summaryProvider = Preference.SummaryProvider { it.values.sorted().joinToString(", ") }
}
override fun onResume() {
@@ -266,22 +276,28 @@ class RouteSettingsActivity(
return
}
- val editingId = DataStore.editingId
- if (editingId == 0L) {
- if (intent.hasExtra(EXTRA_PACKAGE_NAME)) {
- setResult(RESULT_OK, Intent())
- }
+ try {
+ val editingId = DataStore.editingId
+ if (editingId == 0L) {
+ if (intent.hasExtra(EXTRA_PACKAGE_NAME)) {
+ setResult(RESULT_OK, Intent())
+ }
- ProfileManager.createRule(RuleEntity().apply { serialize() })
- } else {
- val entity = SagerDatabase.rulesDao.getById(DataStore.editingId)
- if (entity == null) {
- finish()
- return
+ ProfileManager.createRule(RuleEntity().apply { serialize() })
+ } else {
+ val entity = SagerDatabase.rulesDao.getById(DataStore.editingId)
+ if (entity == null) {
+ finish()
+ return
+ }
+ ProfileManager.updateRule(entity.apply { serialize() })
}
- ProfileManager.updateRule(entity.apply { serialize() })
+ finish()
+ } catch (e: IllegalArgumentException) {
+ onMainDispatcher { Toast.makeText(this@RouteSettingsActivity, e.message, Toast.LENGTH_LONG).show() }
+ } catch (e: IllegalStateException) {
+ onMainDispatcher { Toast.makeText(this@RouteSettingsActivity, e.message, Toast.LENGTH_LONG).show() }
}
- finish()
}
@@ -391,4 +407,4 @@ class RouteSettingsActivity(
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt
index 2116c237ea..f6d37e8571 100644
--- a/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt
+++ b/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt
@@ -2,6 +2,7 @@ package io.nekohasekai.sagernet.widget
import android.annotation.SuppressLint
import android.content.Context
+import android.content.ContextWrapper
import android.text.format.Formatter
import android.util.AttributeSet
import android.view.View
@@ -80,8 +81,18 @@ class StatsBar @JvmOverloads constructor(
TooltipCompat.setTooltipText(this, text)
}
+ private fun requireActivity(): MainActivity {
+ // Material theme overlays can wrap the Activity in multiple ContextWrappers.
+ var current = context
+ while (current is ContextWrapper) {
+ if (current is MainActivity) return current
+ current = current.baseContext
+ }
+ error("StatsBar requires a MainActivity context")
+ }
+
fun changeState(state: BaseService.State) {
- val activity = context as MainActivity
+ val activity = requireActivity()
fun postWhenStarted(what: () -> Unit) = activity.lifecycleScope.launch(Dispatchers.Main) {
delay(100L)
activity.whenStarted { what() }
@@ -123,7 +134,7 @@ class StatsBar @JvmOverloads constructor(
}
fun testConnection() {
- val activity = context as MainActivity
+ val activity = requireActivity()
isEnabled = false
setStatus(app.getText(R.string.connection_test_testing))
runOnDefaultDispatcher {
@@ -146,7 +157,7 @@ class StatsBar @JvmOverloads constructor(
Logs.w(e.toString())
onMainDispatcher {
isEnabled = true
- setStatus(app.getText(R.string.connection_test_testing))
+ setStatus(app.getText(R.string.connection_test_failed))
activity.snackbar(
app.getString(
diff --git a/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java b/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java
index 05f89275ff..995b232d85 100644
--- a/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java
+++ b/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java
@@ -113,6 +113,8 @@ public static class MyOptions extends SingBoxOption {
public List outbounds;
+ public List endpoints;
+
public RouteOptions route;
public ExperimentalOptions experimental;
@@ -329,6 +331,15 @@ public static class DNSOptions extends SingBoxOption {
public static class DNSServerOptions extends SingBoxOption {
+ public String type;
+ public String server;
+ public Integer server_port;
+ public Object domain_resolver;
+ public Object tls;
+ public String path;
+ public String inet4_range;
+ public String inet6_range;
+
public String tag;
public String address;
@@ -1046,6 +1057,8 @@ public static class TProxyInboundOptions extends SingBoxOption {
public static class RouteOptions extends SingBoxOption {
+ public Object default_domain_resolver;
+
public List rules;
public List rule_set;
@@ -2799,6 +2812,10 @@ public static class WireGuardPeer extends SingBoxOption {
public static class Inbound_TunOptions extends Inbound {
+ public List address;
+ public String udp_mapping;
+ public String udp_filtering;
+
public String interface_name;
public Integer mtu;
@@ -3851,6 +3868,13 @@ public static class Outbound_TrojanOptions extends Outbound {
}
+ public static class Endpoint_WireGuardOptions extends Outbound {
+ public List address;
+ public String private_key;
+ public Integer mtu;
+ public List