From 0dc763e1de950a022d26412bfb36c3c9f11db572 Mon Sep 17 00:00:00 2001 From: sameral Date: Fri, 7 Aug 2026 02:57:02 +0800 Subject: [PATCH 1/8] Revert "feat(debug): add built-in diagnostics center (#4)" This reverts commit 989ef6a2a1087a83340bc091f9b67a38956cf7de. --- app/src/main/AndroidManifest.xml | 15 -- .../github/kr328/clash/DiagnosticsActivity.kt | 243 ------------------ .../github/kr328/clash/SettingsActivity.kt | 2 - app/src/main/res/xml/file_paths.xml | 6 - .../kr328/clash/design/DiagnosticsDesign.kt | 193 -------------- .../kr328/clash/design/SettingsDesign.kt | 2 +- .../src/main/res/layout/design_settings.xml | 7 - design/src/main/res/values-zh/strings.xml | 25 -- design/src/main/res/values/strings.xml | 25 -- 9 files changed, 1 insertion(+), 517 deletions(-) delete mode 100644 app/src/main/java/com/github/kr328/clash/DiagnosticsActivity.kt delete mode 100644 app/src/main/res/xml/file_paths.xml delete mode 100644 design/src/main/java/com/github/kr328/clash/design/DiagnosticsDesign.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5c619c1a9d..9900007e38 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -182,27 +182,12 @@ android:configChanges="uiMode" android:exported="false" android:label="@string/help" /> - - - - - () { - override suspend fun main() { - val design = DiagnosticsDesign(this, fetchState()) - - setContentDesign(design) - - while (isActive) { - select { - events.onReceive { - when (it) { - Event.ActivityStart, - Event.ServiceRecreated, - Event.ClashStart, - Event.ClashStop -> design.patch(fetchState()) - else -> Unit - } - } - design.requests.onReceive { - when (it) { - DiagnosticsDesign.Request.EnableDebug -> enableDebug(design) - DiagnosticsDesign.Request.DisableDebug -> disableDebug(design) - DiagnosticsDesign.Request.Export -> exportDiagnostics(design) - } - } - } - } - } - - private suspend fun fetchState(): DiagnosticsDesign.State { - val store = ServiceStore(this) - - val override = withClash { - queryOverride(Clash.OverrideSlot.Persist) - } - - val mode = if (clashRunning) { - try { - withClash { - queryTunnelState().mode.name - } - } catch (e: Exception) { - Log.w("Query tunnel state: $e") - - null - } - } else { - null - } - - val connections = if (clashRunning) { - try { - val json = withClash { - Clash.queryConnections() - } - - Json.decodeFromString(JsonArray.serializer(), json).size - } catch (e: Exception) { - Log.w("Query connections: $e") - - 0 - } - } else { - 0 - } - - return DiagnosticsDesign.State( - clashRunning = clashRunning, - appVersion = BuildConfig.VERSION_NAME, - coreVersion = if (clashRunning) Bridge.nativeCoreVersion() else "-", - mode = mode, - tunStack = store.tunStackMode, - logLevel = override.logLevel?.name, - dnsEnhancedMode = override.dns.enhancedMode?.name, - ipv6 = override.ipv6?.toString(), - connections = connections, - ) - } - - private suspend fun enableDebug(design: DiagnosticsDesign) { - patchLogLevel(LogMessage.Level.Debug) - - startForegroundServiceCompat(LogcatService::class.intent) - - design.showToast(DesignR.string.diagnostics_debug_enabled, ToastDuration.Short) - - design.patch(fetchState()) - } - - private suspend fun disableDebug(design: DiagnosticsDesign) { - patchLogLevel(null) - - stopService(LogcatService::class.intent) - - design.showToast(DesignR.string.diagnostics_debug_disabled, ToastDuration.Short) - - design.patch(fetchState()) - } - - private suspend fun patchLogLevel(level: LogMessage.Level?) { - withClash { - val configuration = queryOverride(Clash.OverrideSlot.Persist) - - patchOverride( - Clash.OverrideSlot.Persist, - configuration.copy(logLevel = level) - ) - } - } - - private suspend fun exportDiagnostics(design: DiagnosticsDesign) { - val bundle = buildDiagnosticsBundle() - - if (bundle == null) { - design.showToast(DesignR.string.diagnostics_export_failed, ToastDuration.Short) - - return - } - - val uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", bundle) - - val intent = Intent(Intent.ACTION_SEND).apply { - type = "application/zip" - putExtra(Intent.EXTRA_STREAM, uri) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - - startActivity(Intent.createChooser(intent, getString(DesignR.string.diagnostics_export))) - } - - private suspend fun buildDiagnosticsBundle(): File? = withContext(Dispatchers.IO) { - try { - val dir = File(cacheDir, "diagnostics").apply { mkdirs() } - - dir.listFiles()?.forEach { it.delete() } - - val timestamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) - - val zip = File(dir, "diagnostics-$timestamp.zip") - - ZipOutputStream(BufferedOutputStream(FileOutputStream(zip))).use { zos -> - zos.writeEntry("info.txt", buildInfoText()) - - if (clashRunning) { - zos.writeEntry("connections.json", Clash.queryConnections()) - } - - logsDir.listFiles() - ?.sortedByDescending { it.lastModified() } - ?.forEach { logFile -> - zos.putNextEntry(ZipEntry("logs/${logFile.name}")) - - logFile.inputStream().use { it.copyTo(zos) } - - zos.closeEntry() - } - } - - zip - } catch (e: Exception) { - Log.e("Build diagnostics bundle: $e", e) - - null - } - } - - private suspend fun buildInfoText(): String { - val store = ServiceStore(this) - - val builder = StringBuilder() - - builder.appendLine("ClashMetaForAndroid diagnostics") - builder.appendLine("Generated: ${Date()}") - builder.appendLine() - - builder.appendLine("App version: ${BuildConfig.VERSION_NAME}") - builder.appendLine("Core version: ${if (clashRunning) Bridge.nativeCoreVersion() else "-"}") - builder.appendLine("Clash running: $clashRunning") - builder.appendLine() - - builder.appendLine("Device: ${Build.MANUFACTURER} ${Build.MODEL}") - builder.appendLine("Android SDK: ${Build.VERSION.SDK_INT}") - builder.appendLine() - - builder.appendLine("TUN stack: ${store.tunStackMode}") - - try { - val override = withClash { - queryOverride(Clash.OverrideSlot.Persist) - } - - builder.appendLine("Mode: ${override.mode?.name}") - builder.appendLine("Log level: ${override.logLevel?.name}") - builder.appendLine("IPv6: ${override.ipv6}") - builder.appendLine("DNS enhanced mode: ${override.dns.enhancedMode?.name}") - builder.appendLine("DNS enable: ${override.dns.enable}") - builder.appendLine("DNS prefer H3: ${override.dns.preferH3}") - } catch (e: Exception) { - builder.appendLine("Query override failed: $e") - } - - return builder.toString() - } - - private fun ZipOutputStream.writeEntry(name: String, content: String) { - putNextEntry(ZipEntry(name)) - - write(content.toByteArray()) - - closeEntry() - } -} diff --git a/app/src/main/java/com/github/kr328/clash/SettingsActivity.kt b/app/src/main/java/com/github/kr328/clash/SettingsActivity.kt index e86658723f..95a0393367 100644 --- a/app/src/main/java/com/github/kr328/clash/SettingsActivity.kt +++ b/app/src/main/java/com/github/kr328/clash/SettingsActivity.kt @@ -26,8 +26,6 @@ class SettingsActivity : BaseActivity() { startActivity(OverrideSettingsActivity::class.intent) SettingsDesign.Request.StartMetaFeature -> startActivity(MetaFeatureSettingsActivity::class.intent) - SettingsDesign.Request.StartDiagnostics -> - startActivity(DiagnosticsActivity::class.intent) } } } diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml deleted file mode 100644 index 7c46cb1f42..0000000000 --- a/app/src/main/res/xml/file_paths.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - diff --git a/design/src/main/java/com/github/kr328/clash/design/DiagnosticsDesign.kt b/design/src/main/java/com/github/kr328/clash/design/DiagnosticsDesign.kt deleted file mode 100644 index b1698b4598..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/DiagnosticsDesign.kt +++ /dev/null @@ -1,193 +0,0 @@ -package com.github.kr328.clash.design - -import android.content.Context -import android.view.View -import com.github.kr328.clash.design.databinding.DesignSettingsCommonBinding -import com.github.kr328.clash.design.preference.ClickablePreference -import com.github.kr328.clash.design.preference.category -import com.github.kr328.clash.design.preference.clickable -import com.github.kr328.clash.design.preference.preferenceScreen -import com.github.kr328.clash.design.preference.tips -import com.github.kr328.clash.design.util.applyFrom -import com.github.kr328.clash.design.util.bindAppBarElevation -import com.github.kr328.clash.design.util.layoutInflater -import com.github.kr328.clash.design.util.root - -class DiagnosticsDesign( - context: Context, - initialState: State, -) : Design(context) { - enum class Request { - EnableDebug, DisableDebug, Export, - } - - data class State( - val clashRunning: Boolean, - val appVersion: String, - val coreVersion: String, - val mode: String?, - val tunStack: String, - val logLevel: String?, - val dnsEnhancedMode: String?, - val ipv6: String?, - val connections: Int, - ) - - private val binding = DesignSettingsCommonBinding - .inflate(context.layoutInflater, context.root, false) - - override val root: View - get() = binding.root - - private var state: State = initialState - - private val statusValues = mutableMapOf() - - init { - binding.surface = surface - - binding.activityBarLayout.applyFrom(context) - - binding.scrollRoot.bindAppBarElevation(binding.activityBarLayout) - - rebuild() - } - - fun patch(newState: State) { - state = newState - - statusValues["clash_status"]?.let { it.summary = statusText(state) } - statusValues["app_version"]?.let { it.summary = state.appVersion } - statusValues["core_version"]?.let { it.summary = state.coreVersion } - statusValues["mode"]?.let { it.summary = state.mode ?: "-" } - statusValues["tun_stack"]?.let { it.summary = state.tunStack } - statusValues["log_level"]?.let { it.summary = state.logLevel ?: "-" } - statusValues["dns_mode"]?.let { it.summary = state.dnsEnhancedMode ?: "-" } - statusValues["ipv6"]?.let { it.summary = state.ipv6 ?: "-" } - statusValues["connections"]?.let { it.summary = state.connections.toString() } - } - - private fun statusText(state: State): String { - return if (state.clashRunning) { - context.getString(R.string.diagnostics_running) - } else { - context.getString(R.string.diagnostics_stopped) - } - } - - private fun rebuild() { - binding.content.removeAllViews() - - val screen = preferenceScreen(context) { - tips(R.string.diagnostics_tips) - - category(R.string.diagnostics_status) - - clickable( - title = R.string.diagnostics_clash_status, - ) { - summary = statusText(state) - }.also { - statusValues["clash_status"] = it - } - - clickable( - title = R.string.diagnostics_app_version, - ) { - summary = state.appVersion - }.also { - statusValues["app_version"] = it - } - - clickable( - title = R.string.diagnostics_core_version, - ) { - summary = state.coreVersion - }.also { - statusValues["core_version"] = it - } - - clickable( - title = R.string.diagnostics_mode, - ) { - summary = state.mode ?: "-" - }.also { - statusValues["mode"] = it - } - - clickable( - title = R.string.diagnostics_tun_stack, - ) { - summary = state.tunStack - }.also { - statusValues["tun_stack"] = it - } - - clickable( - title = R.string.diagnostics_log_level, - ) { - summary = state.logLevel ?: "-" - }.also { - statusValues["log_level"] = it - } - - clickable( - title = R.string.diagnostics_dns_mode, - ) { - summary = state.dnsEnhancedMode ?: "-" - }.also { - statusValues["dns_mode"] = it - } - - clickable( - title = R.string.diagnostics_ipv6, - ) { - summary = state.ipv6 ?: "-" - }.also { - statusValues["ipv6"] = it - } - - clickable( - title = R.string.diagnostics_connections, - ) { - summary = state.connections.toString() - }.also { - statusValues["connections"] = it - } - - category(R.string.diagnostics_actions) - - clickable( - title = R.string.diagnostics_enable_debug, - summary = R.string.diagnostics_enable_debug_summary, - icon = R.drawable.ic_baseline_flash_on, - ) { - clicked { - requests.trySend(Request.EnableDebug) - } - } - - clickable( - title = R.string.diagnostics_disable_debug, - summary = R.string.diagnostics_disable_debug_summary, - icon = R.drawable.ic_baseline_stop, - ) { - clicked { - requests.trySend(Request.DisableDebug) - } - } - - clickable( - title = R.string.diagnostics_export, - summary = R.string.diagnostics_export_summary, - icon = R.drawable.ic_baseline_save, - ) { - clicked { - requests.trySend(Request.Export) - } - } - } - - binding.content.addView(screen.root) - } -} diff --git a/design/src/main/java/com/github/kr328/clash/design/SettingsDesign.kt b/design/src/main/java/com/github/kr328/clash/design/SettingsDesign.kt index a3d7f95993..0536a587cd 100644 --- a/design/src/main/java/com/github/kr328/clash/design/SettingsDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/SettingsDesign.kt @@ -10,7 +10,7 @@ import com.github.kr328.clash.design.util.root class SettingsDesign(context: Context) : Design(context) { enum class Request { - StartApp, StartNetwork, StartOverride, StartMetaFeature, StartDiagnostics, + StartApp, StartNetwork, StartOverride, StartMetaFeature, } private val binding = DesignSettingsBinding diff --git a/design/src/main/res/layout/design_settings.xml b/design/src/main/res/layout/design_settings.xml index b52719cea9..885ec4a5c8 100644 --- a/design/src/main/res/layout/design_settings.xml +++ b/design/src/main/res/layout/design_settings.xml @@ -56,13 +56,6 @@ app:icon="@drawable/ic_baseline_meta" app:text="@string/meta_features" /> - - diff --git a/design/src/main/res/values-zh/strings.xml b/design/src/main/res/values-zh/strings.xml index bbbbe649ab..06e43723fe 100644 --- a/design/src/main/res/values-zh/strings.xml +++ b/design/src/main/res/values-zh/strings.xml @@ -284,29 +284,4 @@ 启动 Clash 服务 停止 Clash 停止 Clash 服务 - - 诊断 - 一键开启调试日志并导出诊断包,用于问题反馈。 - 状态 - 操作 - Clash 状态 - 运行中 - 未运行 - 应用版本 - 核心版本 - 模式 - TUN 栈 - 日志等级 - DNS 增强模式 - IPv6 - 当前连接数 - 开启调试日志 - 将 clash 日志等级设为 debug 并写入日志文件 - 关闭调试日志 - 恢复配置的日志等级并停止记录 - 导出诊断包 - 打包应用信息、日志与连接快照用于分享 - 调试日志已开启,请重新连接 VPN 生效。 - 调试日志已关闭。 - 生成诊断包失败 diff --git a/design/src/main/res/values/strings.xml b/design/src/main/res/values/strings.xml index eaca20cf73..438de8ae63 100644 --- a/design/src/main/res/values/strings.xml +++ b/design/src/main/res/values/strings.xml @@ -374,29 +374,4 @@ Start Clash service Stop Clash Stop Clash service - - Diagnostics - One-tap debug logging and diagnostics bundle for issue reporting. - Status - Actions - Clash status - Running - Stopped - App version - Core version - Mode - TUN stack - Log level - DNS enhanced mode - IPv6 - Active connections - Enable debug logging - Set clash log level to debug and record logs to file - Disable debug logging - Restore configured log level and stop recording - Export diagnostics bundle - Zip app info, logs and connections for sharing - Debug logging enabled. Reconnect the VPN to take effect. - Debug logging disabled. - Failed to generate diagnostics bundle From 058f7171e82a93025d14189ae217af5a1cddc24c Mon Sep 17 00:00:00 2001 From: sameral Date: Fri, 7 Aug 2026 02:59:21 +0800 Subject: [PATCH 2/8] feat(proxy): show full dialer-proxy chain details on long-press - Native side resolves dialer-proxy chains into physical order (entry -> ... -> exit) for every proxy in a group - Proxy model gains a chain field - Long-press a proxy node shows a dialog listing all hops with entry/middle/exit labels - Remove diagnostics feature (revert #4): the built-in logs page already covers log viewing/recording/export --- .../com/github/kr328/clash/ProxyActivity.kt | 3 ++ core/src/main/golang/native/tunnel/proxies.go | 49 ++++++++++++++++--- .../github/kr328/clash/core/model/Proxy.kt | 1 + .../github/kr328/clash/design/ProxyDesign.kt | 45 +++++++++++++++-- .../clash/design/adapter/ProxyAdapter.kt | 7 +++ design/src/main/res/values-zh/strings.xml | 5 ++ design/src/main/res/values/strings.xml | 5 ++ 7 files changed, 106 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/github/kr328/clash/ProxyActivity.kt b/app/src/main/java/com/github/kr328/clash/ProxyActivity.kt index 9136a3000d..61e9be93a2 100644 --- a/app/src/main/java/com/github/kr328/clash/ProxyActivity.kt +++ b/app/src/main/java/com/github/kr328/clash/ProxyActivity.kt @@ -90,6 +90,9 @@ class ProxyActivity : BaseActivity() { design.requestRedrawVisible() } + is ProxyDesign.Request.ShowChain -> { + design.showChainDialog(it.proxy) + } is ProxyDesign.Request.UrlTest -> { launch { withClash { diff --git a/core/src/main/golang/native/tunnel/proxies.go b/core/src/main/golang/native/tunnel/proxies.go index e750b7adde..5373398e99 100644 --- a/core/src/main/golang/native/tunnel/proxies.go +++ b/core/src/main/golang/native/tunnel/proxies.go @@ -22,12 +22,13 @@ const ( ) type Proxy struct { - Name string `json:"name"` - Title string `json:"title"` - Subtitle string `json:"subtitle"` - Type string `json:"type"` - Delay int `json:"delay"` - IsGroup bool `json:"isGroup"` + Name string `json:"name"` + Title string `json:"title"` + Subtitle string `json:"subtitle"` + Type string `json:"type"` + Delay int `json:"delay"` + IsGroup bool `json:"isGroup"` + Chain []string `json:"chain,omitempty"` } type ProxyGroup struct { @@ -199,11 +200,47 @@ func convertProxies(proxies []C.Proxy, uiSubtitlePattern *regexp2.Regexp) []*Pro Type: p.Type().String(), Delay: int(p.LastDelayForTestUrl(testURL)), IsGroup: isGroup, + Chain: buildProxyChain(p), }) } return result } +// buildProxyChain resolves the dialer-proxy chain of a proxy into physical +// order: from the outermost entry node (dialed by the local device) to the +// proxy itself which acts as the exit node towards the target server. +func buildProxyChain(p C.Proxy) []string { + var reversed []string + seen := map[string]bool{} + cur := p + + for cur != nil && !seen[cur.Name()] { + seen[cur.Name()] = true + + dialerName := cur.Adapter().ProxyInfo().DialerProxy + if dialerName == "" { + break + } + + next, ok := tunnel.Proxies()[dialerName] + if !ok { + reversed = append(reversed, dialerName) + break + } + + reversed = append(reversed, dialerName) + cur = next + } + + chain := make([]string, 0, len(reversed)+1) + for i := len(reversed) - 1; i >= 0; i-- { + chain = append(chain, reversed[i]) + } + chain = append(chain, p.Name()) + + return chain +} + func collectProviders(providers []provider.ProxyProvider, uiSubtitlePattern *regexp2.Regexp) []*Proxy { result := make([]*Proxy, 0, 128) diff --git a/core/src/main/java/com/github/kr328/clash/core/model/Proxy.kt b/core/src/main/java/com/github/kr328/clash/core/model/Proxy.kt index 02f910d776..446556a64e 100644 --- a/core/src/main/java/com/github/kr328/clash/core/model/Proxy.kt +++ b/core/src/main/java/com/github/kr328/clash/core/model/Proxy.kt @@ -13,6 +13,7 @@ data class Proxy( val type: String, val delay: Int, var isGroup: Boolean, + val chain: List = emptyList(), ) : Parcelable { override fun writeToParcel(parcel: Parcel, flags: Int) { Parcelizer.encodeToParcel(serializer(), parcel, this) diff --git a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt index 7dc7f413d7..4fb48bb21e 100644 --- a/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/ProxyDesign.kt @@ -18,6 +18,7 @@ import com.github.kr328.clash.design.util.applyFrom import com.github.kr328.clash.design.util.layoutInflater import com.github.kr328.clash.design.util.resolveThemedColor import com.github.kr328.clash.design.util.root +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.tabs.TabLayoutMediator import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -35,6 +36,7 @@ class ProxyDesign( data class PatchMode(val mode: TunnelState.Mode?) : Request() data class Reload(val index: Int) : Request() data class Select(val index: Int, val name: String) : Request() + data class ShowChain(val index: Int, val proxy: Proxy) : Request() data class UrlTest(val index: Int) : Request() } @@ -83,6 +85,37 @@ class ProxyDesign( } } + suspend fun showChainDialog(proxy: Proxy) { + val chain = proxy.chain + + if (chain.size <= 1) + return + + val builder = StringBuilder() + + chain.forEachIndexed { index, name -> + val label = when (index) { + 0 -> context.getString(R.string.chain_entry) + chain.size - 1 -> context.getString(R.string.chain_exit) + else -> context.getString(R.string.chain_middle) + } + + builder.append(label).append(": ").append(name) + + if (index != chain.size - 1) { + builder.append("\n ↓\n") + } + } + + withContext(Dispatchers.Main) { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.chain_detail) + .setMessage(builder.toString()) + .setPositiveButton(R.string.ok, null) + .show() + } + } + suspend fun showModeSwitchTips() { withContext(Dispatchers.Main) { Toast.makeText(context, R.string.mode_switch_tips, Toast.LENGTH_LONG).show() @@ -116,9 +149,15 @@ class ProxyDesign( surface, config, List(groupNames.size) { index -> - ProxyAdapter(config) { name -> - requests.trySend(Request.Select(index, name)) - } + ProxyAdapter( + config, + { name -> + requests.trySend(Request.Select(index, name)) + }, + { proxy -> + requests.trySend(Request.ShowChain(index, proxy)) + } + ) } ) { if (it == currentItem) diff --git a/design/src/main/java/com/github/kr328/clash/design/adapter/ProxyAdapter.kt b/design/src/main/java/com/github/kr328/clash/design/adapter/ProxyAdapter.kt index 619c17e58c..5daafb5154 100644 --- a/design/src/main/java/com/github/kr328/clash/design/adapter/ProxyAdapter.kt +++ b/design/src/main/java/com/github/kr328/clash/design/adapter/ProxyAdapter.kt @@ -9,6 +9,7 @@ import com.github.kr328.clash.design.component.ProxyViewState class ProxyAdapter( private val config: ProxyViewConfig, private val clicked: (String) -> Unit, + private val longClicked: (Proxy) -> Unit = {}, ) : RecyclerView.Adapter() { class Holder(val view: ProxyView) : RecyclerView.ViewHolder(view) @@ -29,6 +30,12 @@ class ProxyAdapter( clicked(current.proxy.name) } + setOnLongClickListener { + longClicked(current.proxy) + + true + } + val isSelector = selectable isFocusable = isSelector diff --git a/design/src/main/res/values-zh/strings.xml b/design/src/main/res/values-zh/strings.xml index 06e43723fe..d7e947caeb 100644 --- a/design/src/main/res/values-zh/strings.xml +++ b/design/src/main/res/values-zh/strings.xml @@ -284,4 +284,9 @@ 启动 Clash 服务 停止 Clash 停止 Clash 服务 + + 链路详情 + 入口 + 中转 + 出口 diff --git a/design/src/main/res/values/strings.xml b/design/src/main/res/values/strings.xml index 438de8ae63..0cd168f6e8 100644 --- a/design/src/main/res/values/strings.xml +++ b/design/src/main/res/values/strings.xml @@ -374,4 +374,9 @@ Start Clash service Stop Clash Stop Clash service + + Chain Details + Entry + Hop + Exit From 40b8527dbfdaed976c3c3abec42d9184ce88adae Mon Sep 17 00:00:00 2001 From: sameral Date: Fri, 7 Aug 2026 03:15:21 +0800 Subject: [PATCH 3/8] ci: restore stable debug signing key from Actions secret Replace branch-scoped actions/cache with a repository secret holding the base64-encoded keystore, so every branch/run signs the agent APK with the same certificate and overwrite installs keep working. --- .github/workflows/build-agent.yaml | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/.github/workflows/build-agent.yaml b/.github/workflows/build-agent.yaml index cfeab2e743..3828667e4f 100644 --- a/.github/workflows/build-agent.yaml +++ b/.github/workflows/build-agent.yaml @@ -30,27 +30,12 @@ jobs: distribution: temurin java-version: 21 - - name: Restore stable Agent debug signing key - uses: actions/cache@v5 - with: - path: ~/.android/debug.keystore - key: clash-meta-agent-debug-keystore-v1 - - - name: Ensure stable Agent debug signing key + - name: Restore stable Agent debug signing key from secret shell: bash run: | mkdir -p "$HOME/.android" - if [ ! -f "$HOME/.android/debug.keystore" ]; then - keytool -genkeypair -v \ - -keystore "$HOME/.android/debug.keystore" \ - -storepass android \ - -alias androiddebugkey \ - -keypass android \ - -keyalg RSA \ - -keysize 2048 \ - -validity 10000 \ - -dname "CN=Clash Meta AI Debug,O=Clash Meta AI,C=CN" - fi + echo "${{ secrets.AGENT_DEBUG_KEYSTORE }}" | base64 -d > "$HOME/.android/debug.keystore" + test -s "$HOME/.android/debug.keystore" - name: Set up Gradle uses: gradle/actions/setup-gradle@v5 From d7ce408618f179c1f8029660c005730995071d95 Mon Sep 17 00:00:00 2001 From: sameral Date: Fri, 7 Aug 2026 03:18:58 +0800 Subject: [PATCH 4/8] fix(proxy): add missing Proxy import in ProxyAdapter --- .../java/com/github/kr328/clash/design/adapter/ProxyAdapter.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/design/src/main/java/com/github/kr328/clash/design/adapter/ProxyAdapter.kt b/design/src/main/java/com/github/kr328/clash/design/adapter/ProxyAdapter.kt index 5daafb5154..356328eff6 100644 --- a/design/src/main/java/com/github/kr328/clash/design/adapter/ProxyAdapter.kt +++ b/design/src/main/java/com/github/kr328/clash/design/adapter/ProxyAdapter.kt @@ -2,6 +2,7 @@ package com.github.kr328.clash.design.adapter import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView +import com.github.kr328.clash.core.model.Proxy import com.github.kr328.clash.design.component.ProxyView import com.github.kr328.clash.design.component.ProxyViewConfig import com.github.kr328.clash.design.component.ProxyViewState From 06bce220de62804e7be6726cda7d0fa136925b24 Mon Sep 17 00:00:00 2001 From: sameral Date: Fri, 7 Aug 2026 03:55:31 +0800 Subject: [PATCH 5/8] feat(agent): polish streaming output, markdown styling, stop behavior and core-version prompt 1. Streaming jitter: commit at ~15fps with larger chunks (66ms interval, 120 chars/commit, min 80 cps) and includeFontPadding=false for stable line metrics 2. Markdown visuals: theme-aware colors (surfaceVariant + onSurfaceVariant), rounded code blocks replacing flat gray, primary-color quote accent bar, primary link color 3. Stop preserves output: cancellation keeps the visible streamed text and appends a quoted stop note instead of wiping the message 4. Prompt: runtime.status now reports core_version + app_version; system prompt rule 13 requires checking the running mihomo core version before any config/override change and forbids unsupported fields --- .../kr328/clash/agent/runtime/AgentEngine.kt | 8 +- .../com/github/kr328/clash/AgentActivity.kt | 8 +- .../kr328/clash/agent/AgentChatAdapter.kt | 102 +++++++++++++++++- .../clash/agent/AndroidAgentToolExecutor.kt | 3 + .../kr328/clash/agent/SmoothMarkdownStream.kt | 11 +- .../agent/res/layout/item_agent_message.xml | 1 + 6 files changed, 125 insertions(+), 8 deletions(-) diff --git a/agent/src/main/java/com/github/kr328/clash/agent/runtime/AgentEngine.kt b/agent/src/main/java/com/github/kr328/clash/agent/runtime/AgentEngine.kt index 5a5e10497b..4c6bf4c81a 100644 --- a/agent/src/main/java/com/github/kr328/clash/agent/runtime/AgentEngine.kt +++ b/agent/src/main/java/com/github/kr328/clash/agent/runtime/AgentEngine.kt @@ -212,8 +212,12 @@ class AgentEngine( 10. Do not call nonexistent tools or ask the user to manually edit files that an exposed tool can handle. 11. App-aware routing has two layers: use YAML rules for policy selection and access_control_replace only when the user wants Android to include/exclude entire apps from the VPN. Call installed_apps first and use exact packages. - 12. Before changing app overrides or Android VPN settings, read their current complete state and preserve fields the - user did not ask to change. Prefer runtime_set_mode for a temporary mode switch. + 12. Before changing app overrides or Android VPN settings, read their current complete state and preserve fields the + user did not ask to change. Prefer runtime_set_mode for a temporary mode switch. + 13. Before any modification of a profile, override, or DNS/TUN setting, call runtime.status and note core_version. + Only write fields supported by that mihomo core version: never emit YAML options the running core does not + support. If the user asks for a feature that depends on a newer core, say so and propose the closest supported + alternative instead of writing an invalid field. """.trimIndent() } } diff --git a/app/src/agent/java/com/github/kr328/clash/AgentActivity.kt b/app/src/agent/java/com/github/kr328/clash/AgentActivity.kt index f4a8118bb2..5a075beb4a 100644 --- a/app/src/agent/java/com/github/kr328/clash/AgentActivity.kt +++ b/app/src/agent/java/com/github/kr328/clash/AgentActivity.kt @@ -178,7 +178,13 @@ class AgentActivity : BaseActivity() { smoothStream.finish(finalText) adapter.replace(assistantPosition, assistantMessage.copy(content = finalText)) } catch (_: CancellationException) { - adapter.replace(assistantPosition, assistantMessage.copy(content = "已停止本次操作。")) + val preserved = smoothStream.currentText().trimEnd() + val content = if (preserved.isBlank()) { + "已停止本次操作。" + } else { + "$preserved\n\n> 已停止本次操作。" + } + adapter.replace(assistantPosition, assistantMessage.copy(content = content)) } catch (error: Throwable) { val detail = error.message?.take(1200) ?: error.javaClass.simpleName adapter.replace( diff --git a/app/src/agent/java/com/github/kr328/clash/agent/AgentChatAdapter.kt b/app/src/agent/java/com/github/kr328/clash/agent/AgentChatAdapter.kt index 6572e48f51..9b7e73d9a4 100644 --- a/app/src/agent/java/com/github/kr328/clash/agent/AgentChatAdapter.kt +++ b/app/src/agent/java/com/github/kr328/clash/agent/AgentChatAdapter.kt @@ -1,12 +1,17 @@ package com.github.kr328.clash.agent import android.content.Context +import android.graphics.Canvas import android.graphics.Color +import android.graphics.Paint +import android.text.Layout import android.text.NoCopySpan import android.text.Spannable import android.text.SpannableString import android.text.SpannableStringBuilder import android.text.Spanned +import android.text.style.LeadingMarginSpan +import android.text.style.LineBackgroundSpan import android.util.TypedValue import android.view.Gravity import android.view.LayoutInflater @@ -20,7 +25,11 @@ import com.github.kr328.clash.R import com.github.kr328.clash.agent.model.AgentConversationMessage import com.github.kr328.clash.agent.model.AgentMessageRole import com.google.android.material.card.MaterialCardView +import io.noties.markwon.AbstractMarkwonPlugin import io.noties.markwon.Markwon +import io.noties.markwon.MarkwonTheme +import io.noties.markwon.SpansFactory +import org.commonmark.node.CodeBlock import java.io.Closeable import java.util.UUID import java.util.concurrent.Executors @@ -40,7 +49,7 @@ class AgentChatAdapter( val messages: MutableList, private val onContentHeightChanged: (String) -> Unit = {}, ) : RecyclerView.Adapter(), Closeable { - private val markwon = Markwon.create(context) + private val markwon = createMarkwon(context) private val markdownExecutor = Executors.newSingleThreadExecutor() private val attachedHolders = mutableSetOf() @@ -251,6 +260,97 @@ class AgentChatAdapter( return if (context.theme.resolveAttribute(attribute, value, true)) value.data else fallback } + private fun createMarkwon(context: Context): Markwon { + val primary = resolve(com.google.android.material.R.attr.colorPrimary, Color.rgb(25, 118, 210)) + val codeBackground = resolve( + com.google.android.material.R.attr.colorSurfaceVariant, + Color.rgb(0xE6, 0xE6, 0xE9) + ) + val codeText = resolve( + com.google.android.material.R.attr.colorOnSurfaceVariant, + Color.rgb(0x44, 0x44, 0x44) + ) + val quoteColor = resolve( + com.google.android.material.R.attr.colorPrimary, + Color.rgb(25, 118, 210) + ) + val radius = 8 * context.resources.displayMetrics.density + + return Markwon.builder(context) + .usePlugin(object : AbstractMarkwonPlugin() { + override fun configureTheme(builder: MarkwonTheme.Builder) { + builder + .setLinkColor(primary) + .setCodeTextColor(codeText) + .setCodeBackgroundColor(codeBackground) + .setCodeBlockTextColor(codeText) + .setCodeBlockBackgroundColor(codeBackground) + .setBlockQuoteColor(quoteColor) + .setBlockQuoteWidth((3 * context.resources.displayMetrics.density).toInt()) + } + + override fun configureSpansFactory(builder: SpansFactory.Builder) { + builder.setFactory(CodeBlock::class.java) { + arrayOf(RoundedCodeBlockSpan(codeBackground, radius)) + } + } + }) + .build() + } + + /** + * Rounded background behind code blocks, replacing Markwon's flat gray rectangle. + * A [LeadingMarginSpan] so indentation of wrapped lines stays aligned. + */ + private class RoundedCodeBlockSpan( + private val backgroundColor: Int, + private val cornerRadius: Float, + ) : LeadingMarginSpan, LineBackgroundSpan { + private val paint = Paint().apply { isAntiAlias = true } + + override fun getLeadingMargin(first: Boolean): Int = 0 + + override fun drawLeadingMargin( + c: Canvas, + p: Paint, + x: Int, + dir: Int, + top: Int, + baseline: Int, + bottom: Int, + text: CharSequence, + start: Int, + end: Int, + first: Boolean, + layout: Layout, + ) = Unit + + override fun drawBackground( + c: Canvas, + p: Paint, + left: Int, + right: Int, + top: Int, + baseline: Int, + bottom: Int, + text: CharSequence, + start: Int, + end: Int, + lineNumber: Int, + ) { + paint.color = backgroundColor + c.drawRoundRect( + left.toFloat(), + top.toFloat(), + right.toFloat(), + bottom.toFloat(), + cornerRadius, + cornerRadius, + paint, + ) + } + } + private fun String.toStableLong(): Long = runCatching { UUID.fromString(this).let { it.mostSignificantBits xor it.leastSignificantBits } }.getOrElse { hashCode().toLong() } diff --git a/app/src/agent/java/com/github/kr328/clash/agent/AndroidAgentToolExecutor.kt b/app/src/agent/java/com/github/kr328/clash/agent/AndroidAgentToolExecutor.kt index c6e0dd19df..91d1227c1f 100644 --- a/app/src/agent/java/com/github/kr328/clash/agent/AndroidAgentToolExecutor.kt +++ b/app/src/agent/java/com/github/kr328/clash/agent/AndroidAgentToolExecutor.kt @@ -8,6 +8,7 @@ import android.net.NetworkCapabilities import android.os.Build import android.os.ParcelFileDescriptor import androidx.core.content.getSystemService +import com.github.kr328.clash.BuildConfig import com.github.kr328.clash.core.Clash import com.github.kr328.clash.agent.model.AgentToolExecutionResult import com.github.kr328.clash.agent.runtime.AgentToolExecutor @@ -433,6 +434,8 @@ class AndroidAgentToolExecutor( put("active_profile", active?.name ?: "") put("active_profile_id", active?.uuid?.toString() ?: "") put("mode", state.mode.name) + put("core_version", com.github.kr328.clash.core.bridge.Bridge.nativeCoreVersion()) + put("app_version", BuildConfig.VERSION_NAME) put("traffic_total", queryTrafficTotal()) put("selectable_groups", buildJsonArray { groups.forEach { add(kotlinx.serialization.json.JsonPrimitive(it)) } }) put("provider_count", queryProviders().size) diff --git a/app/src/agent/java/com/github/kr328/clash/agent/SmoothMarkdownStream.kt b/app/src/agent/java/com/github/kr328/clash/agent/SmoothMarkdownStream.kt index eeb705f4f4..6008889306 100644 --- a/app/src/agent/java/com/github/kr328/clash/agent/SmoothMarkdownStream.kt +++ b/app/src/agent/java/com/github/kr328/clash/agent/SmoothMarkdownStream.kt @@ -67,6 +67,9 @@ class SmoothMarkdownStream( finishContinuation = null } + /** The text currently committed to the UI. Safe to call from any thread. */ + fun currentText(): String = visible + override fun doFrame(frameTimeNanos: Long) { scheduled = false if (cancelled) return @@ -164,14 +167,14 @@ class SmoothMarkdownStream( const val NANOS_PER_SECOND = 1_000_000_000.0 const val START_DELAY_NANOS = 24_000_000L const val MAX_DELTA_NANOS = 100_000_000L - const val MIN_COMMIT_INTERVAL_NANOS = 33_000_000L - const val MIN_CHARS_PER_SECOND = 60.0 - const val MAX_CHARS_PER_SECOND = 1_600.0 + const val MIN_COMMIT_INTERVAL_NANOS = 66_000_000L + const val MIN_CHARS_PER_SECOND = 80.0 + const val MAX_CHARS_PER_SECOND = 1_400.0 const val TARGET_LATENCY_SECONDS = 0.42 const val FINISH_LATENCY_SECONDS = 0.16 const val CATCH_UP_LATENCY_SECONDS = 0.24 const val CATCH_UP_THRESHOLD = 560 - const val MAX_CHARS_PER_COMMIT = 96 + const val MAX_CHARS_PER_COMMIT = 120 const val SPEED_EASING = 0.2 const val ZERO_WIDTH_JOINER = 0x200D } diff --git a/app/src/agent/res/layout/item_agent_message.xml b/app/src/agent/res/layout/item_agent_message.xml index ccbdd8ac18..41f1a02b28 100644 --- a/app/src/agent/res/layout/item_agent_message.xml +++ b/app/src/agent/res/layout/item_agent_message.xml @@ -20,6 +20,7 @@ android:id="@+id/agent_message_text" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:includeFontPadding="false" android:lineSpacingExtra="2dp" android:minWidth="48dp" android:paddingHorizontal="14dp" From 1334be11283053921eb1c8997b36c022acc6a373 Mon Sep 17 00:00:00 2001 From: sameral Date: Fri, 7 Aug 2026 10:00:29 +0800 Subject: [PATCH 6/8] fix(agent): correct Markwon 4.6.2 API imports (io.noties.markwon.core) --- .../java/com/github/kr328/clash/agent/AgentChatAdapter.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/agent/java/com/github/kr328/clash/agent/AgentChatAdapter.kt b/app/src/agent/java/com/github/kr328/clash/agent/AgentChatAdapter.kt index 9b7e73d9a4..ad2c5eb856 100644 --- a/app/src/agent/java/com/github/kr328/clash/agent/AgentChatAdapter.kt +++ b/app/src/agent/java/com/github/kr328/clash/agent/AgentChatAdapter.kt @@ -27,8 +27,8 @@ import com.github.kr328.clash.agent.model.AgentMessageRole import com.google.android.material.card.MaterialCardView import io.noties.markwon.AbstractMarkwonPlugin import io.noties.markwon.Markwon -import io.noties.markwon.MarkwonTheme -import io.noties.markwon.SpansFactory +import io.noties.markwon.MarkwonSpansFactory +import io.noties.markwon.core.MarkwonTheme import org.commonmark.node.CodeBlock import java.io.Closeable import java.util.UUID @@ -289,8 +289,8 @@ class AgentChatAdapter( .setBlockQuoteWidth((3 * context.resources.displayMetrics.density).toInt()) } - override fun configureSpansFactory(builder: SpansFactory.Builder) { - builder.setFactory(CodeBlock::class.java) { + override fun configureSpansFactory(builder: MarkwonSpansFactory.Builder) { + builder.setFactory(CodeBlock::class.java) { _, _ -> arrayOf(RoundedCodeBlockSpan(codeBackground, radius)) } } From 85c3dad58e5695270d26212ac80b7ba907549615 Mon Sep 17 00:00:00 2001 From: sameral Date: Fri, 7 Aug 2026 10:05:04 +0800 Subject: [PATCH 7/8] fix(agent): use FencedCodeBlock/IndentedCodeBlock nodes (commonmark 0.13.0 has no CodeBlock class) --- .../github/kr328/clash/agent/AgentChatAdapter.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/app/src/agent/java/com/github/kr328/clash/agent/AgentChatAdapter.kt b/app/src/agent/java/com/github/kr328/clash/agent/AgentChatAdapter.kt index ad2c5eb856..38e71b830b 100644 --- a/app/src/agent/java/com/github/kr328/clash/agent/AgentChatAdapter.kt +++ b/app/src/agent/java/com/github/kr328/clash/agent/AgentChatAdapter.kt @@ -29,7 +29,8 @@ import io.noties.markwon.AbstractMarkwonPlugin import io.noties.markwon.Markwon import io.noties.markwon.MarkwonSpansFactory import io.noties.markwon.core.MarkwonTheme -import org.commonmark.node.CodeBlock +import org.commonmark.node.FencedCodeBlock +import org.commonmark.node.IndentedCodeBlock import java.io.Closeable import java.util.UUID import java.util.concurrent.Executors @@ -290,9 +291,13 @@ class AgentChatAdapter( } override fun configureSpansFactory(builder: MarkwonSpansFactory.Builder) { - builder.setFactory(CodeBlock::class.java) { _, _ -> - arrayOf(RoundedCodeBlockSpan(codeBackground, radius)) - } + builder + .setFactory(FencedCodeBlock::class.java) { _, _ -> + arrayOf(RoundedCodeBlockSpan(codeBackground, radius)) + } + .setFactory(IndentedCodeBlock::class.java) { _, _ -> + arrayOf(RoundedCodeBlockSpan(codeBackground, radius)) + } } }) .build() From a1559ca088e92ceabb0358c9527db63d45715da9 Mon Sep 17 00:00:00 2001 From: sameral Date: Fri, 7 Aug 2026 10:08:05 +0800 Subject: [PATCH 8/8] fix(agent): MarkwonTheme.Builder methods have no set prefix in 4.6.2 --- .../github/kr328/clash/agent/AgentChatAdapter.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/src/agent/java/com/github/kr328/clash/agent/AgentChatAdapter.kt b/app/src/agent/java/com/github/kr328/clash/agent/AgentChatAdapter.kt index 38e71b830b..3faf637a1e 100644 --- a/app/src/agent/java/com/github/kr328/clash/agent/AgentChatAdapter.kt +++ b/app/src/agent/java/com/github/kr328/clash/agent/AgentChatAdapter.kt @@ -281,13 +281,13 @@ class AgentChatAdapter( .usePlugin(object : AbstractMarkwonPlugin() { override fun configureTheme(builder: MarkwonTheme.Builder) { builder - .setLinkColor(primary) - .setCodeTextColor(codeText) - .setCodeBackgroundColor(codeBackground) - .setCodeBlockTextColor(codeText) - .setCodeBlockBackgroundColor(codeBackground) - .setBlockQuoteColor(quoteColor) - .setBlockQuoteWidth((3 * context.resources.displayMetrics.density).toInt()) + .linkColor(primary) + .codeTextColor(codeText) + .codeBackgroundColor(codeBackground) + .codeBlockTextColor(codeText) + .codeBlockBackgroundColor(codeBackground) + .blockQuoteColor(quoteColor) + .blockQuoteWidth((3 * context.resources.displayMetrics.density).toInt()) } override fun configureSpansFactory(builder: MarkwonSpansFactory.Builder) {