From 42e83cfa4298b6edfcaf6aae231a6238da691525 Mon Sep 17 00:00:00 2001 From: Nova Date: Sun, 27 Sep 2026 02:54:50 +0330 Subject: [PATCH 1/5] fix Xray asset issue && fix tests && remove clash dashboard and add default on sing-box dashboard --- README.md | 2 + .../java/io/nekohasekai/sagernet/Constants.kt | 2 +- .../io/nekohasekai/sagernet/bg/BaseService.kt | 19 +- .../io/nekohasekai/sagernet/bg/CoreRuntime.kt | 26 ++ .../sagernet/bg/ProfileValidator.kt | 18 +- .../sagernet/bg/ServiceNotification.kt | 9 + .../nekohasekai/sagernet/bg/XrayGeoAssets.kt | 318 +++++++++++++ .../sagernet/bg/proto/BoxInstance.kt | 39 ++ .../sagernet/bg/proto/ProxyInstance.kt | 5 + .../sagernet/bg/test/SpeedTestRunner.kt | 14 +- .../sagernet/bg/test/TestNotification.kt | 38 +- .../nekohasekai/sagernet/bg/test/TestProbe.kt | 151 ++++++- .../sagernet/bg/test/TestSession.kt | 48 +- .../sagernet/database/DataStore.kt | 10 +- .../sagernet/database/SettingsMapper.kt | 1 + .../sagernet/database/SettingsRegistry.kt | 19 +- .../java/io/nekohasekai/sagernet/ktx/Nets.kt | 58 +++ .../outbound/config/ConfigGenerator.kt | 32 +- .../outbound/config/GeneratorSettings.kt | 7 +- .../sagernet/ui/DashboardFragment.kt | 423 ++++++++++++++++++ .../nekohasekai/sagernet/ui/MainActivity.kt | 21 +- .../sagernet/ui/WebviewFragment.kt | 79 ---- .../ui/settings/CoreSettingsScreens.kt | 59 ++- .../ui/settings/XrayGeoSettingsScreen.kt | 193 ++++++++ .../moe/matsuri/nb4a/utils/WebViewUtil.kt | 18 - .../main/res/layout/dialog_geo_download.xml | 26 ++ app/src/main/res/layout/layout_dashboard.xml | 113 +++++ app/src/main/res/layout/layout_webview.xml | 16 - app/src/main/res/menu/dashboard_menu.xml | 9 + app/src/main/res/menu/yacd_menu.xml | 9 - app/src/main/res/values-fa/strings.xml | 1 - app/src/main/res/values-ja/strings.xml | 1 - app/src/main/res/values-ko/strings.xml | 1 - app/src/main/res/values-ru/strings.xml | 1 - app/src/main/res/values-uk/strings.xml | 1 - app/src/main/res/values-zh-rCN/strings.xml | 1 - app/src/main/res/values-zh-rTW/strings.xml | 1 - app/src/main/res/values/strings.xml | 53 ++- app/src/main/res/xml/settings_core.xml | 22 + app/src/main/res/xml/settings_xray_geo.xml | 43 ++ nb4a.properties | 2 +- 41 files changed, 1727 insertions(+), 182 deletions(-) create mode 100644 app/src/main/java/io/nekohasekai/sagernet/bg/XrayGeoAssets.kt create mode 100644 app/src/main/java/io/nekohasekai/sagernet/ui/DashboardFragment.kt delete mode 100644 app/src/main/java/io/nekohasekai/sagernet/ui/WebviewFragment.kt create mode 100644 app/src/main/java/io/nekohasekai/sagernet/ui/settings/XrayGeoSettingsScreen.kt delete mode 100644 app/src/main/java/moe/matsuri/nb4a/utils/WebViewUtil.kt create mode 100644 app/src/main/res/layout/dialog_geo_download.xml create mode 100644 app/src/main/res/layout/layout_dashboard.xml delete mode 100644 app/src/main/res/layout/layout_webview.xml create mode 100644 app/src/main/res/menu/dashboard_menu.xml delete mode 100644 app/src/main/res/menu/yacd_menu.xml create mode 100644 app/src/main/res/xml/settings_xray_geo.xml 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/src/main/java/io/nekohasekai/sagernet/Constants.kt b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt index e39642bc..806c2b80 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/Constants.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt @@ -34,7 +34,6 @@ 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" @@ -61,6 +60,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..6d14678a 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). @@ -616,6 +618,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 +631,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..8a3e0482 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 @@ -38,9 +40,17 @@ 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 @@ -52,8 +62,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/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..502a388c 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,20 @@ package io.nekohasekai.sagernet.bg.proto import io.nekohasekai.sagernet.bg.AbstractInstance import io.nekohasekai.sagernet.bg.CoreRuntime +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 +34,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 +51,36 @@ abstract class BoxInstance( open suspend fun init() { buildConfig() core = CoreConfig.from(config, listOf(CoreConfig.TAG_PROXY)) + CoreRuntime.applyLogLevel(config.coreConfig) + ensureXrayAssets() 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..b44eaca3 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt @@ -54,6 +54,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 +83,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 +261,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 +277,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/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..65641336 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.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, + CORE_BOX_API_PORT.key, ) // ------------------------------------------------------------------------------------------------ lookup 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/outbound/config/ConfigGenerator.kt b/app/src/main/java/io/nekohasekai/sagernet/outbound/config/ConfigGenerator.kt index 4139e4bb..03da6811 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, @@ -979,12 +979,34 @@ 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. Unlike the desktop, which installs the dashboard itself, the + * core downloads it on first use, so that fetch goes through `proxy` rather than a possibly blocked direct route. + */ 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}") + // apiDashboardDir (generate.h:10-11), not the Clash external_ui dir; relative to the core's working dir. + val options = jsonObjectOf("enabled" to true, "path" to "sb-dashboard") + // The core refuses a detour to a `direct` outbound without dial options; direct is its default anyway. + val proxied = (state.outbounds + state.endpoints).any { + it is JsonObject && it.string("tag") == Tags.PROXY && it.string("type") != "direct" + } + if (proxied) options["http_client"] = jsonObjectOf("detour" to Tags.PROXY) + api["dashboard"] = options + } + state.coreConfig["services"] = JsonArray.of(api) } /** buildXrayConfig (:2189-2221): one socks inbound and routing rule per Xray ingress, no dns object. */ 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/ui/DashboardFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/DashboardFragment.kt new file mode 100644 index 00000000..254ee7b1 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/DashboardFragment.kt @@ -0,0 +1,423 @@ +package io.nekohasekai.sagernet.ui + +import android.annotation.SuppressLint +import android.graphics.Bitmap +import android.os.Build +import android.os.Bundle +import android.os.SystemClock +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 androidx.lifecycle.lifecycleScope +import io.nekohasekai.sagernet.BuildConfig +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.bg.BaseService +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 kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +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 core downloads the dashboard on first use and answers 404 until it has it. + */ +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 var downloadWaitStart = 0L + private var retryJob: Job? = null + + 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() { + retryJob?.cancel() + 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 + retryJob?.cancel() + 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 + downloadWaitStart = 0L + 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() { + retryJob?.cancel() + 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 + downloadWaitStart = 0L + showDashboard() + } + } + + /** The core fetches the dashboard when the service starts; a failed fetch is retried at the next start. */ + private fun waitForDownload(view: WebView) { + val now = SystemClock.elapsedRealtime() + if (downloadWaitStart == 0L) downloadWaitStart = now + if (now - downloadWaitStart >= DOWNLOAD_WAIT_MS) { + showMessage( + R.drawable.ic_baseline_warning_24, R.string.dashboard_not_downloaded_title, + getString(R.string.dashboard_not_downloaded), R.string.dashboard_retry, + ) { render(reload = true) } + return + } + showProgress(R.string.dashboard_downloading) + retryJob?.cancel() + retryJob = viewLifecycleOwner.lifecycleScope.launch { + delay(DOWNLOAD_POLL_MS) + if (view === webView) view.reload() + } + } + + 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 + retryJob?.cancel() + 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 -> waitForDownload(view) + 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 { + const val DOWNLOAD_WAIT_MS = 60_000L + const val DOWNLOAD_POLL_MS = 3_000L + + /** + * 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/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/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/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/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-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index de466833..90414a20 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -237,7 +237,6 @@ نسخه TLS مدت زمان Port hopping (به ثانیه) اثر انگشت uTLS - تعیین لینک صفحه فعال کردن Clash API سطح ثبت آمار Flow (VLESS دستورالعمل فرعی) diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index c4262db9..0770572a 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -220,7 +220,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..0f7933ba 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -219,7 +219,6 @@ 구독 최소 TLS 버전 포트 호핑 간격 (초) uTLS 지문 - 패널 URL 설정 Clash API 활성화 로그 레벨 Flow (VLESS 하위 프로토콜) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 2e8ae394..35d82371 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-код diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index c8ded38f..e645f6d8 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -231,7 +231,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..e3d14bf2 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -242,7 +242,6 @@ 拥塞控制 UDP 转发模式 sing-box 仪表板 - 设置面板 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..5b367019 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -242,7 +242,6 @@ 訂閱最低 TLS 版本 埠跳躍間隔(秒) uTLS 指紋 - 設定面板 URL 啟用 Clash API 日誌級別 Flow(VLESS 子協議) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 841554ab..f0412536 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -294,10 +294,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 +648,60 @@ 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… + Downloading 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 not downloaded yet + The core downloads it through the proxy when the service starts, which can take a few minutes on a slow connection. If the download failed, the log says why, and 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 diff --git a/app/src/main/res/xml/settings_core.xml b/app/src/main/res/xml/settings_core.xml index 03d4101f..69208538 100644 --- a/app/src/main/res/xml/settings_core.xml +++ b/app/src/main/res/xml/settings_core.xml @@ -48,6 +48,23 @@ app:title="@string/clash_api_secret" /> + + + + + + + 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/nb4a.properties b/nb4a.properties index 05387d88..03769aa3 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=66ad9287dbd76b591b26e594608822de99ede525 \ No newline at end of file From cefc295bd554a12af94c28e7de21200a2f668640 Mon Sep 17 00:00:00 2001 From: Nova Date: Mon, 28 Sep 2026 04:20:54 +0330 Subject: [PATCH 2/5] improve routing --- .../13.json | 747 ++++++++++++++++++ .../sagernet/database/RouteRuleEntity.kt | 11 +- .../sagernet/database/SagerDatabase.kt | 3 +- .../sagernet/database/backup/BackupRestore.kt | 47 ++ .../sagernet/database/backup/DesktopSchema.kt | 3 + .../sagernet/outbound/config/BuildState.kt | 3 + .../outbound/config/ConfigGenerator.kt | 40 +- .../sagernet/route/RouteProfile.kt | 57 +- .../nekohasekai/sagernet/route/RouteRule.kt | 72 +- .../nekohasekai/sagernet/route/RouteShare.kt | 70 +- .../sagernet/ui/AppListActivity.kt | 39 +- .../nekohasekai/sagernet/ui/RouteFragment.kt | 12 +- .../sagernet/ui/route/RouteProfileActivity.kt | 10 +- .../sagernet/ui/route/RouteRuleActivity.kt | 47 +- .../sagernet/ui/route/RouteTexts.kt | 6 +- .../java/moe/matsuri/nb4a/NativeInterface.kt | 23 +- app/src/main/res/drawable/ic_throne_tile.png | Bin 54201 -> 0 bytes app/src/main/res/drawable/ic_throne_tile.xml | 11 + app/src/main/res/values/arrays.xml | 7 + app/src/main/res/values/strings.xml | 13 +- .../main/res/xml/route_rule_preferences.xml | 8 + nb4a.properties | 2 +- 22 files changed, 1151 insertions(+), 80 deletions(-) create mode 100644 app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/13.json delete mode 100644 app/src/main/res/drawable/ic_throne_tile.png create mode 100644 app/src/main/res/drawable/ic_throne_tile.xml 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/src/main/java/io/nekohasekai/sagernet/database/RouteRuleEntity.kt b/app/src/main/java/io/nekohasekai/sagernet/database/RouteRuleEntity.kt index e1ee76f6..53fdbc1e 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` and + * `network_is_expensive`. List members are compact JSON string arrays in the `_json` columns, like the + * desktop stores them. */ @Entity( tableName = RouteRuleEntity.TABLE, @@ -64,6 +65,8 @@ 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, ) { fun toModel(): RouteRule = RouteRule().also { @@ -104,6 +107,8 @@ 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 } companion object { @@ -149,6 +154,8 @@ 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, ) /** 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..b2eb3da3 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,10 @@ import kotlinx.coroutines.launch ProxyGroup::class, ProxyEntity::class, RouteProfileEntity::class, RouteRuleEntity::class, SettingEntry::class, MarkerEntity::class, ], - version = 12, + version = 13, autoMigrations = [ AutoMigration(from = 8, to = 9), + AutoMigration(from = 12, to = 13), ] ) @TypeConverters(value = [SubscriptionOptions.Converter::class]) 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..583e23d8 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,8 @@ 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, 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/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 03da6811..aaa9885a 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 @@ -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,8 @@ 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 becomes a logical rule ([RouteRule.toConfigJson]). */ private fun getRouteRules(state: BuildState, route: RouteProfile): JsonArray { val out = JsonArray() @@ -922,7 +942,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 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..45e5e273 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`). [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,11 @@ 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() + /** [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 +116,8 @@ class RouteRule { putStrings(obj, "process_path", process_path) putStrings(obj, "process_path_regex", process_path_regex) putStrings(obj, "package_name", package_name) + 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 +161,47 @@ class RouteRule { return obj } + /** + * The rule as the core takes it. sing-box cannot match "no identified app" with package_name, so Apps holding + * [UNKNOWN_PACKAGE] become a logical `or` of the listed packages and an inverted `.*` package_name_regex (true + * only while no package is known), 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() ?: return flat + if (UNKNOWN_PACKAGE !in apps) return flat + flat.remove("package_name") + 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 unidentified = jsonObjectOf("package_name_regex" to JsonArray.of(".*"), "invert" to true) + val packages = apps.filter { it != UNKNOWN_PACKAGE } + val appMatch = if (packages.isEmpty()) unidentified else jsonObjectOf( + "type" to "logical", "mode" to "or", + "rules" to JsonArray.of(jsonObjectOf("package_name" to JsonValues.stringArray(packages)), unidentified), + ) + 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) + + /** 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 +257,8 @@ class RouteRule { l("process_path", process_path) l("process_path_regex", process_path_regex) l("package_name", package_name) + 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 +318,25 @@ 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::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..ebbf88a3 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", "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 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,11 @@ object RouteShare { "process_path" -> rule.process_path = list "process_path_regex" -> rule.process_path_regex = list "package_name" -> rule.package_name = 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/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/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..83d1e9e5 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", + "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", + "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/RouteTexts.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/route/RouteTexts.kt index a002e5a2..04e78e5a 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,9 @@ 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("process", rule.process_name) list("path", rule.process_path) list("path regex", rule.process_path_regex) @@ -93,6 +95,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/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/res/drawable/ic_throne_tile.png b/app/src/main/res/drawable/ic_throne_tile.png deleted file mode 100644 index 942dbe9b49c5fd94ed873887514137fd947f3b1b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 54201 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 F{~w`xOJM*2 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/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 f0412536..9ec8ce21 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -755,7 +755,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 or metered network when it imports this link, and rules that match apps never match there. Import routing profile Add this routing profile?\n\nName: %s Note: @@ -842,6 +842,7 @@ private source IP all connections inverted + metered network Route rule Drag to reorder Rule @@ -862,6 +863,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 @@ -1642,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..2e895558 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" /> + + Date: Mon, 28 Sep 2026 05:38:11 +0330 Subject: [PATCH 3/5] bundle sb panel && improve ux && remove publish from preview --- .github/actions/android-build/action.yml | 6 +- .github/actions/sing-box-dashboard/action.yml | 27 ++++++ .gitignore | 3 +- app/src/main/AndroidManifest.xml | 2 +- .../java/io/nekohasekai/sagernet/Constants.kt | 1 + .../io/nekohasekai/sagernet/bg/BaseService.kt | 1 + .../io/nekohasekai/sagernet/bg/CoreRuntime.kt | 5 +- .../sagernet/bg/SingBoxDashboard.kt | 86 +++++++++++++++++++ .../sagernet/bg/proto/BoxInstance.kt | 3 + .../sagernet/database/DataStore.kt | 3 + .../sagernet/database/ProfileManager.kt | 22 ++++- .../sagernet/database/SettingsRegistry.kt | 4 +- .../java/io/nekohasekai/sagernet/ktx/Utils.kt | 8 ++ .../outbound/config/ConfigGenerator.kt | 19 ++-- .../sagernet/ui/AutoSelectorStatusActivity.kt | 9 +- .../sagernet/ui/DashboardFragment.kt | 44 ++-------- .../ui/profile/ProfileSettingsActivity.kt | 12 ++- .../sagernet/ui/profiles/ProfileItemMenu.kt | 9 +- .../sagernet/ui/profiles/ProfileRowHolder.kt | 1 - app/src/main/res/values-ar/strings.xml | 1 - app/src/main/res/values-be/strings.xml | 1 - app/src/main/res/values-de/strings.xml | 1 - app/src/main/res/values-es/strings.xml | 1 - app/src/main/res/values-fa/strings.xml | 1 - app/src/main/res/values-fr/strings.xml | 1 - app/src/main/res/values-in/strings.xml | 1 - app/src/main/res/values-ja/strings.xml | 1 - app/src/main/res/values-ko/strings.xml | 1 - app/src/main/res/values-nb-rNO/strings.xml | 1 - app/src/main/res/values-ru/strings.xml | 1 - app/src/main/res/values-tr/strings.xml | 1 - app/src/main/res/values-uk/strings.xml | 1 - app/src/main/res/values-zh-rCN/strings.xml | 1 - app/src/main/res/values-zh-rTW/strings.xml | 1 - app/src/main/res/values/strings.xml | 6 +- buildScript/fdroid/prebuild.sh | 10 +++ 36 files changed, 214 insertions(+), 82 deletions(-) create mode 100644 .github/actions/sing-box-dashboard/action.yml create mode 100644 app/src/main/java/io/nekohasekai/sagernet/bg/SingBoxDashboard.kt 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/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 806c2b80..e954d621 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/Constants.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt @@ -39,6 +39,7 @@ object Key { 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" 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 6d14678a..4b4160a6 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt @@ -603,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) 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 8a3e0482..2fba23fd 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/CoreRuntime.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/CoreRuntime.kt @@ -17,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() } @@ -53,7 +56,7 @@ object CoreRuntime { 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 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/proto/BoxInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt index 502a388c..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,6 +2,7 @@ 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 @@ -53,6 +54,8 @@ abstract class BoxInstance( 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() } 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 b44eaca3..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 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/SettingsRegistry.kt b/app/src/main/java/io/nekohasekai/sagernet/database/SettingsRegistry.kt index 65641336..73f6324b 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/SettingsRegistry.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/SettingsRegistry.kt @@ -338,7 +338,7 @@ object SettingsRegistry { Key.ALWAYS_SHOW_ADDRESS, Key.GROUP_LAYOUT_MODE, 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, @@ -353,7 +353,7 @@ object SettingsRegistry { 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.SERVICE_ERROR_DNS, Key.SERVICE_ERROR_GEO, - CORE_BOX_API_PORT.key, + Key.RUNNING_PROFILES, CORE_BOX_API_PORT.key, ) // ------------------------------------------------------------------------------------------------ lookup 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/ConfigGenerator.kt b/app/src/main/java/io/nekohasekai/sagernet/outbound/config/ConfigGenerator.kt index aaa9885a..53e09640 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 @@ -1001,8 +1001,8 @@ class ConfigGenerator @JvmOverloads constructor( /** * 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. Unlike the desktop, which installs the dashboard itself, the - * core downloads it on first use, so that fetch goes through `proxy` rather than a possibly blocked direct route. + * 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) return @@ -1017,14 +1017,7 @@ class ConfigGenerator @JvmOverloads constructor( 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}") - // apiDashboardDir (generate.h:10-11), not the Clash external_ui dir; relative to the core's working dir. - val options = jsonObjectOf("enabled" to true, "path" to "sb-dashboard") - // The core refuses a detour to a `direct` outbound without dial options; direct is its default anyway. - val proxied = (state.outbounds + state.endpoints).any { - it is JsonObject && it.string("tag") == Tags.PROXY && it.string("type") != "direct" - } - if (proxied) options["http_client"] = jsonObjectOf("detour" to Tags.PROXY) - api["dashboard"] = options + api["dashboard"] = jsonObjectOf("enabled" to true, "path" to DASHBOARD_PATH) } state.coreConfig["services"] = JsonArray.of(api) } @@ -1067,6 +1060,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/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 index 254ee7b1..5b8b07bc 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/DashboardFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/DashboardFragment.kt @@ -4,7 +4,6 @@ import android.annotation.SuppressLint import android.graphics.Bitmap import android.os.Build import android.os.Bundle -import android.os.SystemClock import android.view.MenuItem import android.view.View import android.view.ViewGroup @@ -22,26 +21,23 @@ import androidx.annotation.StringRes import androidx.appcompat.widget.Toolbar import androidx.core.view.isInvisible import androidx.core.view.isVisible -import androidx.lifecycle.lifecycleScope 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 kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch 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 core downloads the dashboard on first use and answers 404 until it has it. + * 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 { @@ -66,8 +62,6 @@ class DashboardFragment : ToolbarFragment(R.layout.layout_dashboard), Toolbar.On /** 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 var downloadWaitStart = 0L - private var retryJob: Job? = null private val backCallback = object : OnBackPressedCallback(false) { override fun handleOnBackPressed() { @@ -98,7 +92,6 @@ class DashboardFragment : ToolbarFragment(R.layout.layout_dashboard), Toolbar.On } override fun onDestroyView() { - retryJob?.cancel() destroyWebView() origin = "" shown = false @@ -157,7 +150,6 @@ class DashboardFragment : ToolbarFragment(R.layout.layout_dashboard), Toolbar.On private fun load() { val binding = binding ?: return - retryJob?.cancel() val webView = this.webView ?: createWebView(binding.dashboardWeb)?.also { this.webView = it } if (webView == null) { origin = "" @@ -168,7 +160,6 @@ class DashboardFragment : ToolbarFragment(R.layout.layout_dashboard), Toolbar.On origin = "http://127.0.0.1:$port" activate = true seedReloads = 0 - downloadWaitStart = 0L mainFrameStatus = 0 clearHistory = true showProgress(R.string.dashboard_loading) @@ -177,7 +168,6 @@ class DashboardFragment : ToolbarFragment(R.layout.layout_dashboard), Toolbar.On /** Stops the page, whose streams would otherwise keep knocking on a port nothing serves. */ private fun unload() { - retryJob?.cancel() if (origin.isEmpty()) return origin = "" webView?.run { @@ -207,30 +197,10 @@ class DashboardFragment : ToolbarFragment(R.layout.layout_dashboard), Toolbar.On if (result != "\"ok\"") Logs.w("dashboard: server entry check returned $result") activate = false seedReloads = 0 - downloadWaitStart = 0L showDashboard() } } - /** The core fetches the dashboard when the service starts; a failed fetch is retried at the next start. */ - private fun waitForDownload(view: WebView) { - val now = SystemClock.elapsedRealtime() - if (downloadWaitStart == 0L) downloadWaitStart = now - if (now - downloadWaitStart >= DOWNLOAD_WAIT_MS) { - showMessage( - R.drawable.ic_baseline_warning_24, R.string.dashboard_not_downloaded_title, - getString(R.string.dashboard_not_downloaded), R.string.dashboard_retry, - ) { render(reload = true) } - return - } - showProgress(R.string.dashboard_downloading) - retryJob?.cancel() - retryJob = viewLifecycleOwner.lifecycleScope.launch { - delay(DOWNLOAD_POLL_MS) - if (view === webView) view.reload() - } - } - private fun showProgress(@StringRes text: Int) { val binding = binding ?: return shown = false @@ -245,7 +215,6 @@ class DashboardFragment : ToolbarFragment(R.layout.layout_dashboard), Toolbar.On @DrawableRes icon: Int, @StringRes title: Int, text: CharSequence, @StringRes action: Int, onAction: () -> Unit, ) { val binding = binding ?: return - retryJob?.cancel() shown = false binding.dashboardWeb.isInvisible = true binding.dashboardProgress.isVisible = false @@ -352,7 +321,11 @@ class DashboardFragment : ToolbarFragment(R.layout.layout_dashboard), Toolbar.On when (status) { 0 -> seed(view) -1 -> Unit - 404 -> waitForDownload(view) + 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)) } } @@ -373,9 +346,6 @@ class DashboardFragment : ToolbarFragment(R.layout.layout_dashboard), Toolbar.On } private companion object { - const val DOWNLOAD_WAIT_MS = 60_000L - const val DOWNLOAD_POLL_MS = 3_000L - /** * 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 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/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index d8b235c8..60e32c6d 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. مشروع تحديث - الجلاد تبديل تمكين تعطيل diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 64e04f0f..f58b4be3 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -53,7 +53,6 @@ Толькі проксі Рэжым абслугоўвання Абнаўленне - Перамыкач Пераключыць Уключыць Выключыць diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 0d830ae4..ff1d5c36 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 diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 83c7c13b..104addfc 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 diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 90414a20..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 تنظیمات کپی diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 8ab958f1..13a9934a 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 diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 9fce51fc..0bbbcc73 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 diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 0770572a..07fb93a5 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -10,7 +10,6 @@ プロキシのみ サービスモード アップデート - スイッチャー トグル 有効化 無効化 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 0f7933ba..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 대시보드 복사 업데이트 diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 367c558f..3d4303ac 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 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 35d82371..d220c5af 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -207,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..45a489f7 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 diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index e645f6d8..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 Копіювати diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index e3d14bf2..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 基本 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 5b367019..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) 額外標頭 主題 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9ec8ce21..ff7624fc 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 @@ -655,14 +654,13 @@ Reload Retry Loading the dashboard… - Downloading 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 not downloaded yet - The core downloads it through the proxy when the service starts, which can take a few minutes on a slow connection. If the download failed, the log says why, and reloading the service tries again. + 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. 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 From 0cc7ac6f0d72427cbd59b5b08ca2b70195935a43 Mon Sep 17 00:00:00 2001 From: Nova Date: Mon, 28 Sep 2026 12:09:33 +0330 Subject: [PATCH 4/5] add package regex --- .../14.json | 754 ++++++++++++++++++ .../sagernet/database/RouteRuleEntity.kt | 9 +- .../sagernet/database/SagerDatabase.kt | 3 +- .../sagernet/database/backup/DesktopSchema.kt | 1 + .../outbound/config/ConfigGenerator.kt | 3 +- .../nekohasekai/sagernet/route/RouteRule.kt | 42 +- .../nekohasekai/sagernet/route/RouteShare.kt | 9 +- .../sagernet/ui/route/RouteRuleActivity.kt | 6 +- .../sagernet/ui/route/RouteRuleChecks.kt | 1 + .../sagernet/ui/route/RouteTexts.kt | 1 + app/src/main/res/values/strings.xml | 4 +- .../main/res/xml/route_rule_preferences.xml | 4 + 12 files changed, 808 insertions(+), 29 deletions(-) create mode 100644 app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/14.json 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/java/io/nekohasekai/sagernet/database/RouteRuleEntity.kt b/app/src/main/java/io/nekohasekai/sagernet/database/RouteRuleEntity.kt index 53fdbc1e..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,9 +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 the Android-only `network_type_json` and - * `network_is_expensive`. 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, @@ -67,6 +67,7 @@ data class RouteRuleEntity( @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 { @@ -109,6 +110,7 @@ data class RouteRuleEntity( it.tls_spoof_method = tlsSpoofMethod it.network_type = listFromJson(networkTypeJson) it.network_is_expensive = networkIsExpensive + it.package_name_regex = listFromJson(packageNameRegexJson) } companion object { @@ -156,6 +158,7 @@ data class RouteRuleEntity( 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 b2eb3da3..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,10 +22,11 @@ import kotlinx.coroutines.launch ProxyGroup::class, ProxyEntity::class, RouteProfileEntity::class, RouteRuleEntity::class, SettingEntry::class, MarkerEntity::class, ], - version = 13, + 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/backup/DesktopSchema.kt b/app/src/main/java/io/nekohasekai/sagernet/database/backup/DesktopSchema.kt index 583e23d8..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 @@ -141,6 +141,7 @@ object DesktopSchema { 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/outbound/config/ConfigGenerator.kt b/app/src/main/java/io/nekohasekai/sagernet/outbound/config/ConfigGenerator.kt index 53e09640..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 @@ -933,7 +933,8 @@ 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), rule-level TLS spoof is dropped (D8) and a rule - * whose apps include unidentified ones becomes a logical rule ([RouteRule.toConfigJson]). + * 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() 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 45e5e273..8528d3ce 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/route/RouteRule.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/route/RouteRule.kt @@ -10,8 +10,8 @@ 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 the desktop also stores (column `package_name_json`, rule key `package_name`). [network_type] and - * [network_is_expensive] exist only on Android: the desktop drops a rule carrying them on import. + * 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 { @@ -38,6 +38,8 @@ class RouteRule { @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 @@ -116,6 +118,7 @@ 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) @@ -162,25 +165,30 @@ class RouteRule { } /** - * The rule as the core takes it. sing-box cannot match "no identified app" with package_name, so Apps holding - * [UNKNOWN_PACKAGE] become a logical `or` of the listed packages and an inverted `.*` package_name_regex (true - * only while no package is known), and-ed with the rule's other conditions; invert then applies to the whole. + * 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() ?: return flat - if (UNKNOWN_PACKAGE !in apps) return flat + 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 unidentified = jsonObjectOf("package_name_regex" to JsonArray.of(".*"), "invert" to true) - val packages = apps.filter { it != UNKNOWN_PACKAGE } - val appMatch = if (packages.isEmpty()) unidentified else jsonObjectOf( - "type" to "logical", "mode" to "or", - "rules" to JsonArray.of(jsonObjectOf("package_name" to JsonValues.stringArray(packages)), unidentified), - ) + 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) @@ -190,7 +198,7 @@ class RouteRule { } /** Whether the match depends on the connection's app, which DNS rules and tun routes cannot follow. */ - fun matchesByApp(): Boolean = !blank(package_name) + 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 { @@ -257,6 +265,7 @@ 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) @@ -318,8 +327,9 @@ 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::network_type, - 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( 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 ebbf88a3..587273ae 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/route/RouteShare.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/route/RouteShare.kt @@ -21,9 +21,9 @@ object RouteShare { "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", "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", + "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) @@ -226,7 +226,7 @@ object RouteShare { else -> "" } - /** set_field_value (RouteRule.cpp:480-600) plus Android's network_type and network_is_expensive. */ + /** 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() } @@ -251,6 +251,7 @@ 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 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 83d1e9e5..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 @@ -64,7 +64,7 @@ class RouteRuleActivity : ThemedActivity(R.layout.layout_config_settings), OnPre private val LIST_KEYS = listOf( "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", - "process_name", "process_path", "process_path_regex", "wifi_ssid", "wifi_bssid", + "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", @@ -74,8 +74,8 @@ class RouteRuleActivity : ThemedActivity(R.layout.layout_config_settings), OnPre 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", - "network_is_expensive", "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. */ 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 04e78e5a..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 @@ -82,6 +82,7 @@ internal object RouteTexts { 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) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ff7624fc..45ed27a5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -753,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. - Some rules use conditions only Android supports. The desktop skips rules with network type or metered network when it imports this link, and rules that match apps never match there. + 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: @@ -899,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) diff --git a/app/src/main/res/xml/route_rule_preferences.xml b/app/src/main/res/xml/route_rule_preferences.xml index 2e895558..9e1805d5 100644 --- a/app/src/main/res/xml/route_rule_preferences.xml +++ b/app/src/main/res/xml/route_rule_preferences.xml @@ -154,6 +154,10 @@ app:key="no_drop" app:summary="@string/route_rule_no_drop_sum" app:title="@string/route_rule_no_drop" /> + Date: Fri, 9 Oct 2026 15:31:39 +0330 Subject: [PATCH 5/5] remove stale set_panel_url and tile_title translations Both strings were removed from values/strings.xml (clash panel and tile label), but main's i18n pass had translated them in the meantime. --- app/src/main/res/values-ar/strings.xml | 1 - app/src/main/res/values-be/strings.xml | 1 - app/src/main/res/values-de/strings.xml | 1 - app/src/main/res/values-es/strings.xml | 1 - app/src/main/res/values-fr/strings.xml | 1 - app/src/main/res/values-in/strings.xml | 1 - app/src/main/res/values-it/strings.xml | 2 -- app/src/main/res/values-nb-rNO/strings.xml | 1 - app/src/main/res/values-nl/strings.xml | 2 -- app/src/main/res/values-pt-rBR/strings.xml | 2 -- app/src/main/res/values-tr/strings.xml | 1 - app/src/main/res/values-zh-rHK/strings.xml | 2 -- 12 files changed, 16 deletions(-) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 60e32c6d..bace029d 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1123,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 f58b4be3..0d679a15 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -1077,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 ff1d5c36..c735c4d0 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -246,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 104addfc..9f55dd4e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -246,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-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 13a9934a..75184f08 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -246,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 0bbbcc73..dbcc5c7d 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -243,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-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 3d4303ac..17072f3b 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -253,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-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 45a489f7..67be9913 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -257,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-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 子協議)