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(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 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