diff --git a/.github/workflows/build-agent.yaml b/.github/workflows/build-agent.yaml index 1b7092e394..cfeab2e743 100644 --- a/.github/workflows/build-agent.yaml +++ b/.github/workflows/build-agent.yaml @@ -11,6 +11,7 @@ on: - "service/**" - "agent/**" - "agent-ui/**" + - ".github/workflows/build-agent.yaml" - "*.gradle.kts" - "gradle/**" @@ -29,6 +30,28 @@ 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 + 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 + - name: Set up Gradle uses: gradle/actions/setup-gradle@v5 @@ -54,9 +77,31 @@ jobs: sudo update-ca-certificates cp -f /etc/ssl/certs/ca-certificates.crt core/src/foss/golang/clash/component/ca/ca-certificates.crt + - name: Stress-test incremental streaming patches + run: ./gradlew --no-daemon app:testAgentDebugUnitTest + - name: Build arm64 APK run: ./gradlew --no-daemon -PagentArm64Only=true app:assembleAgentDebug + - name: Verify stable signing identity + shell: bash + run: | + apk="$(find app/build/outputs/apk/agent/debug -name '*-arm64-v8a-*.apk' -print -quit)" + apksigner="$(find "$ANDROID_HOME/build-tools" -name apksigner -type f | sort -V | tail -n 1)" + keytool -exportcert \ + -keystore "$HOME/.android/debug.keystore" \ + -storepass android \ + -alias androiddebugkey \ + -file "$RUNNER_TEMP/agent-debug-cert.der" + key_fingerprint="$(sha256sum "$RUNNER_TEMP/agent-debug-cert.der" | cut -d' ' -f1)" + apk_fingerprint="$("$apksigner" verify --print-certs "$apk" | sed -n 's/.*certificate SHA-256 digest: //p' | head -n 1)" + test -n "$key_fingerprint" + test -n "$apk_fingerprint" + echo "Cached key certificate: ${key_fingerprint,,}" + echo "Built APK certificate: ${apk_fingerprint,,}" + test "${key_fingerprint,,}" = "${apk_fingerprint,,}" + echo "Verified stable APK signing certificate: ${apk_fingerprint}" + - name: Upload arm64 APK uses: actions/upload-artifact@v7 with: diff --git a/NOTICE b/NOTICE index 6853d8820c..fe667e2028 100644 --- a/NOTICE +++ b/NOTICE @@ -1,5 +1,27 @@ 3th-party software licenses + * markstream-core smooth streaming algorithm +========================================================================== +Copyright (c) 2022 Simon He + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + * Clash ========================================================================== GNU GENERAL PUBLIC LICENSE diff --git a/agent/src/main/java/com/github/kr328/clash/agent/protocol/OpenAICompatibleClient.kt b/agent/src/main/java/com/github/kr328/clash/agent/protocol/OpenAICompatibleClient.kt index b7d71017f9..b7a7921f19 100644 --- a/agent/src/main/java/com/github/kr328/clash/agent/protocol/OpenAICompatibleClient.kt +++ b/agent/src/main/java/com/github/kr328/clash/agent/protocol/OpenAICompatibleClient.kt @@ -63,19 +63,21 @@ class OpenAICompatibleClient( put("model", settings.model) put("messages", messages) put("stream", true) - put("tools", buildJsonArray { - tools.forEach { tool -> - add(buildJsonObject { - put("type", "function") - put("function", buildJsonObject { - put("name", tool.name) - put("description", tool.description) - put("parameters", tool.parameters) + if (tools.isNotEmpty()) { + put("tools", buildJsonArray { + tools.forEach { tool -> + add(buildJsonObject { + put("type", "function") + put("function", buildJsonObject { + put("name", tool.name) + put("description", tool.description) + put("parameters", tool.parameters) + }) }) - }) - } - }) - put("tool_choice", "auto") + } + }) + put("tool_choice", "auto") + } } try { @@ -97,6 +99,17 @@ class OpenAICompatibleClient( } } + /** Performs a minimal real chat completion so URL, credentials and model are all verified. */ + suspend fun testConnection(settings: AgentProviderSettings): String { + val messages = buildJsonArray { + add(buildJsonObject { + put("role", "user") + put("content", "Connection test. Reply with OK only.") + }) + } + return complete(settings, messages, emptyList()) {}.content + } + private suspend fun parseResponse( reader: BufferedReader, onText: suspend (String) -> Unit, @@ -225,6 +238,7 @@ class OpenAICompatibleClient( companion object { private const val CONNECT_TIMEOUT_MS = 20_000 private const val READ_TIMEOUT_MS = 180_000 - private const val STREAM_FRAME_MS = 45L + // Keep the source buffer fresh; visual pacing is handled independently on Android's VSync. + private const val STREAM_FRAME_MS = 24L } } diff --git a/agent/src/main/java/com/github/kr328/clash/agent/tools/AgentExecutableTools.kt b/agent/src/main/java/com/github/kr328/clash/agent/tools/AgentExecutableTools.kt index 7a885f89ed..aeca933af9 100644 --- a/agent/src/main/java/com/github/kr328/clash/agent/tools/AgentExecutableTools.kt +++ b/agent/src/main/java/com/github/kr328/clash/agent/tools/AgentExecutableTools.kt @@ -53,6 +53,7 @@ object AgentExecutableTools { boolean("restart_if_running", "Restart the VPN immediately so network-affecting changes take effect. Defaults to true.", required = false)), tool("network_info", "Read active Android network, transports, DNS servers, routes, metering, and validation state.", READ_ONLY), tool("logs_recent", "Read the tail of the newest saved mihomo log capture for diagnosis. Returns unavailable when no capture has been recorded.", READ_ONLY), + tool("app_exit_history", "Read Android's system process-exit history for the VPN process, including low-memory kills, Java/native crashes, ANRs, user stops, and signals. Android 11 or newer.", READ_ONLY), tool("runtime_status", "Read VPN state, active profile, tunnel mode, traffic totals, proxy groups, and providers.", READ_ONLY), tool("runtime_set_mode", "Set the current session tunnel mode (rule, global, direct, or script).", MEDIUM, string("mode", "One of: rule, global, direct, script.")), diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e662fe070a..f7f4ba5348 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,6 +28,8 @@ dependencies { implementation(libs.google.material) implementation(libs.quickie.bundled) implementation(libs.androidx.activity.ktx) + implementation(libs.markwon.core) + testImplementation(kotlin("test")) } tasks.getByName("clean", type = Delete::class) { 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 5b625e8265..f4a8118bb2 100644 --- a/app/src/agent/java/com/github/kr328/clash/AgentActivity.kt +++ b/app/src/agent/java/com/github/kr328/clash/AgentActivity.kt @@ -2,14 +2,13 @@ package com.github.kr328.clash import android.app.Activity import android.content.DialogInterface -import android.os.SystemClock import android.net.Uri -import android.view.KeyEvent import android.view.View import android.view.inputmethod.InputMethodManager import android.widget.EditText import android.widget.RadioGroup import android.widget.TextView +import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.getSystemService import androidx.recyclerview.widget.LinearLayoutManager @@ -17,11 +16,13 @@ import androidx.recyclerview.widget.RecyclerView import com.github.kr328.clash.agent.AgentChatAdapter import com.github.kr328.clash.agent.AgentScreenDesign import com.github.kr328.clash.agent.AndroidAgentToolExecutor +import com.github.kr328.clash.agent.SmoothMarkdownStream import com.github.kr328.clash.agent.authorization.AgentAuthorizationMode import com.github.kr328.clash.agent.model.AgentConversationMessage import com.github.kr328.clash.agent.model.AgentMessageRole import com.github.kr328.clash.agent.model.AgentProviderSettings import com.github.kr328.clash.agent.model.AgentRunEvent +import com.github.kr328.clash.agent.protocol.OpenAICompatibleClient import com.github.kr328.clash.agent.runtime.AgentApprovalHandler import com.github.kr328.clash.agent.runtime.AgentEngine import com.github.kr328.clash.agent.settings.AgentConversationStore @@ -54,6 +55,10 @@ class AgentActivity : BaseActivity() { private lateinit var modelStatus: TextView private lateinit var progressRow: View private lateinit var progressText: TextView + private lateinit var suggestions: View + private var streamingMessageId: String? = null + private var followOutput = true + private var scrollScheduled = false override suspend fun main() { val screen = AgentScreenDesign(this) @@ -70,9 +75,23 @@ class AgentActivity : BaseActivity() { modelStatus = root.findViewById(R.id.agent_model_status) progressRow = root.findViewById(R.id.agent_progress_row) progressText = root.findViewById(R.id.agent_progress_text) - adapter = AgentChatAdapter(this, conversationStore.load().toMutableList()) + suggestions = root.findViewById(R.id.agent_suggestions_container) + adapter = AgentChatAdapter(this, conversationStore.load().toMutableList()) { messageId -> + if (followOutput && messageId == streamingMessageId) scheduleScrollToEnd() + } recycler.layoutManager = LinearLayoutManager(this).apply { stackFromEnd = true } + recycler.itemAnimator = null + recycler.setHasFixedSize(true) recycler.adapter = adapter + recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + when (newState) { + RecyclerView.SCROLL_STATE_DRAGGING -> followOutput = false + RecyclerView.SCROLL_STATE_IDLE -> followOutput = isNearBottom() + } + } + }) + updateSuggestionsVisibility() scrollToEnd() root.findViewById(R.id.agent_back).setOnClickListener { finish() } @@ -80,12 +99,6 @@ class AgentActivity : BaseActivity() { root.findViewById(R.id.agent_clear).setOnClickListener { confirmClear() } root.findViewById(R.id.agent_send).setOnClickListener { sendCurrentMessage() } root.findViewById(R.id.agent_stop).setOnClickListener { generation?.cancel() } - input.setOnEditorActionListener { _, actionId, event -> - val isSend = actionId == android.view.inputmethod.EditorInfo.IME_ACTION_SEND || - (event?.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN && !event.isShiftPressed) - if (isSend) sendCurrentMessage() - isSend - } root.findViewById(R.id.agent_suggest_create).setOnClickListener { submitPrompt("请从零开始帮我创建一份可用配置。先了解我的节点来源、使用地区和分流需求;如果没有节点,先创建安全的 DIRECT/REJECT 基础配置。") @@ -120,12 +133,22 @@ class AgentActivity : BaseActivity() { val history = adapter.messages.toList() val userMessage = message(AgentMessageRole.USER, prompt) adapter.append(userMessage) - val assistantPosition = adapter.append(message(AgentMessageRole.ASSISTANT, "正在思考…")) + val assistantMessage = message(AgentMessageRole.ASSISTANT, "") + val assistantPosition = adapter.append(assistantMessage) + streamingMessageId = assistantMessage.id + followOutput = true + updateSuggestionsVisibility() scrollToEnd() setRunning(true, "正在连接 ${settings.model}…") generation = launch { - var lastRendered = 0L + val smoothStream = SmoothMarkdownStream { visibleText -> + adapter.replace( + assistantPosition, + assistantMessage.copy(content = visibleText), + streaming = true, + ) + } try { val executor = AndroidAgentToolExecutor( context = this@AgentActivity, @@ -140,14 +163,10 @@ class AgentActivity : BaseActivity() { approvalHandler = AgentApprovalHandler { tool, _, summary -> approve(tool, summary) }, ) { event -> withContext(Dispatchers.Main.immediate) { when (event) { - is AgentRunEvent.Thinking -> progressText.text = "正在规划第 ${event.round} 步…" + is AgentRunEvent.Thinking -> progressText.text = "正在思考 · 规划第 ${event.round} 步…" is AgentRunEvent.Streaming -> { - val now = SystemClock.elapsedRealtime() - if (now - lastRendered >= 40L || event.text.length < 80) { - adapter.replace(assistantPosition, message(AgentMessageRole.ASSISTANT, event.text)) - scrollToEnd() - lastRendered = now - } + progressText.text = "正在生成回复…" + smoothStream.submit(event.text) } is AgentRunEvent.ToolStarted -> progressText.text = event.summary is AgentRunEvent.ToolFinished -> progressText.text = @@ -156,16 +175,22 @@ class AgentActivity : BaseActivity() { is AgentRunEvent.Completed -> Unit } } } - adapter.replace(assistantPosition, message(AgentMessageRole.ASSISTANT, finalText)) + smoothStream.finish(finalText) + adapter.replace(assistantPosition, assistantMessage.copy(content = finalText)) } catch (_: CancellationException) { - adapter.replace(assistantPosition, message(AgentMessageRole.ASSISTANT, "已停止本次操作。")) + adapter.replace(assistantPosition, assistantMessage.copy(content = "已停止本次操作。")) } catch (error: Throwable) { val detail = error.message?.take(1200) ?: error.javaClass.simpleName - adapter.replace(assistantPosition, message(AgentMessageRole.ASSISTANT, "操作未完成:$detail", isError = true)) + adapter.replace( + assistantPosition, + assistantMessage.copy(content = "操作未完成:$detail", isError = true), + ) } finally { + smoothStream.cancel() conversationStore.save(adapter.messages) setRunning(false, "") - scrollToEnd() + if (followOutput) scrollToEnd() + streamingMessageId = null } } } @@ -211,39 +236,42 @@ class AgentActivity : BaseActivity() { AgentAuthorizationMode.FULL_AUTO -> R.id.agent_auth_full }) + val horizontalMargin = (24 * resources.displayMetrics.density).toInt() val dialog = MaterialAlertDialogBuilder(this) .setTitle(R.string.agent_settings) - .setView(view) + .setView(view, horizontalMargin, 0, horizontalMargin, 0) .setNegativeButton(R.string.agent_cancel, null) + .setNeutralButton(R.string.agent_test_connection, null) .setPositiveButton(R.string.agent_save, null) .create() + var testJob: Job? = null dialog.setOnShowListener { - dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener { - val normalizedUrl = baseUrl.text.toString().trim() - val key = apiKey.text.toString().trim() - val modelName = model.text.toString().trim() - if (!normalizedUrl.startsWith("https://") && !normalizedUrl.startsWith("http://")) { - baseUrl.error = "请输入 http:// 或 https:// 地址" - return@setOnClickListener - } - val host = runCatching { Uri.parse(normalizedUrl).host.orEmpty() }.getOrDefault("") - if (normalizedUrl.startsWith("http://") && key.isNotEmpty() && - host !in setOf("localhost", "127.0.0.1", "::1")) { - apiKey.error = "为防止密钥泄露,非本机地址请使用 HTTPS" - return@setOnClickListener - } - if (modelName.isEmpty()) { - model.error = "请输入模型名称" - return@setOnClickListener - } - val mode = when (authorization.checkedRadioButtonId) { - R.id.agent_auth_cautious -> AgentAuthorizationMode.CAUTIOUS - R.id.agent_auth_full -> AgentAuthorizationMode.FULL_AUTO - else -> AgentAuthorizationMode.BALANCED + val testButton = dialog.getButton(DialogInterface.BUTTON_NEUTRAL) + testButton.setOnClickListener { + val candidate = readProviderSettings(baseUrl, apiKey, model, authorization, current) + ?: return@setOnClickListener + testButton.isEnabled = false + testButton.setText(R.string.agent_testing_connection) + testJob = launch { + runCatching { OpenAICompatibleClient().testConnection(candidate) } + .onSuccess { + Toast.makeText(this@AgentActivity, R.string.agent_test_success, Toast.LENGTH_SHORT).show() + } + .onFailure { error -> + MaterialAlertDialogBuilder(this@AgentActivity) + .setTitle("连接失败") + .setMessage(error.message?.take(500) ?: error.javaClass.simpleName) + .setPositiveButton(android.R.string.ok, null) + .show() + } + testButton.isEnabled = true + testButton.setText(R.string.agent_test_connection) } - runCatching { - settingsStore.save(AgentProviderSettings(normalizedUrl, modelName, key, mode, current.maxToolRounds)) - }.onFailure { + } + dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener { + val candidate = readProviderSettings(baseUrl, apiKey, model, authorization, current) + ?: return@setOnClickListener + runCatching { settingsStore.save(candidate) }.onFailure { apiKey.error = it.message ?: "API Key 保存失败" return@setOnClickListener } @@ -252,9 +280,42 @@ class AgentActivity : BaseActivity() { if (input.text?.isNotBlank() == true) input.requestFocus() } } + dialog.setOnDismissListener { testJob?.cancel() } dialog.show() } + private fun readProviderSettings( + baseUrl: EditText, + apiKey: EditText, + model: EditText, + authorization: RadioGroup, + current: AgentProviderSettings, + ): AgentProviderSettings? { + val normalizedUrl = baseUrl.text.toString().trim() + val key = apiKey.text.toString().trim() + val modelName = model.text.toString().trim() + if (!normalizedUrl.startsWith("https://") && !normalizedUrl.startsWith("http://")) { + baseUrl.error = "请输入 http:// 或 https:// 地址" + return null + } + val host = runCatching { Uri.parse(normalizedUrl).host.orEmpty() }.getOrDefault("") + if (normalizedUrl.startsWith("http://") && key.isNotEmpty() && + host !in setOf("localhost", "127.0.0.1", "::1")) { + apiKey.error = "为防止密钥泄露,非本机地址请使用 HTTPS" + return null + } + if (modelName.isEmpty()) { + model.error = "请输入模型名称" + return null + } + val mode = when (authorization.checkedRadioButtonId) { + R.id.agent_auth_cautious -> AgentAuthorizationMode.CAUTIOUS + R.id.agent_auth_full -> AgentAuthorizationMode.FULL_AUTO + else -> AgentAuthorizationMode.BALANCED + } + return AgentProviderSettings(normalizedUrl, modelName, key, mode, current.maxToolRounds) + } + private fun confirmClear() { if (generation?.isActive == true || adapter.messages.isEmpty()) return MaterialAlertDialogBuilder(this) @@ -263,6 +324,7 @@ class AgentActivity : BaseActivity() { .setPositiveButton(R.string.agent_clear) { _, _ -> adapter.clear() conversationStore.clear() + updateSuggestionsVisibility() } .show() } @@ -309,6 +371,35 @@ class AgentActivity : BaseActivity() { } } + private fun scheduleScrollToEnd() { + if (scrollScheduled || !::recycler.isInitialized) return + scrollScheduled = true + recycler.postOnAnimation { + scrollScheduled = false + if (followOutput && adapter.itemCount > 0) { + recycler.scrollToPosition(adapter.itemCount - 1) + } + } + } + + private fun isNearBottom(): Boolean { + if (!::recycler.isInitialized || adapter.itemCount == 0) return true + val manager = recycler.layoutManager as? LinearLayoutManager ?: return true + return manager.findLastVisibleItemPosition() >= adapter.itemCount - 2 + } + + private fun updateSuggestionsVisibility() { + if (::suggestions.isInitialized) { + suggestions.visibility = if (adapter.messages.isEmpty()) View.VISIBLE else View.GONE + } + } + + override fun onDestroy() { + if (::adapter.isInitialized) adapter.close() + super.onDestroy() + } + private fun message(role: AgentMessageRole, content: String, isError: Boolean = false) = AgentConversationMessage(UUID.randomUUID().toString(), role, content, isError = isError) + } 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 c549d8c5ac..6572e48f51 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 @@ -2,6 +2,11 @@ package com.github.kr328.clash.agent import android.content.Context import android.graphics.Color +import android.text.NoCopySpan +import android.text.Spannable +import android.text.SpannableString +import android.text.SpannableStringBuilder +import android.text.Spanned import android.util.TypedValue import android.view.Gravity import android.view.LayoutInflater @@ -9,19 +14,68 @@ import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import android.widget.TextView +import androidx.core.view.doOnNextLayout import androidx.recyclerview.widget.RecyclerView 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.Markwon +import java.io.Closeable +import java.util.UUID +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference +/** + * Chat adapter optimized for a single message that grows while the model streams. + * + * Stable IDs and payload updates keep RecyclerView from recreating the bubble. Markdown + * parsing runs away from the main thread and each holder coalesces pending work, so an old + * partial response can never replace a newer one. + */ class AgentChatAdapter( private val context: Context, val messages: MutableList, -) : RecyclerView.Adapter() { + private val onContentHeightChanged: (String) -> Unit = {}, +) : RecyclerView.Adapter(), Closeable { + private val markwon = Markwon.create(context) + private val markdownExecutor = Executors.newSingleThreadExecutor() + private val attachedHolders = mutableSetOf() + class Holder(view: View) : RecyclerView.ViewHolder(view) { val card: MaterialCardView = view.findViewById(R.id.agent_message_card) val text: TextView = view.findViewById(R.id.agent_message_text) + var boundMessageId: String? = null + val bindToken = AtomicLong() + val sequence = AtomicLong() + val appliedSequence = AtomicLong() + val renderScheduled = AtomicBoolean() + val pendingRender = AtomicReference() + val renderedText = SpannableStringBuilder() + var measuredHeight = 0 + var heightReportPending = false + + init { + text.setSpannableFactory(object : Spannable.Factory() { + override fun newSpannable(source: CharSequence): Spannable = + source as? Spannable ?: SpannableString(source) + }) + text.setText(renderedText, TextView.BufferType.SPANNABLE) + } + } + + data class RenderRequest( + val bindToken: Long, + val sequence: Long, + val messageId: String, + val markdown: String, + val streaming: Boolean, + ) + + init { + setHasStableIds(true) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder = Holder( @@ -30,27 +84,132 @@ class AgentChatAdapter( override fun getItemCount(): Int = messages.size + override fun getItemId(position: Int): Long = messages[position].id.toStableLong() + override fun onBindViewHolder(holder: Holder, position: Int) { - val message = messages[position] - val mine = message.role == AgentMessageRole.USER - val params = holder.card.layoutParams as FrameLayout.LayoutParams - params.gravity = if (mine) Gravity.END else Gravity.START - holder.card.layoutParams = params - holder.text.text = message.content - - val background = when { - message.isError -> resolve(com.google.android.material.R.attr.colorError, Color.RED) - mine -> resolve(com.google.android.material.R.attr.colorPrimary, Color.DKGRAY) - else -> resolve(com.google.android.material.R.attr.colorSurface, Color.WHITE) + bindMessage(holder, messages[position], fullBind = true) + } + + override fun onViewRecycled(holder: Holder) { + holder.bindToken.incrementAndGet() + holder.boundMessageId = null + holder.pendingRender.set(null) + super.onViewRecycled(holder) + } + + override fun onViewAttachedToWindow(holder: Holder) { + attachedHolders += holder + super.onViewAttachedToWindow(holder) + } + + override fun onViewDetachedFromWindow(holder: Holder) { + attachedHolders -= holder + super.onViewDetachedFromWindow(holder) + } + + private fun bindMessage(holder: Holder, message: AgentConversationMessage, fullBind: Boolean) { + val identityChanged = holder.boundMessageId != message.id + if (identityChanged) { + holder.boundMessageId = message.id + holder.bindToken.incrementAndGet() + holder.appliedSequence.set(0L) + holder.pendingRender.set(null) + holder.renderedText.getSpans(0, holder.renderedText.length, Any::class.java).forEach { span -> + if (span !is NoCopySpan) holder.renderedText.removeSpan(span) + } + holder.renderedText.clear() + holder.text.minHeight = 0 + holder.measuredHeight = 0 + holder.heightReportPending = false + } + if (fullBind || identityChanged) { + val mine = message.role == AgentMessageRole.USER + val params = holder.card.layoutParams as FrameLayout.LayoutParams + params.gravity = if (mine) Gravity.END else Gravity.START + holder.card.layoutParams = params + + val background = when { + message.isError -> resolve(com.google.android.material.R.attr.colorError, Color.RED) + mine -> resolve(com.google.android.material.R.attr.colorPrimary, Color.DKGRAY) + else -> resolve(com.google.android.material.R.attr.colorSurface, Color.WHITE) + } + val foreground = when { + message.isError -> resolve(com.google.android.material.R.attr.colorOnError, Color.WHITE) + mine -> resolve(com.google.android.material.R.attr.colorOnPrimary, Color.WHITE) + else -> resolve(com.google.android.material.R.attr.colorOnSurface, Color.BLACK) + } + holder.card.setCardBackgroundColor(background) + holder.card.strokeColor = if (mine || message.isError) background else foreground.withAlpha(32) + holder.text.setTextColor(foreground) } - val foreground = when { - message.isError -> resolve(com.google.android.material.R.attr.colorOnError, Color.WHITE) - mine -> resolve(com.google.android.material.R.attr.colorOnPrimary, Color.WHITE) - else -> resolve(com.google.android.material.R.attr.colorOnSurface, Color.BLACK) + holder.card.visibility = if (message.content.isBlank()) View.INVISIBLE else View.VISIBLE + enqueueMarkdown(holder, message, streaming = false) + } + + private fun enqueueMarkdown(holder: Holder, message: AgentConversationMessage, streaming: Boolean) { + val request = RenderRequest( + bindToken = holder.bindToken.get(), + sequence = holder.sequence.incrementAndGet(), + messageId = message.id, + markdown = message.content, + streaming = streaming, + ) + holder.pendingRender.set(request) + if (holder.renderScheduled.compareAndSet(false, true)) { + markdownExecutor.execute { drainMarkdown(holder) } + } + } + + private fun drainMarkdown(holder: Holder) { + while (true) { + val request = holder.pendingRender.getAndSet(null) + if (request == null) { + holder.renderScheduled.set(false) + if (holder.pendingRender.get() != null && holder.renderScheduled.compareAndSet(false, true)) { + continue + } + return + } + + val rendered: Spanned? = runCatching { markwon.toMarkdown(request.markdown) }.getOrNull() + holder.text.post { + if (holder.bindToken.get() != request.bindToken || holder.boundMessageId != request.messageId) { + return@post + } + if (request.sequence <= holder.appliedSequence.get()) return@post + holder.appliedSequence.set(request.sequence) + if (request.streaming) { + holder.text.minHeight = maxOf(holder.text.minHeight, holder.text.height) + } + if (holder.measuredHeight == 0 && holder.itemView.height > 0) { + holder.measuredHeight = holder.itemView.height + } + applyRenderedTail(holder, rendered ?: SpannableString(request.markdown)) + reportHeightAfterLayout(holder, request.messageId, request.streaming) + } + } + } + + private fun applyRenderedTail(holder: Holder, rendered: Spanned) { + val buffer = holder.renderedText + val patch = StreamingTextPatchPlanner.calculate(buffer, rendered) ?: return + buffer.getSpans(patch.start, buffer.length, Any::class.java).forEach { span -> + if (span !is NoCopySpan) buffer.removeSpan(span) + } + buffer.replace(patch.start, patch.oldEnd, rendered, patch.start, patch.newEnd) + } + + private fun reportHeightAfterLayout(holder: Holder, messageId: String, streaming: Boolean) { + if (holder.heightReportPending) return + holder.heightReportPending = true + holder.itemView.doOnNextLayout { view -> + holder.heightReportPending = false + if (holder.boundMessageId != messageId) return@doOnNextLayout + val previous = holder.measuredHeight + holder.measuredHeight = view.height + if (streaming) holder.text.minHeight = maxOf(holder.text.minHeight, holder.text.height) + if (previous > 0 && view.height != previous) onContentHeightChanged(messageId) } - holder.card.setCardBackgroundColor(background) - holder.card.strokeColor = if (mine || message.isError) background else foreground.withAlpha(32) - holder.text.setTextColor(foreground) } fun append(message: AgentConversationMessage): Int { @@ -60,10 +219,21 @@ class AgentChatAdapter( return position } - fun replace(position: Int, message: AgentConversationMessage) { + fun replace(position: Int, message: AgentConversationMessage, streaming: Boolean = false) { if (position !in messages.indices) return messages[position] = message - notifyItemChanged(position) + val holder = attachedHolders.firstOrNull { it.boundMessageId == message.id } + if (holder != null) { + if (streaming) { + holder.card.visibility = if (message.content.isBlank()) View.INVISIBLE else View.VISIBLE + enqueueMarkdown(holder, message, streaming = true) + } else if (!message.isError) { + holder.card.visibility = View.VISIBLE + enqueueMarkdown(holder, message, streaming = false) + } else { + bindMessage(holder, message, fullBind = true) + } + } } fun clear() { @@ -72,10 +242,18 @@ class AgentChatAdapter( if (count > 0) notifyItemRangeRemoved(0, count) } + override fun close() { + markdownExecutor.shutdownNow() + } + private fun resolve(attribute: Int, fallback: Int): Int { val value = TypedValue() return if (context.theme.resolveAttribute(attribute, value, true)) value.data else fallback } + private fun String.toStableLong(): Long = runCatching { + UUID.fromString(this).let { it.mostSignificantBits xor it.leastSignificantBits } + }.getOrElse { hashCode().toLong() } + private fun Int.withAlpha(alpha: Int): Int = Color.argb(alpha, Color.red(this), Color.green(this), Color.blue(this)) } diff --git a/app/src/agent/java/com/github/kr328/clash/agent/AgentScreenDesign.kt b/app/src/agent/java/com/github/kr328/clash/agent/AgentScreenDesign.kt index cffda8f7b8..08e9ebc355 100644 --- a/app/src/agent/java/com/github/kr328/clash/agent/AgentScreenDesign.kt +++ b/app/src/agent/java/com/github/kr328/clash/agent/AgentScreenDesign.kt @@ -3,9 +3,41 @@ package com.github.kr328.clash.agent import android.content.Context import android.view.LayoutInflater import android.view.View +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding import com.github.kr328.clash.R import com.github.kr328.clash.design.Design +import kotlin.math.max class AgentScreenDesign(context: Context) : Design(context) { override val root: View = LayoutInflater.from(context).inflate(R.layout.activity_agent, null, false) + + init { + val header = root.findViewById(R.id.agent_header) + val composer = root.findViewById(R.id.agent_composer) + val density = context.resources.displayMetrics.density + val headerHeight = (64 * density).toInt() + val composerBottom = (10 * density).toInt() + + // The application is edge-to-edge. Consume both system-bar and IME insets + // directly here so the toolbar never sits behind a cutout/status bar and the + // composer follows the keyboard instead of being covered by it. + ViewCompat.setOnApplyWindowInsetsListener(root) { _, windowInsets -> + val bars = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val ime = windowInsets.getInsets(WindowInsetsCompat.Type.ime()) + + header.updateLayoutParams { height = headerHeight + bars.top } + header.updatePadding(left = bars.left, top = bars.top, right = bars.right) + composer.updatePadding( + left = bars.left + (12 * density).toInt(), + right = bars.right + (8 * density).toInt(), + bottom = composerBottom + max(bars.bottom, ime.bottom), + ) + + windowInsets + } + root.post { ViewCompat.requestApplyInsets(root) } + } } 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 575c33bf8a..c6e0dd19df 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 @@ -22,6 +22,7 @@ import com.github.kr328.clash.design.store.UiStore import com.github.kr328.clash.service.model.Profile import com.github.kr328.clash.service.model.AccessControlMode import com.github.kr328.clash.service.store.ServiceStore +import com.github.kr328.clash.service.util.ProcessExitDiagnostics import com.github.kr328.clash.remote.Remote import com.github.kr328.clash.util.withClash import com.github.kr328.clash.util.withProfile @@ -66,6 +67,7 @@ class AndroidAgentToolExecutor( "vpn_settings_update" -> vpnSettingsUpdate(arguments) "network_info" -> networkInfo() "logs_recent" -> logsRecent() + "app_exit_history" -> appExitHistory() "runtime_status" -> runtimeStatus() "runtime_set_mode" -> runtimeSetMode(arguments) "runtime_start" -> runtimeStart() @@ -416,6 +418,11 @@ class AndroidAgentToolExecutor( return ok(body.toString().take(120_000), "已读取最近 ${lines.size} 行已保存日志") } + private fun appExitHistory(): AgentToolExecutionResult { + val body = ProcessExitDiagnostics.read(context) + return ok(body, "已读取 Android 记录的 VPN 进程退出历史") + } + private suspend fun runtimeStatus(): AgentToolExecutionResult { val active = withProfile { queryActive() } val body = withClash { 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 new file mode 100644 index 0000000000..eeb705f4f4 --- /dev/null +++ b/app/src/agent/java/com/github/kr328/clash/agent/SmoothMarkdownStream.kt @@ -0,0 +1,178 @@ +package com.github.kr328.clash.agent + +import android.view.Choreographer +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.math.max +import kotlin.math.min + +/** + * Presents a bursty cumulative token stream as stable, frame-paced text. + * + * The adaptive pacing algorithm is based on markstream-core's + * smooth-stream-controller.ts, Copyright (c) 2022 Simon He, MIT licensed. + * This is a native Android implementation using Choreographer; no Vue or DOM code is used. + */ +class SmoothMarkdownStream( + private val onFrame: (String) -> Unit, +) : Choreographer.FrameCallback { + private val choreographer = Choreographer.getInstance() + private var source = "" + private var visible = "" + private var scheduled = false + private var cancelled = false + private var finishing = false + private var startedAtNanos = 0L + private var lastFrameNanos = 0L + private var lastCommitNanos = 0L + private var currentCharsPerSecond = MIN_CHARS_PER_SECOND + private var characterBudget = 0.0 + private var finishContinuation: Continuation? = null + + fun submit(cumulativeText: String) { + if (cancelled || finishing || cumulativeText == source) return + source = cumulativeText + if (!source.startsWith(visible)) { + visible = source.commonPrefixWith(visible) + onFrame(visible) + } + schedule() + } + + suspend fun finish(finalText: String) { + if (cancelled) return + source = finalText + if (!source.startsWith(visible)) { + visible = source.commonPrefixWith(visible) + onFrame(visible) + } + finishing = true + if (visible == source) return + suspendCancellableCoroutine { continuation -> + finishContinuation = continuation + continuation.invokeOnCancellation { + if (finishContinuation === continuation) finishContinuation = null + } + schedule() + } + } + + fun cancel() { + if (cancelled) return + cancelled = true + scheduled = false + choreographer.removeFrameCallback(this) + finishContinuation?.resume(Unit) + finishContinuation = null + } + + override fun doFrame(frameTimeNanos: Long) { + scheduled = false + if (cancelled) return + if (startedAtNanos == 0L) { + startedAtNanos = frameTimeNanos + lastFrameNanos = frameTimeNanos + } + if (frameTimeNanos - startedAtNanos < START_DELAY_NANOS) { + schedule() + return + } + + val pending = source.length - visible.length + if (pending <= 0) { + completeIfFinished() + return + } + + val elapsedSeconds = ((frameTimeNanos - lastFrameNanos).coerceAtMost(MAX_DELTA_NANOS)) / NANOS_PER_SECOND + lastFrameNanos = frameTimeNanos + val targetLatency = if (finishing) FINISH_LATENCY_SECONDS else TARGET_LATENCY_SECONDS + val latencyCharsPerSecond = pending / targetLatency + val estimatedLatency = pending / max(currentCharsPerSecond, 1.0) + val targetCharsPerSecond = if ( + pending >= CATCH_UP_THRESHOLD || estimatedLatency >= CATCH_UP_LATENCY_SECONDS + ) { + MAX_CHARS_PER_SECOND + } else { + latencyCharsPerSecond.coerceIn(MIN_CHARS_PER_SECOND, MAX_CHARS_PER_SECOND) + } + currentCharsPerSecond += (targetCharsPerSecond - currentCharsPerSecond) * SPEED_EASING + characterBudget += currentCharsPerSecond * elapsedSeconds + + val commitIntervalPassed = frameTimeNanos - lastCommitNanos >= MIN_COMMIT_INTERVAL_NANOS + val requestedCharacters = min(characterBudget.toInt(), MAX_CHARS_PER_COMMIT) + if (commitIntervalPassed && requestedCharacters > 0) { + val end = safeSliceEnd(source, visible.length, requestedCharacters) + if (end > visible.length) { + val consumed = end - visible.length + visible = source.substring(0, end) + characterBudget = max(0.0, characterBudget - consumed) + lastCommitNanos = frameTimeNanos + onFrame(visible) + } + } + + if (visible == source) completeIfFinished() else schedule() + } + + private fun completeIfFinished() { + if (!finishing || visible != source) return + finishContinuation?.resume(Unit) + finishContinuation = null + } + + private fun schedule() { + if (scheduled || cancelled || visible == source) { + completeIfFinished() + return + } + scheduled = true + choreographer.postFrameCallback(this) + } + + private fun safeSliceEnd(text: String, start: Int, requestedCharacters: Int): Int { + var index = start + var remaining = requestedCharacters + while (index < text.length && remaining > 0) { + val codePoint = text.codePointAt(index) + index += Character.charCount(codePoint) + remaining-- + + while (index < text.length) { + val next = text.codePointAt(index) + val type = Character.getType(next) + val extendsCluster = type == Character.NON_SPACING_MARK.toInt() || + type == Character.COMBINING_SPACING_MARK.toInt() || + type == Character.ENCLOSING_MARK.toInt() || + next in 0xFE00..0xFE0F || next in 0x1F3FB..0x1F3FF + if (extendsCluster) { + index += Character.charCount(next) + } else if (next == ZERO_WIDTH_JOINER && index + 1 < text.length) { + index += Character.charCount(next) + val joined = text.codePointAt(index) + index += Character.charCount(joined) + } else { + break + } + } + } + return index + } + + private companion object { + 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 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 SPEED_EASING = 0.2 + const val ZERO_WIDTH_JOINER = 0x200D + } +} diff --git a/app/src/agent/java/com/github/kr328/clash/agent/StreamingTextPatch.kt b/app/src/agent/java/com/github/kr328/clash/agent/StreamingTextPatch.kt new file mode 100644 index 0000000000..535e3eaa3e --- /dev/null +++ b/app/src/agent/java/com/github/kr328/clash/agent/StreamingTextPatch.kt @@ -0,0 +1,40 @@ +package com.github.kr328.clash.agent + +/** A bounded tail replacement. Stable rendered blocks before [start] are never touched. */ +internal data class StreamingTextPatch( + val start: Int, + val oldEnd: Int, + val newEnd: Int, +) + +/** + * Finds the smallest safe Markdown block tail to update. + * + * Replacing only this tail keeps already rendered blocks and their Android spans stable. A + * paragraph boundary is used instead of the raw common prefix because list/quote/code paragraph + * spans can change while the active block is still being streamed. + */ +internal object StreamingTextPatchPlanner { + fun calculate(current: CharSequence, next: CharSequence): StreamingTextPatch? { + if (current.contentEquals(next)) return null + + val commonLimit = minOf(current.length, next.length) + var common = 0 + while (common < commonLimit && current[common] == next[common]) common++ + if (common > 0 && common < current.length && Character.isLowSurrogate(current[common]) && + Character.isHighSurrogate(current[common - 1])) { + common-- + } + + var blockStart = 0 + var index = common - 1 + while (index > 0) { + if (current[index] == '\n' && current[index - 1] == '\n') { + blockStart = index + 1 + break + } + index-- + } + return StreamingTextPatch(blockStart, current.length, next.length) + } +} diff --git a/app/src/agent/res/drawable/ic_agent_send.xml b/app/src/agent/res/drawable/ic_agent_send.xml new file mode 100644 index 0000000000..286c0d07b8 --- /dev/null +++ b/app/src/agent/res/drawable/ic_agent_send.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/agent/res/layout/activity_agent.xml b/app/src/agent/res/layout/activity_agent.xml index a5ef51aedb..db7257c455 100644 --- a/app/src/agent/res/layout/activity_agent.xml +++ b/app/src/agent/res/layout/activity_agent.xml @@ -3,10 +3,10 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" - android:orientation="vertical" - android:fitsSystemWindows="true"> + android:orientation="vertical"> - + android:scrollbars="none"> - + android:paddingVertical="2dp" + app:chipSpacingHorizontal="8dp" + app:singleLine="true"> - + - - + + + + + + android:minHeight="56dp" + android:minLines="1" + android:singleLine="false" /> + android:contentDescription="@string/agent_send" + android:insetTop="0dp" + android:insetBottom="0dp" + android:minWidth="0dp" + android:padding="0dp" + app:icon="@drawable/ic_agent_send" + app:iconGravity="textStart" + app:iconPadding="0dp" + app:iconSize="24dp" /> diff --git a/app/src/agent/res/layout/dialog_agent_settings.xml b/app/src/agent/res/layout/dialog_agent_settings.xml index 0153300f91..ac00447b2e 100644 --- a/app/src/agent/res/layout/dialog_agent_settings.xml +++ b/app/src/agent/res/layout/dialog_agent_settings.xml @@ -8,7 +8,6 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" - android:paddingHorizontal="4dp" android:paddingBottom="8dp"> 发送 停止 模型与授权设置 + 测试连接 + 正在测试… + 连接成功,模型已响应 清空对话 从零创建配置 按已安装应用配置分流 diff --git a/app/src/main/java/com/github/kr328/clash/MainActivity.kt b/app/src/main/java/com/github/kr328/clash/MainActivity.kt index e94bbe8e87..d18a8a817c 100644 --- a/app/src/main/java/com/github/kr328/clash/MainActivity.kt +++ b/app/src/main/java/com/github/kr328/clash/MainActivity.kt @@ -31,12 +31,11 @@ import com.github.kr328.clash.design.R as DesignR class MainActivity : BaseActivity() { override suspend fun main() { - val design = MainDesign(this) + val design = MainDesign(this, agentEnabled = BuildConfig.FLAVOR == "agent") setContentDesign(design) design.fetch() - design.setAgentEnabled(BuildConfig.FLAVOR == "agent") val ticker = ticker(TimeUnit.SECONDS.toMillis(1)) diff --git a/app/src/test/java/com/github/kr328/clash/agent/StreamingTextPatchTest.kt b/app/src/test/java/com/github/kr328/clash/agent/StreamingTextPatchTest.kt new file mode 100644 index 0000000000..26b28fbe64 --- /dev/null +++ b/app/src/test/java/com/github/kr328/clash/agent/StreamingTextPatchTest.kt @@ -0,0 +1,51 @@ +package com.github.kr328.clash.agent + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class StreamingTextPatchTest { + @Test + fun identicalFramesDoNoWork() { + assertNull(StreamingTextPatchPlanner.calculate("same", "same")) + } + + @Test + fun onlyActiveBlockIsReplaced() { + val current = "First paragraph.\n\nSecond **par" + val next = "First paragraph.\n\nSecond paragraph" + val patch = assertNotNull(StreamingTextPatchPlanner.calculate(current, next)) + assertEquals("First paragraph.\n\n".length, patch.start) + assertEquals(next, apply(current, next, patch)) + } + + @Test + fun completedHistoryNeverChangesDuringLongBurstyStream() { + val stable = (1..120).joinToString("\n\n") { "Stable block $it." } + "\n\n" + var visible = stable + var totalMutatedCharacters = 0L + repeat(4_000) { index -> + val next = stable + "Live block " + "token ".repeat(index + 1) + val patch = assertNotNull(StreamingTextPatchPlanner.calculate(visible, next)) + assertEquals(stable.length, patch.start) + assertEquals(next, apply(visible, next, patch)) + totalMutatedCharacters += patch.oldEnd - patch.start + patch.newEnd - patch.start + visible = next + } + assertTrue(totalMutatedCharacters < visible.length.toLong() * 4_100) + } + + @Test + fun surrogatePairIsNeverSplit() { + val current = "Stable.\n\nHello 👨‍👩‍👧" + val next = "Stable.\n\nHello 👨‍👩‍👧‍👦!" + val patch = assertNotNull(StreamingTextPatchPlanner.calculate(current, next)) + assertTrue(patch.start == 0 || !Character.isLowSurrogate(current[patch.start])) + assertEquals(next, apply(current, next, patch)) + } + + private fun apply(current: String, next: String, patch: StreamingTextPatch): String = + current.substring(0, patch.start) + next.substring(patch.start, patch.newEnd) +} diff --git a/build.gradle.kts b/build.gradle.kts index 267d97b789..a3e273c8b1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -165,6 +165,15 @@ subprojects { } signingConfigs { + create("agentDebug") { + // CI restores this development-only key from the repository's private + // Actions cache. Use an explicit path so every module signs with it. + storeFile = file("${System.getProperty("user.home")}/.android/debug.keystore") + storePassword = "android" + keyAlias = "androiddebugkey" + keyPassword = "android" + } + val keystore = rootProject.file("signing.properties") if (keystore.exists()) { create("release") { @@ -192,6 +201,7 @@ subprojects { } named("debug") { versionNameSuffix = ".debug" + signingConfig = signingConfigs["agentDebug"] } } diff --git a/design/src/main/java/com/github/kr328/clash/design/MainDesign.kt b/design/src/main/java/com/github/kr328/clash/design/MainDesign.kt index 2b9e3b8e40..08af745f89 100644 --- a/design/src/main/java/com/github/kr328/clash/design/MainDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/MainDesign.kt @@ -13,7 +13,10 @@ import com.github.kr328.clash.design.util.root import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -class MainDesign(context: Context) : Design(context) { +class MainDesign( + context: Context, + agentEnabled: Boolean = false, +) : Design(context) { enum class Request { ToggleStatus, OpenProxy, @@ -67,12 +70,6 @@ class MainDesign(context: Context) : Design(context) { } } - suspend fun setAgentEnabled(enabled: Boolean) { - withContext(Dispatchers.Main) { - binding.agentEnabled = enabled - } - } - suspend fun showAbout(versionName: String) { withContext(Dispatchers.Main) { val binding = DesignAboutBinding.inflate(context.layoutInflater).apply { @@ -87,6 +84,9 @@ class MainDesign(context: Context) : Design(context) { init { binding.self = this + // Set flavor-only visibility before the root view is attached. Updating it after + // the initial status/profile IPC calls caused the Agent card to pop in late. + binding.agentEnabled = agentEnabled binding.colorClashStarted = context.resolveThemedColor(com.google.android.material.R.attr.colorPrimary) binding.colorClashStopped = context.resolveThemedColor(R.attr.colorClashStopped) diff --git a/design/src/main/res/drawable/ic_baseline_auto_awesome.xml b/design/src/main/res/drawable/ic_baseline_auto_awesome.xml index 07567dd431..409cb9ff7b 100644 --- a/design/src/main/res/drawable/ic_baseline_auto_awesome.xml +++ b/design/src/main/res/drawable/ic_baseline_auto_awesome.xml @@ -1,6 +1,7 @@ + + + + + diff --git a/design/src/main/res/layout/design_main.xml b/design/src/main/res/layout/design_main.xml index 1ea138dcd3..e815e16149 100644 --- a/design/src/main/res/layout/design_main.xml +++ b/design/src/main/res/layout/design_main.xml @@ -116,7 +116,7 @@ android:layout_marginVertical="@dimen/main_card_margin_vertical" android:onClick="@{() -> self.request(Request.OpenAgent)}" android:visibility="@{agentEnabled ? View.VISIBLE : View.GONE}" - app:icon="@drawable/ic_baseline_auto_awesome" + app:icon="@drawable/ic_outline_agent_chat" app:subtext="@string/ai_assistant_summary" app:text="@string/ai_assistant" /> diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3190850eff..c53d6e08a7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ room = "2.4.2" multiprocess = "1.0.0" quickie = "1.11.0" androidx-activity-ktx = "1.9.0" +markwon = "4.6.2" [libraries] build-android = { module = "com.android.tools.build:gradle", version.ref = "agp" } @@ -43,5 +44,6 @@ kaidl-runtime = { module = "com.github.kr328.kaidl:kaidl-runtime", version.ref = rikkax-multiprocess = { module = "dev.rikka.rikkax.preference:multiprocess", version.ref = "multiprocess" } quickie-bundled = { group = "io.github.g00fy2.quickie", name = "quickie-bundled", version.ref = "quickie" } androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "androidx-activity-ktx" } +markwon-core = { module = "io.noties.markwon:core", version.ref = "markwon" } [plugins] diff --git a/service/src/main/java/com/github/kr328/clash/service/TunService.kt b/service/src/main/java/com/github/kr328/clash/service/TunService.kt index 8550f8ab81..0127bb0289 100644 --- a/service/src/main/java/com/github/kr328/clash/service/TunService.kt +++ b/service/src/main/java/com/github/kr328/clash/service/TunService.kt @@ -15,8 +15,10 @@ import com.github.kr328.clash.service.model.AccessControlMode import com.github.kr328.clash.service.store.ServiceStore import com.github.kr328.clash.service.util.cancelAndJoinBlocking import com.github.kr328.clash.service.util.parseCIDR +import com.github.kr328.clash.service.util.ProcessExitDiagnostics import com.github.kr328.clash.service.util.sendClashStarted import com.github.kr328.clash.service.util.sendClashStopped +import com.github.kr328.clash.service.util.VpnServiceRecovery import kotlinx.coroutines.* import kotlinx.coroutines.selects.select @@ -83,6 +85,8 @@ class TunService : VpnService(), CoroutineScope by CoroutineScope(Dispatchers.De override fun onCreate() { super.onCreate() + ProcessExitDiagnostics.logLatest(this) + if (StatusProvider.serviceRunning) return stopSelf() @@ -92,17 +96,37 @@ class TunService : VpnService(), CoroutineScope by CoroutineScope(Dispatchers.De StaticNotificationModule.notifyLoadingNotification(this) runtime.launch() + launch { + while (isActive) { + VpnServiceRecovery.arm(this@TunService) + delay(VpnServiceRecovery.heartbeatDelay(this@TunService)) + } + } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { sendClashStarted() - return super.onStartCommand(intent, flags, startId) + // VpnService inherits Service's non-sticky default. On devices with aggressive + // background management (notably OriginOS), that meant the tunnel disappeared + // permanently after the process was reclaimed. A sticky foreground VPN is + // recreated by Android, while explicit stopSelf()/user stops still stay stopped. + return START_STICKY + } + + override fun onRevoke() { + reason = "VPN permission was revoked by Android or another VPN app" + Log.w(reason!!) + super.onRevoke() } override fun onDestroy() { TunModule.requestStop() + // A deliberate stop must remain stopped. Abrupt process death cannot execute this + // cancellation, which is exactly when the system-owned recovery alarm is needed. + VpnServiceRecovery.cancel(this) + StatusProvider.serviceRunning = false sendClashStopped(reason) diff --git a/service/src/main/java/com/github/kr328/clash/service/util/ProcessExitDiagnostics.kt b/service/src/main/java/com/github/kr328/clash/service/util/ProcessExitDiagnostics.kt new file mode 100644 index 0000000000..78dcbb6e1d --- /dev/null +++ b/service/src/main/java/com/github/kr328/clash/service/util/ProcessExitDiagnostics.kt @@ -0,0 +1,93 @@ +package com.github.kr328.clash.service.util + +import android.app.ActivityManager +import android.content.Context +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.content.getSystemService +import com.github.kr328.clash.common.log.Log +import org.json.JSONArray +import org.json.JSONObject + +/** Reads Android's system-maintained process exit history without exposing configs or secrets. */ +object ProcessExitDiagnostics { + fun read(context: Context): String { + if (Build.VERSION.SDK_INT < 30) { + return JSONObject() + .put("available", false) + .put("reason", "requires_android_11") + .toString() + } + + return readApi30(context) + } + + fun logLatest(context: Context) { + if (Build.VERSION.SDK_INT < 30) return + + runCatching { + val manager = context.getSystemService() ?: return + val background = "${context.packageName}:background" + val latest = manager.getHistoricalProcessExitReasons(context.packageName, 0, 10) + .firstOrNull { it.processName == background } + ?: return + Log.w( + "Previous background process exit: ${reasonName(latest.reason)}, " + + "timestamp=${latest.timestamp}, status=${latest.status}, " + + "description=${latest.description?.take(240).orEmpty()}" + ) + }.onFailure { Log.w("Unable to read previous process exit reason", it) } + } + + @RequiresApi(30) + private fun readApi30(context: Context): String { + val manager = context.getSystemService() + ?: return JSONObject().put("available", false).put("reason", "activity_manager_unavailable").toString() + val background = "${context.packageName}:background" + val exits = manager.getHistoricalProcessExitReasons(context.packageName, 0, 20) + .filter { it.processName == background } + .take(10) + + val rows = JSONArray() + exits.forEach { exit -> + rows.put( + JSONObject() + .put("timestamp", exit.timestamp) + .put("reason", reasonName(exit.reason)) + .put("reason_code", exit.reason) + .put("status", exit.status) + .put("importance", exit.importance) + .put("pss_kb", exit.pss) + .put("rss_kb", exit.rss) + .put("description", exit.description?.take(500).orEmpty()) + ) + } + + return JSONObject() + .put("available", true) + .put("process", background) + .put("exits", rows) + .toString() + } + + private fun reasonName(reason: Int): String = when (reason) { + 0 -> "unknown" + 1 -> "exit_self" + 2 -> "signaled" + 3 -> "low_memory" + 4 -> "crash" + 5 -> "native_crash" + 6 -> "anr" + 7 -> "initialization_failure" + 8 -> "permission_change" + 9 -> "excessive_resource_usage" + 10 -> "user_requested" + 11 -> "user_stopped" + 12 -> "dependency_died" + 13 -> "other" + 14 -> "freezer" + 15 -> "package_state_change" + 16 -> "package_updated" + else -> "unknown_$reason" + } +} diff --git a/service/src/main/java/com/github/kr328/clash/service/util/VpnServiceRecovery.kt b/service/src/main/java/com/github/kr328/clash/service/util/VpnServiceRecovery.kt new file mode 100644 index 0000000000..8859a80200 --- /dev/null +++ b/service/src/main/java/com/github/kr328/clash/service/util/VpnServiceRecovery.kt @@ -0,0 +1,60 @@ +package com.github.kr328.clash.service.util + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.PowerManager +import android.os.SystemClock +import androidx.core.content.getSystemService +import com.github.kr328.clash.common.compat.pendingIntentFlags +import com.github.kr328.clash.service.TunService + +/** + * A dead-man timer for OEM process kills. A healthy service continuously moves the alarm + * forward, so it never fires or wakes the device. If the process disappears abruptly, the + * last system-owned PendingIntent remains and recreates the foreground VPN service. + */ +object VpnServiceRecovery { + private const val REQUEST_CODE = 0x564E + private const val ACTION_RECOVER = "com.github.kr328.clash.action.RECOVER_VPN" + private const val INTERACTIVE_HEARTBEAT_MS = 20_000L + private const val IDLE_HEARTBEAT_MS = 120_000L + private const val INTERACTIVE_TIMEOUT_MS = 60_000L + private const val IDLE_TIMEOUT_MS = 240_000L + + fun heartbeatDelay(context: Context): Long = + if (context.getSystemService()?.isInteractive == false) + IDLE_HEARTBEAT_MS + else + INTERACTIVE_HEARTBEAT_MS + + fun arm(context: Context) { + val alarm = context.getSystemService() ?: return + val timeout = if (context.getSystemService()?.isInteractive == false) + IDLE_TIMEOUT_MS + else + INTERACTIVE_TIMEOUT_MS + val intent = recoveryIntent(context) + alarm.cancel(intent) + val triggerAt = SystemClock.elapsedRealtime() + timeout + if (Build.VERSION.SDK_INT >= 23) + alarm.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, triggerAt, intent) + else + alarm.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, triggerAt, intent) + } + + fun cancel(context: Context) { + context.getSystemService()?.cancel(recoveryIntent(context)) + } + + private fun recoveryIntent(context: Context): PendingIntent { + val intent = Intent(context, TunService::class.java).setAction(ACTION_RECOVER) + val flags = pendingIntentFlags(PendingIntent.FLAG_UPDATE_CURRENT) + return if (Build.VERSION.SDK_INT >= 26) + PendingIntent.getForegroundService(context, REQUEST_CODE, intent, flags) + else + PendingIntent.getService(context, REQUEST_CODE, intent, flags) + } +}