From c16b91b15a593bd66ee3896f17fc3d9317049fa6 Mon Sep 17 00:00:00 2001 From: sameral Date: Fri, 7 Aug 2026 01:25:45 +0800 Subject: [PATCH 1/2] feat(debug): add built-in diagnostics center - New Diagnostics screen reachable from Settings - One-tap enable/disable debug logging (clash log-level override + file recording) - Export diagnostics bundle: app/core info, config summary, connections snapshot and clash log files as a shareable zip - Add FileProvider for sharing the bundle --- app/src/main/AndroidManifest.xml | 15 ++ .../github/kr328/clash/DiagnosticsActivity.kt | 242 ++++++++++++++++++ .../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, 516 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/github/kr328/clash/DiagnosticsActivity.kt create mode 100644 app/src/main/res/xml/file_paths.xml create 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 9900007e38..5c619c1a9d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -182,12 +182,27 @@ 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(R.string.diagnostics_debug_enabled, ToastDuration.Short) + + design.patch(fetchState()) + } + + private suspend fun disableDebug(design: DiagnosticsDesign) { + patchLogLevel(null) + + stopService(LogcatService::class.intent) + + design.showToast(R.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(R.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(R.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 95a0393367..e86658723f 100644 --- a/app/src/main/java/com/github/kr328/clash/SettingsActivity.kt +++ b/app/src/main/java/com/github/kr328/clash/SettingsActivity.kt @@ -26,6 +26,8 @@ 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 new file mode 100644 index 0000000000..7c46cb1f42 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + 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 new file mode 100644 index 0000000000..b1698b4598 --- /dev/null +++ b/design/src/main/java/com/github/kr328/clash/design/DiagnosticsDesign.kt @@ -0,0 +1,193 @@ +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 0536a587cd..a3d7f95993 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, + StartApp, StartNetwork, StartOverride, StartMetaFeature, StartDiagnostics, } 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 885ec4a5c8..b52719cea9 100644 --- a/design/src/main/res/layout/design_settings.xml +++ b/design/src/main/res/layout/design_settings.xml @@ -56,6 +56,13 @@ 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 06e43723fe..bbbbe649ab 100644 --- a/design/src/main/res/values-zh/strings.xml +++ b/design/src/main/res/values-zh/strings.xml @@ -284,4 +284,29 @@ 启动 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 438de8ae63..eaca20cf73 100644 --- a/design/src/main/res/values/strings.xml +++ b/design/src/main/res/values/strings.xml @@ -374,4 +374,29 @@ 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 097195098546b85517f6e81a6226dd31d36685d5 Mon Sep 17 00:00:00 2001 From: sameral Date: Fri, 7 Aug 2026 01:32:19 +0800 Subject: [PATCH 2/2] fix(debug): reference design module R for diagnostics strings --- .../java/com/github/kr328/clash/DiagnosticsActivity.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/github/kr328/clash/DiagnosticsActivity.kt b/app/src/main/java/com/github/kr328/clash/DiagnosticsActivity.kt index 30da256e39..4a9fd8984a 100644 --- a/app/src/main/java/com/github/kr328/clash/DiagnosticsActivity.kt +++ b/app/src/main/java/com/github/kr328/clash/DiagnosticsActivity.kt @@ -11,6 +11,7 @@ import com.github.kr328.clash.core.bridge.Bridge import com.github.kr328.clash.core.model.LogMessage import com.github.kr328.clash.design.DiagnosticsDesign import com.github.kr328.clash.design.ui.ToastDuration +import com.github.kr328.clash.design.R as DesignR import com.github.kr328.clash.service.store.ServiceStore import com.github.kr328.clash.util.logsDir import com.github.kr328.clash.util.withClash @@ -112,7 +113,7 @@ class DiagnosticsActivity : BaseActivity() { startForegroundServiceCompat(LogcatService::class.intent) - design.showToast(R.string.diagnostics_debug_enabled, ToastDuration.Short) + design.showToast(DesignR.string.diagnostics_debug_enabled, ToastDuration.Short) design.patch(fetchState()) } @@ -122,7 +123,7 @@ class DiagnosticsActivity : BaseActivity() { stopService(LogcatService::class.intent) - design.showToast(R.string.diagnostics_debug_disabled, ToastDuration.Short) + design.showToast(DesignR.string.diagnostics_debug_disabled, ToastDuration.Short) design.patch(fetchState()) } @@ -142,7 +143,7 @@ class DiagnosticsActivity : BaseActivity() { val bundle = buildDiagnosticsBundle() if (bundle == null) { - design.showToast(R.string.diagnostics_export_failed, ToastDuration.Short) + design.showToast(DesignR.string.diagnostics_export_failed, ToastDuration.Short) return } @@ -155,7 +156,7 @@ class DiagnosticsActivity : BaseActivity() { addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } - startActivity(Intent.createChooser(intent, getString(R.string.diagnostics_export))) + startActivity(Intent.createChooser(intent, getString(DesignR.string.diagnostics_export))) } private suspend fun buildDiagnosticsBundle(): File? = withContext(Dispatchers.IO) {