From 8b1f9141dabe4f1dd748f7f352dcfc0a017dfb00 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:16:50 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat(editor):=20=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E5=89=8D=E6=A3=80=E6=B5=8B=E3=80=8C=E6=96=87=E4=BB=B6=E5=B7=B2?= =?UTF-8?q?=E8=A2=AB=E5=A4=96=E9=83=A8=E6=94=B9=E5=8A=A8=E3=80=8D=EF=BC=8C?= =?UTF-8?q?=E4=BA=8C=E6=AC=A1=E7=A1=AE=E8=AE=A4=E5=90=8E=E5=86=8D=E8=A6=86?= =?UTF-8?q?=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C11。现状:save() 直接 writeFile(overwrite = true),接口里明明有 lastModified() 但保存路径没用 —— 编辑器开着、你在服务器上改了同一文件、回来点保存,服务器侧 改动被静默覆盖。 做法(审计建议的「提示不阻断」): - CodeEditorViewModel 打开文件时记下 mtime(loadedModifiedAt,取不到则 0)。 保存前比对:mtime 变新 = 被外部改过 → 发 SaveResult.Conflict(新增的密封分支) 并且**不写盘**;UI 弹 AlertDialog 二次确认,确认后带 force = true 重来。 取消则留在编辑器里继续改(pendingExit 一并清掉,与保存失败分支一致)。 - 自己写完会刷新基准(重新读一次 mtime),否则下一次保存必然把自己的写入误判成外部改动。 - 换文件时清空基准,避免拿上一个文件的时间戳判新文件。 误报可能性(按你的要求给判断): - 真实误报来源只有一类:内容没变但 mtime 前进了(git checkout、touch、rsync 扫描、 某些编辑器保存时重写)。此时会多弹一次对话框,用户点「仍要保存」即可 —— 不丢数据, 代价是一次多余交互。所以只提示不硬拦。 - 漏报(检测不到外部改动):SFTP v3 的 mtime 只有秒级精度,若外部改动与「本机保存」 落在同一秒内,或远端 FS 时间戳不可靠(取不到 → 基准 0 → 直接跳过冲突检测), 则不提示。宁可漏报也不误报卡人。 - 时钟偏移不影响:比较的是远端文件自己的 mtime 前后值,不掺本地时间。 行为变化:保存多一次 statExistence(本地一次 stat / 远程一次 SFTP 往返); 仅在检测到冲突时多一个确认框;其余路径不变。新增 3 条字符串(中英各一, 键集合 1477/1477)。 --- .../editor/presentation/CodeEditorScreen.kt | 25 +++++++++++ .../presentation/CodeEditorViewModel.kt | 42 +++++++++++++++++-- app/src/main/res/values-en/strings.xml | 3 ++ app/src/main/res/values/strings.xml | 3 ++ 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/aicode/feature/editor/presentation/CodeEditorScreen.kt b/app/src/main/java/com/aicode/feature/editor/presentation/CodeEditorScreen.kt index 13e6a6f7..2476c1e6 100644 --- a/app/src/main/java/com/aicode/feature/editor/presentation/CodeEditorScreen.kt +++ b/app/src/main/java/com/aicode/feature/editor/presentation/CodeEditorScreen.kt @@ -112,6 +112,7 @@ fun CodeEditorScreen( val baselineText = remember { mutableStateOf("") } var pendingSaveText by remember { mutableStateOf("") } var pendingExit by remember { mutableStateOf(false) } + var showSaveConflict by remember { mutableStateOf(false) } var showUnsavedDialog by remember { mutableStateOf(false) } var showSettings by remember { mutableStateOf(false) } var previewMode by remember { mutableStateOf(false) } @@ -130,6 +131,7 @@ fun CodeEditorScreen( canRedo = false dirty = false pendingExit = false + showSaveConflict = false cursorLine = 1 cursorColumn = 1 previewMode = false @@ -158,6 +160,8 @@ fun CodeEditorScreen( Toast.LENGTH_LONG ).show() } + // 文件被外部改过:弹二次确认(不硬拦,确认后强制写盘) + is SaveResult.Conflict -> showSaveConflict = true } } } @@ -330,6 +334,27 @@ fun CodeEditorScreen( } } + if (showSaveConflict) { + AlertDialog( + onDismissRequest = { showSaveConflict = false }, + title = { Text(stringResource(R.string.editor_save_conflict_title)) }, + text = { Text(stringResource(R.string.editor_save_conflict_message)) }, + confirmButton = { + TextButton(onClick = { + showSaveConflict = false + // 强制写盘:pendingExit 保持不变,保存成功后照旧走退出流程 + viewModel.save(pendingSaveText, force = true) + }) { Text(stringResource(R.string.editor_save_conflict_overwrite)) } + }, + dismissButton = { + TextButton(onClick = { + showSaveConflict = false + pendingExit = false + }) { Text(stringResource(R.string.common_cancel)) } + } + ) + } + if (showUnsavedDialog) { AlertDialog( onDismissRequest = { showUnsavedDialog = false }, diff --git a/app/src/main/java/com/aicode/feature/editor/presentation/CodeEditorViewModel.kt b/app/src/main/java/com/aicode/feature/editor/presentation/CodeEditorViewModel.kt index b02eaa78..f195f533 100644 --- a/app/src/main/java/com/aicode/feature/editor/presentation/CodeEditorViewModel.kt +++ b/app/src/main/java/com/aicode/feature/editor/presentation/CodeEditorViewModel.kt @@ -35,6 +35,12 @@ sealed interface EditorUiState { sealed interface SaveResult { data object Success : SaveResult data class Error(val detail: String?) : SaveResult + + /** + * 文件在打开之后被别的程序/会话改过(mtime 变新),本次没有写盘。 + * 由 UI 二次确认后带 force = true 重来——只提示,不硬拦。 + */ + data object Conflict : SaveResult } @HiltViewModel @@ -59,16 +65,23 @@ class CodeEditorViewModel @Inject constructor( private var loadedPath: String? = null + /** 打开时记录的文件修改时间(毫秒;0 表示取不到)。供保存前判断文件是否被外部改过。 */ + private var loadedModifiedAt: Long = 0L + /** 重复调用同一路径不会重复读盘,供 Compose 重组时安全调用。 */ fun load(path: String) { if (loadedPath == path) return loadedPath = path + // 换文件先清掉基准:否则会拿上一个文件的时间戳去判新文件的冲突 + loadedModifiedAt = 0L _uiState.value = EditorUiState.Loading viewModelScope.launch(Dispatchers.IO) { _uiState.value = runCatching { if (!fileAccess.exists(path) || !fileAccess.isFile(path)) { return@runCatching EditorUiState.Error(null) } + // 保存冲突检测的基准:取不到时间戳(0)时后面不做冲突判定,宁可漏报也不误报 + loadedModifiedAt = runCatching { fileAccess.lastModified(path) }.getOrDefault(0L) val size = fileAccess.fileSize(path) if (size > MAX_EDITABLE_BYTES) { return@runCatching EditorUiState.TooLarge(size) @@ -97,15 +110,30 @@ class CodeEditorViewModel @Inject constructor( } } - /** 把编辑器当前内容写回文件。写入在 IO 线程进行,结果通过 [saveEvents] 通知,期间 [saving] 置位。 */ - fun save(content: String) { + /** + * 把编辑器当前内容写回文件。写入在 IO 线程进行,结果通过 [saveEvents] 通知,期间 [saving] 置位。 + * + * [force] = false 时先比对 mtime:文件在打开之后被别的程序/会话改过就先发 [SaveResult.Conflict] + * 并且**不写盘**,由 UI 提示用户二次确认(确认后带 force = true 重来)。只提示不硬拦: + * SFTP v3 的 mtime 只有秒级精度、远端 FS 时间戳也未必可靠,硬拦会误报卡人。 + */ + fun save(content: String, force: Boolean = false) { val path = loadedPath ?: return viewModelScope.launch(Dispatchers.IO) { _saving.value = true try { + if (!force && hasExternalChange()) { + FileLogger.w(TAG, "保存前检测到文件已被外部修改: $path") + _saveEvents.send(SaveResult.Conflict) + return@launch + } val result = runCatching { fileAccess.writeFile(path, content) } .fold( - onSuccess = { SaveResult.Success }, + onSuccess = { + // 自己写完就刷新基准,否则下一次保存必然把自己的写入当成外部改动 + loadedModifiedAt = runCatching { fileAccess.lastModified(path) }.getOrDefault(0L) + SaveResult.Success + }, onFailure = { e -> FileLogger.w(TAG, "保存文件失败: $path", e) SaveResult.Error(e.message) @@ -118,6 +146,14 @@ class CodeEditorViewModel @Inject constructor( } } + /** 文件是否在打开之后被外部改动过(mtime 变新)。基准或当前值取不到(0)时不判定冲突。 */ + private fun hasExternalChange(): Boolean { + val path = loadedPath ?: return false + if (loadedModifiedAt <= 0L) return false + val current = runCatching { fileAccess.lastModified(path) }.getOrDefault(0L) + return current > loadedModifiedAt + } + private companion object { const val TAG = "CodeEditorViewModel" diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index c720d177..e84c9fda 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -552,6 +552,9 @@ %1$d sp File too large (%1$d MB) to open Failed to open file + File changed elsewhere + This file was modified by another program or session after it was opened. Saving will overwrite those changes. Save anyway? + Save anyway Binary file, opening not supported diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1f3fb336..897e589d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -547,6 +547,9 @@ %1$d sp 文件过大(%1$d MB),暂不支持打开 文件打开失败 + 文件已被其他改动 + 该文件在打开后被其它程序或会话修改过,保存会覆盖那些改动。仍要保存吗? + 仍要保存 二进制文件,暂不支持打开 重命名会话 会话名 From d8e39793d2f8bc5550a549ee7d6d4a43563f88bd Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:13:08 +0800 Subject: [PATCH 02/10] =?UTF-8?q?feat(terminal):=20=E6=96=AD=E7=BA=BF?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=E7=9A=84=E4=BC=9A=E8=AF=9D=E7=BB=93=E6=9D=9F?= =?UTF-8?q?=E6=89=93=E5=87=BA=E3=80=8C=E8=BF=9E=E6=8E=A5=E5=B7=B2=E6=96=AD?= =?UTF-8?q?=E5=BC=80=E3=80=8D=E6=A8=AA=E5=B9=85=EF=BC=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E4=BC=AA=E8=A3=85=E6=88=90=E6=AD=A3=E5=B8=B8=E9=80=80=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C10。现状:SshShellBackend.waitForExit() 恒返回 0(远程 shell 没有有意义的退出码), 网断/服务器重启后 Termux 只会打一句「[Process completed]」,与用户敲 exit 长得一样, 用户完全看不出连接已经没了。 选了「加标记 + 标签横幅」,不动 waitForExit 的返回值语义: - 返回值是 SessionBackend 接口约定,本地终端(SubprocessBackend)共用, 改语义会连带影响本地终端的退出码显示。 - SshShellBackend 增加 closedByDisconnect 标记 + 可选的 isConnectionAlive 回调 (默认 { true },既有单参构造调用不受影响)。waitForExit 里:join 抛异常,或 join 正常返回但此时连接已不在,就置位。时序是安全的:Termux 的 TermSessionWaiter 线程先 拿到 waitForExit 的返回值,再经主线程 handler 回调 onSessionFinished,标记一定先于 回调写入(@Volatile 保证可见性)。 - RemoteTerminalSessionManager 记录 标签 id → backend(关闭标签时清理), onSessionFinished 时若 backend.closedByDisconnect 则把 TerminalTab.droppedByDisconnect 置位并记日志。 - TerminalScreen 在当前标签 droppedByDisconnect 时于标签栏下方挂一条 errorContainer 横幅(新增两条字符串,中英各一,键集合仍一致 1479/1479)。 已知局限(不能靠这条兜住所有断线):断网瞬间 sshj 的 transport 未必立刻判定失败 (isConnected 可能仍为 true,要等 keepalive/下一次写失败),此时 shell 结束会被当成 正常退出、没有横幅。连接级的断线由聊天页的连接指示器与「重试连接」按钮覆盖。 行为变化:纯粹新增提示与日志,无既有判定改动。本地终端模式完全不受影响 (isConnectionAlive 默认 true,droppedByDisconnect 恒 false)。 --- .../domain/RemoteTerminalSessionManager.kt | 17 ++++++++- .../terminal/domain/SshShellBackend.kt | 30 ++++++++++++---- .../feature/terminal/domain/TerminalTab.kt | 7 ++++ .../presentation/component/TerminalScreen.kt | 36 ++++++++++++++++++- app/src/main/res/values-en/strings.xml | 2 ++ app/src/main/res/values/strings.xml | 2 ++ 6 files changed, 85 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/aicode/feature/terminal/domain/RemoteTerminalSessionManager.kt b/app/src/main/java/com/aicode/feature/terminal/domain/RemoteTerminalSessionManager.kt index ce4f3afa..26be6dcb 100644 --- a/app/src/main/java/com/aicode/feature/terminal/domain/RemoteTerminalSessionManager.kt +++ b/app/src/main/java/com/aicode/feature/terminal/domain/RemoteTerminalSessionManager.kt @@ -51,6 +51,12 @@ class RemoteTerminalSessionManager @Inject constructor( private val idCounter = AtomicInteger(0) + /** + * 标签 id → 它的 SSH shell backend。用于在会话结束时判断是「正常结束」还是「断线结束」: + * [SshShellBackend.closedByDisconnect] 只能从 backend 上读到。 + */ + private val backends = mutableMapOf() + val activeTab: TerminalTab? get() = _tabs.value.firstOrNull { it.id == _activeTabId.value } fun tab(id: String): TerminalTab? = _tabs.value.firstOrNull { it.id == id } @@ -107,7 +113,8 @@ class RemoteTerminalSessionManager @Inject constructor( val shell = withContext(Dispatchers.IO) { connection.startShellSession().also { it.allocateDefaultPTY() }.startShell() } - val backend = SshShellBackend(shell) + val backend = SshShellBackend(shell) { connection.isConnected() } + backends[id] = backend val termSession = TerminalSession(TRANSCRIPT_ROWS, AppRemoteSessionClient(), backend) termSession.updateSize(DEFAULT_COLUMNS, DEFAULT_ROWS) // shell 登录后默认在 home,先 cd 到当前工作区,与命令执行链路(RemoteSshEngine.buildCdCommand)保持一致: @@ -181,6 +188,7 @@ class RemoteTerminalSessionManager @Inject constructor( val tab = tab(id) ?: return false runCatching { tab.session.finishIfRunning() } tab.view = null + backends.remove(id) val remaining = _tabs.value.filterNot { it.id == id } _tabs.value = remaining if (_activeTabId.value == id) { @@ -239,7 +247,14 @@ class RemoteTerminalSessionManager @Inject constructor( override fun onTitleChanged(changedSession: TerminalSession) {} override fun onSessionFinished(finishedSession: TerminalSession) { _tabs.value.firstOrNull { it.session === finishedSession }?.let { target -> + // 断线导致的结束:远程 shell 没有有意义的退出码,只看 Termux 的 + // 「[Process completed]」分不出是用户敲了 exit 还是网断了,得看 backend 的标记。 + val dropped = backends[target.id]?.closedByDisconnect == true target.runState = RunState.Finished(0) + if (dropped) { + target.droppedByDisconnect = true + FileLogger.w(TAG, "远程 shell 因连接断开而结束: ${target.id}") + } bumpRevision() if (target.notifyOnExit) { _tabFinishedEvents.tryEmit( diff --git a/app/src/main/java/com/aicode/feature/terminal/domain/SshShellBackend.kt b/app/src/main/java/com/aicode/feature/terminal/domain/SshShellBackend.kt index 74fb08ce..de859fd8 100644 --- a/app/src/main/java/com/aicode/feature/terminal/domain/SshShellBackend.kt +++ b/app/src/main/java/com/aicode/feature/terminal/domain/SshShellBackend.kt @@ -20,15 +20,31 @@ private const val TAG = "SshShellBackend" * here. [waitForExit] blocks on [Session.Shell.join]; remote shells have no meaningful * exit status, so 0 is returned (the emulator appends its own "[Process completed]" notice * via [com.termux.terminal.TerminalSession] once the reader thread hits EOF). + * + * 因为返回值永远是 0,「断网导致的结束」与「用户敲 exit」在 UI 上长得一模一样;[closedByDisconnect] + * 把前者单独标出来,供 [RemoteTerminalSessionManager] 打「已断开」。返回值语义([SessionBackend] + * 的约定,本地终端共用)不动。 */ class SshShellBackend( - private val shell: Session.Shell + private val shell: Session.Shell, + /** 连接是否仍然活着;由 [RemoteTerminalSessionManager] 接 RemoteSshConnection.isConnected。 */ + private val isConnectionAlive: () -> Boolean = { true } ) : SessionBackend { // changeWindowDimensions 走网络 I/O,resize 会被 TerminalView 在主线程触发,需切到后台线程 private val resizeExecutor = Executors.newSingleThreadExecutor { r -> Thread(r, "SshShellResize") } @Volatile private var closed = false + /** + * shell 是在「连接已经不在了」的情况下结束的(断网/服务器重启),而不是用户敲了 exit。 + * + * 在 [waitForExit] 里置位:Termux 的 TermSessionWaiter 线程先拿到它的返回值、再经主线程 + * handler 回调 onSessionFinished,所以标记一定早于回调被读到(@Volatile 保证可见性)。 + */ + @Volatile + var closedByDisconnect: Boolean = false + private set + override fun getInputStream(): InputStream = shell.inputStream override fun getOutputStream(): OutputStream = shell.outputStream @@ -42,13 +58,13 @@ class SshShellBackend( } override fun waitForExit(): Int { - return runCatching { - shell.join() - 0 - }.getOrElse { - FileLogger.w(TAG, "shell.join 异常", it) - 0 + val joined = runCatching { shell.join() } + // join 正常返回 = channel 正常结束;抛异常,或结束时连接已经不在了,都算异常结束(断线) + if (joined.isFailure || !isConnectionAlive()) { + joined.exceptionOrNull()?.let { FileLogger.w(TAG, "shell.join 异常", it) } + closedByDisconnect = true } + return 0 } override fun close() { diff --git a/app/src/main/java/com/aicode/feature/terminal/domain/TerminalTab.kt b/app/src/main/java/com/aicode/feature/terminal/domain/TerminalTab.kt index 8bc9b7ed..26ebec5c 100644 --- a/app/src/main/java/com/aicode/feature/terminal/domain/TerminalTab.kt +++ b/app/src/main/java/com/aicode/feature/terminal/domain/TerminalTab.kt @@ -68,6 +68,13 @@ class TerminalTab( */ @Volatile var finishedNotified: Boolean = false + + /** + * 远程会话是否是因连接断开而结束(见 [SshShellBackend.closedByDisconnect]);本地终端恒 false。 + * 断线结束时 Termux 只会打一句「[Process completed]」,不标出来会被当成正常退出。 + */ + var droppedByDisconnect: Boolean = false + internal set } /** diff --git a/app/src/main/java/com/aicode/feature/terminal/presentation/component/TerminalScreen.kt b/app/src/main/java/com/aicode/feature/terminal/presentation/component/TerminalScreen.kt index d942a030..d7620d1b 100644 --- a/app/src/main/java/com/aicode/feature/terminal/presentation/component/TerminalScreen.kt +++ b/app/src/main/java/com/aicode/feature/terminal/presentation/component/TerminalScreen.kt @@ -161,8 +161,14 @@ fun TerminalScreen( onNew = { viewModel.newTab() } ) + val activeTab = tabs.firstOrNull { it.id == activeTabId } + // 因连接断开而结束的会话:不单独标出来,用户看到的只是 Termux 那句「[Process completed]」 + if (activeTab?.droppedByDisconnect == true) { + ConnectionDroppedBanner() + } + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { - val active = tabs.firstOrNull { it.id == activeTabId } + val active = activeTab if (active == null) { StatusView( loading = creatingTab, @@ -622,3 +628,31 @@ private fun KeyChip( /** 单字符按键给固定宽度,多字符按键自适应。 */ private fun Modifier.widthChip(label: String): Modifier = if (label.length <= 1) this.width(36.dp) else this + +/** + * 连接断开导致会话结束的横幅。 + * + * 远程 shell 的退出码无意义(SshShellBackend.waitForExit 恒返 0),断网后 Termux 只会打一句 + * 「[Process completed]」——与用户敲 exit 长得一样,不提示会让人以为终端好好的。 + */ +@Composable +private fun ConnectionDroppedBanner() { + Surface(color = MaterialTheme.colorScheme.errorContainer) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.md, vertical = Spacing.sm) + ) { + Text( + text = stringResource(R.string.terminal_connection_dropped), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer + ) + Text( + text = stringResource(R.string.terminal_connection_dropped_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } +} diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index e84c9fda..1dbe31ce 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -1353,6 +1353,8 @@ Cannot start terminal:\n%1$s Retry No open terminal tabs + Connection lost, this terminal has ended + This was not a normal exit: open a new tab to retry, or reopen after automatic reconnect Close Tab Cleaning up old container data…\n%1$s items removed Extracting environment…\n%1$s files processed diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 897e589d..9548c466 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1352,6 +1352,8 @@ 无法启动终端:\n%1$s 重试 没有打开的终端标签 + 连接已断开,此终端已结束 + 这不是正常退出:可新建标签重试,或等待自动重连后重新打开 关闭标签 正在清理旧容器数据…\n已清理 %1$s 项 正在解压环境…\n已处理 %1$s 个文件 From 8137a79ba19c8d73f03ecf370f78d642c0f8ec9b Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:51:50 +0800 Subject: [PATCH 03/10] =?UTF-8?q?fix(shizuku):=20=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E7=94=A8=E5=AE=98=E6=96=B9=E5=8C=85=E5=90=8D=E5=88=A4=E3=80=8C?= =?UTF-8?q?=E8=A3=85=E6=B2=A1=E8=A3=85=E3=80=8D=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=20Stellar=20/=20Sui=20=E7=AD=89=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户用的是 Stellar(包名 roro.stellar.manager)→ 界面显示「未安装」、点了跳下载页, AI 也用不了 Shizuku。真因在状态机第一关: computeState(): if (!isShizukuInstalled()) return NOT_INSTALLED isShizukuInstalled(): getPackageInfo("moe.shizuku.privileged.api", 0) 而授权判定本身走 binder(Shizuku.checkSelfPermission),与包名无关;库层也不校验 管理器包名(api-13.1.5.aar 里该字符串零命中,ShizukuProvider.handleSendBinder 只判 pingBinder 后直接 onBinderReceived)—— binder 其实早就被 Stellar 推进来了,只是 代码从没走到 pingBinder()。Sui 是 Magisk 模块、没有独立管理器包,同样被这一关判死。 A1:computeState() 改成以 binder 为准(pingBinder → isPreV11 → checkSelfPermission), 全程 runCatching,异常降为 NOT_RUNNING;删掉 isShizukuInstalled();_state 初值 改 NOT_RUNNING。NOT_INSTALLED 常量保留(ShizukuTool / AppPermissionsSection 的 when 是表达式,删了跨文件编不过),只是不再产出。 A2:openShizukuApp() 改为 官方 → Stellar → 按 provider authority 精确匹配扫描 (${pkg}.shizuku / ${pkg}.stellar,排除自身)→ 兜底下载页。 B2:新增只读 peerInfo()(uid/version/SELinux,区分 adb 2000 与 root 0),UI 半边待接。 两条信任锚未动:provider 的 INTERACT_ACROSS_USERS_FULL(只有 shell/root 能推 binder) 与 READY 只由 checkSelfPermission 决定 —— 放宽包名检测不降低授权强度。 明确不做:客户端主动拉 binder(会让抢注 authority 的应用拿到命令原文)、 QUERY_ALL_PACKAGES、引入 Stellar 原生 API。 --- .../agent/domain/shizuku/ShizukuManager.kt | 134 +++++++++++++++--- 1 file changed, 114 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/aicode/feature/agent/domain/shizuku/ShizukuManager.kt b/app/src/main/java/com/aicode/feature/agent/domain/shizuku/ShizukuManager.kt index ea44ecd0..cd412a64 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/shizuku/ShizukuManager.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/shizuku/ShizukuManager.kt @@ -26,10 +26,16 @@ import javax.inject.Singleton /** Shizuku 可用状态,供设置页展示与工具执行前判定。 */ enum class ShizukuState { - /** 未安装 Shizuku(或 Sui)。 */ + /** + * 未安装管理器。 + * + * 状态判定改为以 binder 为准后,[ShizukuManager] 不再产出该状态:管理器包名不在官方契约内 + * (Sui 是 Magisk 模块、没有独立管理器包;Stellar 等兼容层用各自的包名),以包名判「装没装」 + * 必然误判。保留该常量只为让既有 UI 与工具的分支穷举继续编译。 + */ NOT_INSTALLED, - /** 已安装但服务未运行,需用户先在 Shizuku 内启动服务。 */ + /** binder 未就绪:服务未启动或已停止,也可能设备上根本没有可用的管理器。 */ NOT_RUNNING, /** 服务运行中,但本应用尚未获得授权。 */ @@ -42,6 +48,19 @@ enum class ShizukuState { /** 一次 Shizuku 命令执行结果。[exitCode] 为负值表示超时或启动异常。 */ data class ShizukuCommandResult(val output: String, val exitCode: Int) +/** + * Shizuku 服务端的只读身份信息,供设置页展示「当前连的是谁」。 + * + * [version] 是服务端 API 版本,服务端未应答时为 -1;[seLinuxContext] 拿不到时为 null。 + */ +data class ShizukuPeerInfo( + val uid: Int, + val version: Int, + val seLinuxContext: String?, + val isAdb: Boolean, + val isRoot: Boolean +) + /** * Shizuku 后端:以 adb shell(uid 2000)身份执行命令。 * @@ -57,9 +76,25 @@ class ShizukuManager @Inject constructor( private companion object { const val TAG = "ShizukuManager" - /** Shizuku 官方应用包名(Sui 也用它作为入口)。 */ + /** Shizuku 官方应用包名。 */ const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api" + /** Stellar:兼容 Shizuku 的管理器,包名与官方不同,binder 协议一致。 */ + const val STELLAR_PACKAGE = "roro.stellar.manager" + + /** 已知管理器包名,按尝试顺序排列;都没命中时退回 provider authority 扫描。 */ + val KNOWN_MANAGER_PACKAGES = listOf(SHIZUKU_PACKAGE, STELLAR_PACKAGE) + + /** 管理器 provider authority 的命名后缀约定:官方用 `.shizuku`,Stellar 用 `.stellar`。 */ + const val SHIZUKU_PROVIDER_SUFFIX = ".shizuku" + const val STELLAR_PROVIDER_SUFFIX = ".stellar" + + /** adb shell 身份对应的 Linux uid。 */ + const val UID_SHELL = 2000 + + /** root 身份对应的 Linux uid。 */ + const val UID_ROOT = 0 + const val PERMISSION_REQUEST_CODE = 1001 /** 服务身份 tag:不设时用类名,类名经 R8 混淆后不稳定,故显式固定。 */ @@ -74,7 +109,7 @@ class ShizukuManager @Inject constructor( const val DOWNLOAD_URL = "https://shizuku.rikka.app/download/" } - private val _state = MutableStateFlow(ShizukuState.NOT_INSTALLED) + private val _state = MutableStateFlow(ShizukuState.NOT_RUNNING) val state: StateFlow = _state.asStateFlow() private val bindMutex = Mutex() @@ -157,22 +192,45 @@ class ShizukuManager @Inject constructor( _state.value = computeState() } - private fun computeState(): ShizukuState { - if (!isShizukuInstalled()) return ShizukuState.NOT_INSTALLED - if (!runCatching { Shizuku.pingBinder() }.getOrDefault(false)) return ShizukuState.NOT_RUNNING - // pre-v11 无 UserService,等同不可用。 - if (runCatching { Shizuku.isPreV11() }.getOrDefault(false)) return ShizukuState.NOT_RUNNING - val granted = runCatching { - Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED - }.getOrDefault(false) - return if (granted) ShizukuState.READY else ShizukuState.PERMISSION_DENIED + /** + * 按 binder 存活与授权结果推导状态。 + * + * 不以管理器包名判「装没装」:官方契约里 binder 由服务端推来(客户端 provider 只收不拉), + * 与管理器叫什么包名无关,而 Sui 与 Stellar 也都不使用官方包名。 + * 任一 binder 调用抛异常(服务恰好在探测途中断开)都按未运行处理,状态机不崩。 + */ + private fun computeState(): ShizukuState = runCatching { + when { + !Shizuku.pingBinder() -> ShizukuState.NOT_RUNNING + // pre-v11 无 UserService,等同不可用。 + Shizuku.isPreV11() -> ShizukuState.NOT_RUNNING + // READY 只由服务端按 uid 校验的授权结果决定,本地判断不参与。 + Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED -> ShizukuState.READY + else -> ShizukuState.PERMISSION_DENIED + } + }.getOrDefault(ShizukuState.NOT_RUNNING) + + /** + * 读取当前服务端的只读身份信息(adb / root、API 版本、SELinux 上下文)。 + * + * 纯查询:不触发授权、不改状态。[state] 不是 [ShizukuState.READY] 时直接返回 null, + * 其余异常(binder 中途断开等)也一并降级为 null。 + */ + fun peerInfo(): ShizukuPeerInfo? { + if (_state.value != ShizukuState.READY) return null + return runCatching { + val uid = Shizuku.getUid() + ShizukuPeerInfo( + uid = uid, + version = Shizuku.getVersion(), + seLinuxContext = Shizuku.getSELinuxContext(), + isAdb = uid == UID_SHELL, + isRoot = uid == UID_ROOT + ) + }.onFailure { FileLogger.w(TAG, "读取 Shizuku 服务端信息失败: ${it.message}") } + .getOrNull() } - private fun isShizukuInstalled(): Boolean = runCatching { - context.packageManager.getPackageInfo(SHIZUKU_PACKAGE, 0) - true - }.getOrDefault(false) - /** 申请 Shizuku 授权。需在主线程调用(Shizuku 内部要求)。 */ fun requestPermission() { if (computeState() != ShizukuState.PERMISSION_DENIED) return @@ -180,9 +238,9 @@ class ShizukuManager @Inject constructor( .onFailure { FileLogger.w(TAG, "申请 Shizuku 授权失败: ${it.message}") } } - /** 打开 Shizuku 应用;未安装则跳转官方下载页。 */ + /** 打开管理器应用;一个都找不到时跳转官方下载页。 */ fun openShizukuApp() { - val launch = context.packageManager.getLaunchIntentForPackage(SHIZUKU_PACKAGE) + val launch = findManagerLaunchIntent() runCatching { if (launch != null) { launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) @@ -196,6 +254,42 @@ class ShizukuManager @Inject constructor( }.onFailure { FileLogger.w(TAG, "打开 Shizuku 失败: ${it.message}") } } + /** + * 找一个可启动的管理器:先按已知包名(官方、Stellar),再按 provider authority 命名约定扫描。 + * + * 扫描只认「包自身声明了 authority 等于 `<包名>.shizuku` 或 `<包名>.stellar`」这一条, + * 且排除本应用自己(自身就声明 `.shizuku`)。authority 必须精确相等, + * 不做前缀或包含匹配,否则抢注相似 authority 的应用能被当成管理器打开。 + */ + private fun findManagerLaunchIntent(): Intent? { + for (pkg in KNOWN_MANAGER_PACKAGES) { + launchIntentFor(pkg)?.let { return it } + } + val discovered = runCatching { findManagerByProviderAuthority() } + .onFailure { FileLogger.w(TAG, "扫描 Shizuku 管理器包失败: ${it.message}") } + .getOrNull() + return discovered?.let { launchIntentFor(it) } + } + + /** 取包名的启动入口;未安装或不可见时返回 null。 */ + private fun launchIntentFor(pkg: String): Intent? = + runCatching { context.packageManager.getLaunchIntentForPackage(pkg) }.getOrNull() + + @Suppress("DEPRECATION") + private fun findManagerByProviderAuthority(): String? { + val self = context.packageName + for (info in context.packageManager.getInstalledPackages(PackageManager.GET_PROVIDERS)) { + val pkg = info.packageName + if (pkg == self) continue + val matched = info.providers.orEmpty().any { provider -> + val authority = provider.authority ?: return@any false + authority == pkg + SHIZUKU_PROVIDER_SUFFIX || authority == pkg + STELLAR_PROVIDER_SUFFIX + } + if (matched) return pkg + } + return null + } + /** 执行 shell 命令。未就绪或绑定失败时抛异常,由调用方转成工具错误。 */ suspend fun runCommand(command: String, timeoutMs: Long): ShizukuCommandResult { val service = ensureBound() From 0f4d3826ac262c33ad25b82eb09d4a8dd4ffc480 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:09:47 +0800 Subject: [PATCH 04/10] =?UTF-8?q?fix(settings):=20Shizuku=20=E6=9C=AA?= =?UTF-8?q?=E8=A3=85=E8=AE=BE=E5=A4=87=E7=9A=84=E6=96=87=E6=A1=88=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=AE=A9=E4=BA=BA=E5=8E=BB=E5=BC=80=E4=B8=8D=E5=AD=98?= =?UTF-8?q?=E5=9C=A8=E7=9A=84=E5=BA=94=E7=94=A8=EF=BC=8C=E5=B9=B6=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E5=BD=93=E5=89=8D=E8=BF=9E=E6=8E=A5=E8=BA=AB=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:状态判定改成以 binder 为准之后(管理器包名不在官方契约内,按包名判「装没装」 必然误判),NOT_RUNNING 既表示「服务没启动」也表示「设备上根本没有管理器」,但文案还写着 「点击打开 Shizuku 并启动服务」——没装 Shizuku/Stellar 的设备上,那个 App 根本不存在, 用户只能照着做一件做不到的事(点击按钮其实会跳下载页,文案没提)。 改动: 1. 状态文案与状态初值自洽:删掉不再产出的 ShizukuState.NOT_INSTALLED 及其两条死文案 (中英各一条),NOT_RUNNING 的状态改成「未就绪」、副标题改成 「点击打开 Shizuku 并启动服务;未安装会跳转下载页」(复用原有 key,只改内容), 工具侧的 stateHint 同步(原来也分成「未安装 / 服务未运行」两句,现在一句讲清两种情况)。 2. 顺手接上零调用的 ShizukuManager.peerInfo():设置 → 软件权限区在就绪时多一行「当前连接」, 写明当前是以 adb shell(uid 2000)还是 root(uid 0)身份连上的——两者能做的事不一样 (root 才读得到其它应用的私有目录),只写「已就绪」看不出这点。新增四组文案 (标题 + adb/root/其它 uid),中英键集一致(1481 / 1481)。 身份只能从服务端读,所以走 state 派生 + flowOn(IO),未就绪时不问 binder。 行为变化: - 未装管理器的设备:状态文字由「服务未运行」变为「未就绪」,提示里多了「未安装会跳转下载页」; - 就绪时设置页多出一行「当前连接」(原来没有这一行); - ShizukuState 少一个常量,枚举的 when 全套改为三档;Shizuku 工具未就绪的报错文案同步改口径。 --- .../agent/domain/shizuku/ShizukuManager.kt | 12 ++++----- .../agent/domain/tool/shizuku/ShizukuTool.kt | 3 +-- .../settings/presentation/ShizukuViewModel.kt | 18 +++++++++++++ .../component/AppPermissionsSection.kt | 27 ++++++++++++++++--- .../presentation/component/SettingsScreen.kt | 2 ++ app/src/main/res/values-en/strings.xml | 10 ++++--- app/src/main/res/values/strings.xml | 10 ++++--- docs-site/docs/guide/app-permissions.md | 4 +-- docs-site/docs/guide/shizuku.md | 7 ++--- 9 files changed, 67 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/aicode/feature/agent/domain/shizuku/ShizukuManager.kt b/app/src/main/java/com/aicode/feature/agent/domain/shizuku/ShizukuManager.kt index cd412a64..46d304ad 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/shizuku/ShizukuManager.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/shizuku/ShizukuManager.kt @@ -27,15 +27,13 @@ import javax.inject.Singleton /** Shizuku 可用状态,供设置页展示与工具执行前判定。 */ enum class ShizukuState { /** - * 未安装管理器。 + * binder 未就绪:服务未启动或已停止,也可能设备上根本没有可用的管理器。 * - * 状态判定改为以 binder 为准后,[ShizukuManager] 不再产出该状态:管理器包名不在官方契约内 - * (Sui 是 Magisk 模块、没有独立管理器包;Stellar 等兼容层用各自的包名),以包名判「装没装」 - * 必然误判。保留该常量只为让既有 UI 与工具的分支穷举继续编译。 + * 以包名判「装没装」必然误判(管理器包名不在官方契约内:Sui 是 Magisk 模块、没有独立管理器包, + * Stellar 等兼容层用各自的包名),所以状态判定只看 binder; + * 「装没装」不进状态机,而由 UI 文案兼顾(未就绪时的提示同时交代「打开管理器」与「没装就去下载」, + * 点击后 [ShizukuManager.openShizukuApp] 找不到管理器就跳下载页)。 */ - NOT_INSTALLED, - - /** binder 未就绪:服务未启动或已停止,也可能设备上根本没有可用的管理器。 */ NOT_RUNNING, /** 服务运行中,但本应用尚未获得授权。 */ diff --git a/app/src/main/java/com/aicode/feature/agent/domain/tool/shizuku/ShizukuTool.kt b/app/src/main/java/com/aicode/feature/agent/domain/tool/shizuku/ShizukuTool.kt index a8c6c3af..23e2a093 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/tool/shizuku/ShizukuTool.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/tool/shizuku/ShizukuTool.kt @@ -109,8 +109,7 @@ class ShizukuTool @Inject constructor( } private fun stateHint(state: ShizukuState): String = when (state) { - ShizukuState.NOT_INSTALLED -> "未安装 Shizuku,请先安装并启动 Shizuku 服务" - ShizukuState.NOT_RUNNING -> "Shizuku 服务未运行,请在 Shizuku 应用中启动服务" + ShizukuState.NOT_RUNNING -> "Shizuku 未就绪:未安装或服务未启动,请在「设置 → 软件权限 → Shizuku」处理" ShizukuState.PERMISSION_DENIED -> "本应用尚未获得 Shizuku 授权,请在设置中授予" ShizukuState.READY -> "" } diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/ShizukuViewModel.kt b/app/src/main/java/com/aicode/feature/settings/presentation/ShizukuViewModel.kt index c1456796..ca39e698 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/ShizukuViewModel.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/ShizukuViewModel.kt @@ -1,10 +1,17 @@ package com.aicode.feature.settings.presentation import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.aicode.feature.agent.domain.shizuku.ShizukuManager +import com.aicode.feature.agent.domain.shizuku.ShizukuPeerInfo import com.aicode.feature.agent.domain.shizuku.ShizukuState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import javax.inject.Inject /** Shizuku 执行后端的设置页状态与操作入口。 */ @@ -15,6 +22,17 @@ class ShizukuViewModel @Inject constructor( val state: StateFlow = shizukuManager.state + /** + * 当前连到的服务端身份(adb shell / root),供设置页展示「当前连的是谁」。 + * + * 身份只能从服务端读,未就绪时 [ShizukuManager.peerInfo] 直接返回 null,所以这条流只跟着 + * [state] 变化重读——就绪且身份不变时不重复问 binder。读的是 binder 调用,放 IO 上做。 + */ + val peerInfo: StateFlow = shizukuManager.state + .map { shizukuManager.peerInfo() } + .flowOn(Dispatchers.IO) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + /** 重新探测状态(如从 Shizuku 应用返回后)。 */ fun refresh() = shizukuManager.refreshState() diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/AppPermissionsSection.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/AppPermissionsSection.kt index a169227a..f0812d64 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/AppPermissionsSection.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/AppPermissionsSection.kt @@ -33,6 +33,7 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.compose.LifecycleResumeEffect import com.aicode.R import com.aicode.core.theme.Spacing +import com.aicode.feature.agent.domain.shizuku.ShizukuPeerInfo import com.aicode.feature.agent.domain.shizuku.ShizukuState import compose.icons.FeatherIcons import compose.icons.feathericons.Bell @@ -42,6 +43,7 @@ import compose.icons.feathericons.Power import compose.icons.feathericons.RefreshCw import compose.icons.feathericons.Sun import compose.icons.feathericons.Terminal +import compose.icons.feathericons.User import compose.icons.feathericons.Zap /** @@ -49,12 +51,13 @@ import compose.icons.feathericons.Zap * - 安装未知应用:展示授权状态,未授权点击跳转系统设置开启。 * - 访问存储空间:展示授权状态,未授权点击申请运行时权限;已被永久拒绝时同样引导去系统设置。 * - 忽略电池优化 / 自启动管理:跳转系统设置。 - * - Shizuku:展示 adb shell 授权状态,未就绪时点击安装/启动/申请授权。 + * - Shizuku:展示 adb shell 授权状态与当前连到的服务端身份,未就绪时点击安装/启动/申请授权。 * 页面恢复(含从系统设置页或 Shizuku 应用返回)时刷新各权限状态。 */ @Composable internal fun AppPermissionsSection( shizukuState: ShizukuState, + shizukuPeer: ShizukuPeerInfo?, onRequestShizukuPermission: () -> Unit, onOpenShizuku: () -> Unit, onRefreshShizuku: () -> Unit @@ -160,7 +163,7 @@ internal fun AppPermissionsSection( ) SettingsDivider() val shizukuAction: (() -> Unit)? = when (shizukuState) { - ShizukuState.NOT_INSTALLED, ShizukuState.NOT_RUNNING -> onOpenShizuku + ShizukuState.NOT_RUNNING -> onOpenShizuku ShizukuState.PERMISSION_DENIED -> onRequestShizukuPermission ShizukuState.READY -> null } @@ -177,6 +180,16 @@ internal fun AppPermissionsSection( ) } ) + // 就绪后多给一行「当前连的是谁」:adb shell 与 root 能做的事不一样 + //(root 才读得到其它应用的私有目录),只写「已就绪」看不出这点。 + shizukuPeer?.let { peer -> + SettingsDivider() + SettingsRow( + icon = FeatherIcons.User, + title = stringResource(R.string.settings_shizuku_peer_title), + subtitle = shizukuPeerLabel(peer) + ) + } } } @@ -296,7 +309,6 @@ internal fun BackgroundRunSection( /** Shizuku 状态对应的右侧状态文字。 */ @StringRes private fun ShizukuState.statusRes(): Int = when (this) { - ShizukuState.NOT_INSTALLED -> R.string.settings_shizuku_status_not_installed ShizukuState.NOT_RUNNING -> R.string.settings_shizuku_status_not_running ShizukuState.PERMISSION_DENIED -> R.string.settings_shizuku_status_denied ShizukuState.READY -> R.string.settings_shizuku_status_ready @@ -305,12 +317,19 @@ private fun ShizukuState.statusRes(): Int = when (this) { /** Shizuku 状态对应的副标题(点击提示)。 */ @StringRes private fun ShizukuState.hintRes(): Int = when (this) { - ShizukuState.NOT_INSTALLED -> R.string.settings_shizuku_hint_not_installed ShizukuState.NOT_RUNNING -> R.string.settings_shizuku_hint_not_running ShizukuState.PERMISSION_DENIED -> R.string.settings_shizuku_hint_denied ShizukuState.READY -> R.string.settings_shizuku_hint_ready } +/** 服务端身份的展示文字:adb shell / root 各自一档,其余按 uid 如实写。 */ +@Composable +private fun shizukuPeerLabel(peer: ShizukuPeerInfo): String = when { + peer.isAdb -> stringResource(R.string.settings_shizuku_peer_adb) + peer.isRoot -> stringResource(R.string.settings_shizuku_peer_root) + else -> stringResource(R.string.settings_shizuku_peer_other, peer.uid) +} + /** 权限无法通过系统弹框授予时,提醒用户去系统设置手动开启。 */ @Composable private fun PermissionDeniedDialog( diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt index eab2fabe..50865fa3 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt @@ -1046,8 +1046,10 @@ fun SettingsScreen( val shizukuViewModel: ShizukuViewModel = androidx.hilt.navigation.compose.hiltViewModel() val shizukuState by shizukuViewModel.state.collectAsStateWithLifecycle() + val shizukuPeer by shizukuViewModel.peerInfo.collectAsStateWithLifecycle() AppPermissionsSection( shizukuState = shizukuState, + shizukuPeer = shizukuPeer, onRequestShizukuPermission = { shizukuViewModel.requestPermission() }, onOpenShizuku = { shizukuViewModel.openShizukuApp() }, onRefreshShizuku = { shizukuViewModel.refresh() } diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 1dbe31ce..a3679592 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -60,14 +60,16 @@ Tool Permissions Connections & Sync Shizuku - Not installed - Service not running + Not ready Not authorized Ready - Tap to download and install Shizuku - Tap to open Shizuku and start the service + Tap to open Shizuku and start the service; opens the download page if it is not installed Tap to request Shizuku permission Authorized — AI can run commands via adb shell + Connected as + adb shell (uid 2000) + root (uid 0) + uid %1$d Backup & Restore About Language diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9548c466..77ffbbe1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -60,14 +60,16 @@ 工具授权 连接与同步 Shizuku - 未安装 - 服务未运行 + 未就绪 未授权 已就绪 - 点击前往下载安装 Shizuku - 点击打开 Shizuku 并启动服务 + 点击打开 Shizuku 并启动服务;未安装会跳转下载页 点击申请 Shizuku 授权 已获授权,AI 可通过 adb shell 执行命令 + 当前连接 + adb shell(uid 2000) + root(uid 0) + uid %1$d 备份与还原 关于 语言 diff --git a/docs-site/docs/guide/app-permissions.md b/docs-site/docs/guide/app-permissions.md index 97ddcfc6..ba15ee8e 100644 --- a/docs-site/docs/guide/app-permissions.md +++ b/docs-site/docs/guide/app-permissions.md @@ -28,8 +28,8 @@ Shizuku 让本应用以 adb shell(uid 2000)身份执行系统命令,是 AI 的 `Shizuku` 工具的前提。这一行显示当前状态: -- **未安装 / 服务未运行**:点击打开 Shizuku 应用,按其中的指引安装或启动服务。 +- **未就绪**:点击打开 Shizuku 应用启动服务;设备上没装管理器时,点击会跳到官方下载页。 - **未授权**:点击申请授权,在 Shizuku 弹出的授权框里选「允许」。 -- **已就绪**:授权完成,AI 即可调用 `Shizuku` 工具。 +- **已就绪**:授权完成,AI 即可调用 `Shizuku` 工具;下面多一行「当前连接」,写明当前是以 adb shell(uid 2000)还是 root(uid 0)身份连上的。 从 Shizuku 应用返回本页时状态会自动刷新。用法与安全注意事项见 [Shizuku 工具](/guide/shizuku)。 diff --git a/docs-site/docs/guide/shizuku.md b/docs-site/docs/guide/shizuku.md index c999e8a2..a9797555 100644 --- a/docs-site/docs/guide/shizuku.md +++ b/docs-site/docs/guide/shizuku.md @@ -15,10 +15,11 @@ Shizuku 是 shell 身份,**无法读取其他应用的私有数据目录**( 打开「设置 → 软件权限 → Shizuku」,页面显示当前状态: -- **未安装**:点击前往下载安装。 -- **服务未运行**:点击打开 Shizuku 并启动服务。 +- **未就绪**:点击打开 Shizuku 并启动服务;设备上没装管理器时,点击会直接跳到官方下载页。 - **未授权**:点击申请授权,Shizuku 弹出授权框,选择「允许」。 -- **已就绪**:授权完成,AI 即可使用 `Shizuku` 工具。 +- **已就绪**:授权完成,AI 即可使用 `Shizuku` 工具;此时下面多一行「当前连接」,写明当前是以 adb shell(uid 2000)还是 root(uid 0)身份连上的。 + +「未安装 / 服务没启动」在状态上是一回事(都只看 binder 通不通):管理器包名不在 Shizuku 的官方契约内(Sui 是 Magisk 模块、没有独立管理器包,Stellar 等兼容层用各自的包名),按包名判「装没装」必然误判。 从 Shizuku 应用返回 AiCode 时,状态会自动刷新。 From e92ad443c3daa63ebd53cfe7be7d186e937217b5 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:43:50 +0800 Subject: [PATCH 05/10] =?UTF-8?q?fix(skills):=20=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=BA=E8=90=BD=E5=AE=9A/=E5=88=87=E6=8D=A2=E5=90=8E?= =?UTF-8?q?=E9=87=8D=E6=89=AB=E6=8A=80=E8=83=BD=E4=B8=8E=E5=AD=90=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 远程模式下「当前项目」技能组一直空着,真因不是来源写死本地(列/读/写/删/导入 本来就经 DelegatingFileAccess 按 ExecutionModeHolder 转发),而是缺一个刷新触发点: 建连与工作区加载是两条异步链,connectionState=CONNECTED 时工作区可能还没落定, 此时 currentPath() 回退到「工作区根目录」而非某个工作区(WorkspaceRepository.kt:501-503), ProjectDirectorySkillSource 扫的路径少一层 <工作区名>,必然空列表;工作区随后落定 或用户切换工作区,都不再触发重扫,页面就停在空/旧内容上。 补上 workspaceRepository.current 的收集器(与既有 executionMode / connectionState 两处同层),工作区一变就 refreshSkills() + refreshSubAgents()。顺带修掉本地模式下 「切工作区后项目技能不更新」的同类陈旧问题。 技能来源根路径、仓库读写、UI 结构、文案分组均未改动 —— 它们本来就正确。 --- .../settings/presentation/SettingsViewModel.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/SettingsViewModel.kt b/app/src/main/java/com/aicode/feature/settings/presentation/SettingsViewModel.kt index 9e62b829..d0c12cd5 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/SettingsViewModel.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/SettingsViewModel.kt @@ -75,6 +75,7 @@ import com.aicode.feature.settings.data.repository.ToolSafetySettingsRepository import com.aicode.feature.settings.data.repository.BackgroundSettingsRepository import com.aicode.feature.settings.data.repository.ImageGenModelSettingsRepository import com.aicode.feature.settings.data.repository.VisionModelSettingsRepository +import com.aicode.feature.workspace.data.repository.WorkspaceRepository import com.aicode.feature.workspace.domain.model.RemoteConnection import com.aicode.feature.workspace.domain.repository.RemoteRepository import com.aicode.feature.settings.domain.model.AIProviderConfig @@ -330,6 +331,7 @@ class SettingsViewModel @Inject constructor( private val containerOsDetector: ContainerOsDetector, private val executionModeRepository: ExecutionModeRepository, private val executionModeHolder: ExecutionModeHolder, + private val workspaceRepository: WorkspaceRepository, private val remoteSshConnection: RemoteSshConnection, private val remoteRepository: RemoteRepository, private val llmCallRecordDao: LlmCallRecordDao, @@ -955,6 +957,17 @@ class SettingsViewModel @Inject constructor( } } + // 项目级技能/子代理的目录由当前工作区解析(远程模式下是服务器上被选中的工作区)。 + // 建连与工作区加载是两条异步链:连接就绪时工作区可能还没落定,此时 currentPath() 回退到 + // 工作区根目录,扫出来的是空列表;工作区随后落定(或用户切换工作区)不再触发任何重扫, + // 列表就停在旧内容/空列表上。故工作区一变就重扫。 + launch { + workspaceRepository.current.collectLatest { + refreshSkills() + refreshSubAgents() + } + } + launch { permissionRulesRepository.globalRulesFlow.collectLatest { _globalRules.value = it From a972a7d954663427f2fc291f0482cceb56f55a2d Mon Sep 17 00:00:00 2001 From: xiaojie <2772655946@qq.com> Date: Sat, 3 Oct 2026 07:06:59 +0800 Subject: [PATCH 06/10] =?UTF-8?q?fix(credentials):=20=E4=BF=AE=E5=A4=8D=20?= =?UTF-8?q?git=20=E5=87=AD=E6=8D=AE=E7=BC=96=E7=A0=81=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E8=AF=AF=E5=88=A4=E5=AF=BC=E8=87=B4=E5=87=AD=E6=8D=AE=E4=B8=A2?= =?UTF-8?q?=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit android.util.Base64 的 DEFAULT 解码是宽容的:非法字符按 SKIP 忽略、不抛异常,旧版明文凭据因此也能被 「成功解码」成乱码、被误判成本编码格式,凭据全部读空。改用往返校验判定格式:只有 encode(decode(raw)) == raw 才认定为编码格式,否则按明文处理(旧版明文仍需回退解析)。 --- .../data/repository/FileCredentialRepository.kt | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/aicode/feature/credentials/data/repository/FileCredentialRepository.kt b/app/src/main/java/com/aicode/feature/credentials/data/repository/FileCredentialRepository.kt index 6c5171c3..04f0e742 100644 --- a/app/src/main/java/com/aicode/feature/credentials/data/repository/FileCredentialRepository.kt +++ b/app/src/main/java/com/aicode/feature/credentials/data/repository/FileCredentialRepository.kt @@ -119,9 +119,19 @@ class FileCredentialRepository @Inject constructor( private fun encode(plain: String): String = Base64.encodeToString(plain.toByteArray(Charsets.UTF_8), Base64.NO_WRAP).reversed() - /** 解码编码串;不是本格式(如旧版明文)时返回 null。 */ + /** + * 解码编码串;不是本格式(如旧版明文)时返回 null。 + * + * android.util.Base64 的 DEFAULT 解码是「宽容」的:`:`、`/`、`@` 等非法字符一律按 SKIP 忽略、 + * 不抛异常,明文也能被「解码」成乱码。因此不能以「未抛异常」判定格式,改用往返校验:只有 + * `encode(decode(raw)) == raw` 才认定为编码格式,否则按明文处理(旧版明文需回退解析,否则凭据全部读空)。 + */ private fun tryDecode(text: String): String? = runCatching { - String(Base64.decode(text.reversed(), Base64.DEFAULT), Charsets.UTF_8) + val raw = text.trim() + if (raw.isEmpty()) return@runCatching null + val decoded = String(Base64.decode(raw.reversed(), Base64.DEFAULT), Charsets.UTF_8) + if (encode(decoded) != raw) return@runCatching null + decoded }.getOrNull() /** 把旧版明文凭据文件迁移为编码格式(幂等)。 */ From 405aedfa67ecfaf7e6ccb71fe1813c11bc889319 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:22:12 +0800 Subject: [PATCH 07/10] =?UTF-8?q?=E6=89=8B=E5=B7=A5=E9=80=82=E9=85=8D?= =?UTF-8?q?=EF=BC=9AcopyToLocal=20=E5=BC=82=E5=B8=B8=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=EF=BC=88=E6=91=98=E8=87=AA=E4=B8=8A=20fork=20=E7=9A=84=20c6b62?= =?UTF-8?q?ba1=EF=BC=8C=E4=BB=85=E6=AD=A4=E4=B8=80=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么只摘这一小块:fork 上的 c6b62ba1 是混合体(跨 19 文件、改 4 个 repository 签名、与上游 5 处冲突),整条不能提。但其中「RemoteSftpFileAccess.copyToLocal 把断线/通道异常也包成 NoSuchFileException」这一条是本地已提交的附件改动 (打开附件区分「文件不存在」与「这次取不到」)的前置:不先修数据侧的异常语义, 新增的 LocalFileResolution.Unavailable 分支在上游基线上永远走不到——远程路径上 任何失败都被说成「文件不存在」,用户会去删/重建工作区而不是重连。 改动逐行(只有两处,都在异常语义上,无行为以外的牵连): 1. RemoteSftpFileAccess.kt copyToLocal 的 catch 块末行 `throw NoSuchFileException(File(remote))` → `throw IOException(friendlySshError(e), e)` - 原有的 `if (e is NoSuchFileException) throw e` 保留:真「远端没有这个文件」 (statExistence 为 null)仍抛 NoSuchFileException,语义不变。 - 其余失败(断线、SFTP 通道异常、SSH 未连接)改为抛 IOException,文案取 friendlySshError——该方法与本文件的 withSftp 已经在用(同一文件既有用法), 没有引入新依赖,也没有新增 import。 - 注释两行说明「为什么不能一律报不存在」,与上游既有注释风格一致。 2. FileAccessProvider.kt copyToLocal 的 KDoc 补「异常契约」五行。 - 接口里 readFile/writeFile/rename/copy/move 都写了会抛什么,唯独 copyToLocal 没写,而调用方正是靠这段区分两类失败——不写清契约,调用侧的 `catch (NoSuchFileException) → Missing / catch (Exception) → Unavailable` 就是无据可依的假设。 - 文案按上游基线的真实行为写(本实现不检查存在性;远程实现 NoSuchFileException 与其它 IOException 分开),并删掉了 fork 版本里对 WorkspaceNotReadyException 的引用——那个类只存在于 fork,上游没有。只改注释,无行为变化。 明确不带:c6b62ba1 的 repository 签名改造、SettingsViewModel/SettingsScreen 的项目级 写入提示、ManageMcpTool 的 ToolResult.Error、FtpSyncClient/SftpSyncClient 的英文文案 中文化、friendlySshError 的中文透传分支,以及 fork 私有的一切内容。 --- .../aicode/feature/workspace/domain/FileAccessProvider.kt | 5 +++++ .../aicode/feature/workspace/domain/RemoteSftpFileAccess.kt | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/aicode/feature/workspace/domain/FileAccessProvider.kt b/app/src/main/java/com/aicode/feature/workspace/domain/FileAccessProvider.kt index f194d989..2ee2aab3 100644 --- a/app/src/main/java/com/aicode/feature/workspace/domain/FileAccessProvider.kt +++ b/app/src/main/java/com/aicode/feature/workspace/domain/FileAccessProvider.kt @@ -110,6 +110,11 @@ interface FileAccessProvider { * 本地模式直接返回映射后的宿主 [File](不复制); * 远程模式 SFTP 下载到缓存目录,返回本地临时文件。 * 供 [ViewImageTool](需本地路径喂 BitmapFactory)等使用。 + * + * 异常契约:本地实现不检查存在性——路径不存在时返回的 [File] 用 `isFile` 判定为 false,不抛异常; + * 远程实现对「远端确实没有这个文件」抛 [NoSuchFileException],对断线、通道异常、SSH 未连接等 + * 其它失败抛 [java.io.IOException]。两类失败语义不同(前者是文件没了、后者是这次拿不到), + * 调用方要分别处理——只接 [File] 而把异常一律当成文件不存在,就会把连接问题说成文件丢失。 */ fun copyToLocal(path: String): File diff --git a/app/src/main/java/com/aicode/feature/workspace/domain/RemoteSftpFileAccess.kt b/app/src/main/java/com/aicode/feature/workspace/domain/RemoteSftpFileAccess.kt index f3add557..5d49528d 100644 --- a/app/src/main/java/com/aicode/feature/workspace/domain/RemoteSftpFileAccess.kt +++ b/app/src/main/java/com/aicode/feature/workspace/domain/RemoteSftpFileAccess.kt @@ -249,9 +249,11 @@ class RemoteSftpFileAccess @Inject constructor( } catch (e: Exception) { // 用完即删:临时文件不依赖 deleteOnExit(只在进程退出清),失败路径也回收,避免长会话累积。 runCatching { tempFile.delete() } + // 只有真的是「不存在」才报不存在:断线/通道异常一律报连接类原因, + // 否则 AI 读图时看到的是「文件不存在」,而文件其实就在服务器上 if (e is NoSuchFileException) throw e FileLogger.e(TAG, "copyToLocal 失败: $remote", e) - throw NoSuchFileException(File(remote)) + throw IOException(friendlySshError(e), e) } } From b676cf248655272551efe9f90c3c92d03703125b Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:36:35 +0800 Subject: [PATCH 08/10] =?UTF-8?q?fix(agent):=20=E6=89=93=E5=BC=80=E9=99=84?= =?UTF-8?q?=E4=BB=B6=E5=8C=BA=E5=88=86=E3=80=8C=E6=96=87=E4=BB=B6=E4=B8=8D?= =?UTF-8?q?=E5=AD=98=E5=9C=A8=E3=80=8D=E4=B8=8E=E3=80=8C=E8=BF=99=E6=AC=A1?= =?UTF-8?q?=E5=8F=96=E4=B8=8D=E5=88=B0=E3=80=8D=EF=BC=8C=E6=96=AD=E7=BA=BF?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=AF=AF=E6=8A=A5=E6=96=87=E4=BB=B6=E4=B8=8D?= =?UTF-8?q?=E5=AD=98=E5=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 打开已发送附件时 resolveLocalFile 用 runCatching 吞掉异常、只回 File?,调用方一律提示「文件不存在或已被移动」——远程 SSH 断线、SFTP 通道异常、工作区尚未落定都被说成文件没了,用户会去删/重建工作区而不是重连。 - 新增 private sealed interface LocalFileResolution:Found / Missing / Unavailable - 本地模式 copyToLocal 对不存在的路径不抛异常(返回 isFile=false 的 File),仍归 Missing - 远程模式 NoSuchFileException → Missing;断线/通道异常/工作区未落定(IOException、WorkspaceNotReadyException)→ Unavailable - 行为变化:Unavailable 走新文案 chat_open_file_unavailable(中英同键集,values 与 values-en 各 1482 条) - 行为修复:原来连 CancellationException 一起吞,协程取消也会弹一次提示;现在取消照常向上传播 - Unavailable 记一条 FileLogger.w,日志里能看到真实失败原因 - 触发的场景不变:localPath 有效仍直用,containerPath 为空仍算 Missing --- .../component/MessageAttachmentComponents.kt | 64 ++++++++++++++++--- app/src/main/res/values-en/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/component/MessageAttachmentComponents.kt b/app/src/main/java/com/aicode/feature/agent/presentation/component/MessageAttachmentComponents.kt index e542c03d..bfa36825 100644 --- a/app/src/main/java/com/aicode/feature/agent/presentation/component/MessageAttachmentComponents.kt +++ b/app/src/main/java/com/aicode/feature/agent/presentation/component/MessageAttachmentComponents.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.unit.dp import androidx.core.content.FileProvider import com.aicode.R import com.aicode.core.theme.Brand +import com.aicode.core.util.FileLogger import com.aicode.core.theme.Radius import com.aicode.core.theme.Spacing import com.aicode.core.ui.LocalImageViewer @@ -48,6 +49,8 @@ import compose.icons.feathericons.ChevronRight import compose.icons.feathericons.FileText import compose.icons.feathericons.Image import java.io.File +import java.nio.file.NoSuchFileException +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -229,17 +232,26 @@ internal val LocalAttachmentOpener = staticCompositionLocalOf * 卡片记录的 [AgentAttachment.localPath] 不保证仍然有效:远程 SSH 模式下它是 `copyToLocal` 的临时副本 * (进程重启或系统清缓存后消失),外部本地目录工作区、自定义挂载源的文件又落在 FileProvider 声明的目录树之外。 * 因此失效时退回 [AgentAttachment.containerPath] 经 [fileAccess] 重取,重取到的路径仍无法授权时 - * 再复制进 cacheDir(已由 cache-path 覆盖)分享。文件确实不存在与最终无法分享分别提示,不再混用一句文案。 + * 再复制进 cacheDir(已由 cache-path 覆盖)分享。文件确实不存在、这次取不到(断线/未就绪)与最终 + * 无法分享分别提示,不混用一句文案:把可重试的连接问题说成「文件不存在」,用户会去删工作区而不是重连。 */ internal suspend fun openSentAttachment( context: Context, attachment: AgentAttachment, fileAccess: FileAccessProvider ) { - val local = withContext(Dispatchers.IO) { resolveLocalFile(attachment, fileAccess) } - if (local == null) { - Toast.makeText(context, context.getString(R.string.chat_open_file_missing), Toast.LENGTH_SHORT).show() - return + val localFile = when ( + val resolved = withContext(Dispatchers.IO) { resolveLocalFile(attachment, fileAccess) } + ) { + is LocalFileResolution.Found -> resolved.file + LocalFileResolution.Missing -> { + Toast.makeText(context, context.getString(R.string.chat_open_file_missing), Toast.LENGTH_SHORT).show() + return + } + LocalFileResolution.Unavailable -> { + Toast.makeText(context, context.getString(R.string.chat_open_file_unavailable), Toast.LENGTH_SHORT).show() + return + } } // APK 安装包:系统安装器要求「允许安装未知应用」授权,未授权时引导用户去设置页开启, // 否则点击只会弹出「没有权限安装」的拒绝提示。 @@ -260,7 +272,7 @@ internal suspend fun openSentAttachment( } } val shareable = withContext(Dispatchers.IO) { - if (isShareable(context, local)) local else copyToShareCache(context, local) + if (isShareable(context, localFile)) localFile else copyToShareCache(context, localFile) } val uri = shareable?.let { runCatching { FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", it) }.getOrNull() @@ -279,12 +291,42 @@ internal suspend fun openSentAttachment( } } +/** + * 附件落到宿主本地文件的结果。 + * + * 必须把「文件确实不在」与「这次取不到」分开:本地模式下 [FileAccessProvider.copyToLocal] 对不存在的路径 + * 只返回一个 isFile 为 false 的 [File](不抛异常),而远程模式对「远端没有这个文件」抛 [NoSuchFileException]、 + * 对断线/通道异常/工作区未落定抛 [java.io.IOException] 或 WorkspaceNotReadyException。前者是文件没了, + * 后者是文件可能还在、只是这次拿不到——提示语不能共用一句。 + */ +private sealed interface LocalFileResolution { + data class Found(val file: File) : LocalFileResolution + data object Missing : LocalFileResolution + data object Unavailable : LocalFileResolution +} + /** 卡片记录的宿主路径失效(远程临时副本被清、文件被删或移动)时,退回容器路径重取。 */ -private suspend fun resolveLocalFile(attachment: AgentAttachment, fileAccess: FileAccessProvider): File? { - attachment.localPath.takeIf { it.isNotBlank() }?.let(::File)?.takeIf { it.isFile }?.let { return it } +private suspend fun resolveLocalFile( + attachment: AgentAttachment, + fileAccess: FileAccessProvider +): LocalFileResolution { + attachment.localPath.takeIf { it.isNotBlank() }?.let(::File)?.takeIf { it.isFile } + ?.let { return LocalFileResolution.Found(it) } val containerPath = attachment.containerPath - if (containerPath.isBlank()) return null - return runCatching { fileAccess.copyToLocal(containerPath) }.getOrNull()?.takeIf { it.isFile } + if (containerPath.isBlank()) return LocalFileResolution.Missing + return try { + fileAccess.copyToLocal(containerPath).takeIf { it.isFile } + ?.let { LocalFileResolution.Found(it) } + ?: LocalFileResolution.Missing + } catch (e: CancellationException) { + // 协程取消不是「取不到」:继续走 catch (e: Exception) 会把它吞成一次提示,取消就再也传不上去。 + throw e + } catch (e: NoSuchFileException) { + LocalFileResolution.Missing + } catch (e: Exception) { + FileLogger.w(TAG, "取附件本地副本失败:$containerPath", e) + LocalFileResolution.Unavailable + } } /** 路径落在 FileProvider 声明的目录树内时可直接授权;不能就抛异常,故按能否取到 URI 判定。 */ @@ -305,6 +347,8 @@ private fun copyToShareCache(context: Context, source: File): File? = runCatchin private const val SHARE_CACHE_DIR = "sent_files" +private const val TAG = "MessageAttachment" + private fun isApk(attachment: AgentAttachment): Boolean { if (attachment.mimeType.equals("application/vnd.android.package-archive", ignoreCase = true)) return true val name = attachment.fileName.lowercase() diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index a3679592..e182d342 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -372,6 +372,7 @@ Extra high Max File not found or has been moved + Cannot read this file: the connection is lost or the workspace is not ready yet, please try again later This file\'s location cannot be shared with other apps No app available to open this file Installing apps requires permission. Allow installs from this source in the settings that just opened diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 77ffbbe1..32f95540 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -372,6 +372,7 @@ 极高 最高 文件不存在或已被移动 + 无法读取文件:连接已断开或工作区未就绪,请稍后重试 文件所在位置无法分享给其它应用 没有可打开该文件的应用 安装应用需要授权,请在跳转的设置中允许来自此来源的应用 From 420af59f78b66a9d18e64faf3da258d7d22cf92a Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:37:45 +0800 Subject: [PATCH 09/10] =?UTF-8?q?fix(agent):=20=E7=94=9F=E5=9B=BE=20create?= =?UTF-8?q?Image=20=E6=8E=A5=E8=BF=9B=20retryStaircase=EF=BC=8C429=20?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=97=A2=E4=B8=8D=E9=80=80=E9=81=BF=E4=B9=9F?= =?UTF-8?q?=E4=B8=8D=E9=87=8D=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生图 OpenAI 分支直接裸调 openAIApi.createImage:429/5xx/超时既不退避也不重发,一次失败就返回 IMAGE_GEN_FAILED(多 Key 下也只把当前 Key 打进冷却、本次调用照样失败)。文本路径早就统一走 retryStaircase(maxRetries + onKeyFailure 切 Key 重发),生图路径当时创建时就漏了。 - 用同一套 retryStaircase 包住 createImage;onKeyFailure 复用 ProviderKeyRotator.reportFailure(providerId, sessionId, key, triedKeys),判定与文本路径一致(isKeySwitchFailure + 供应商自定义触发码),候选用尽抛 AllKeysFailedException - 重试次数与文本路径有区别:走用户设置「重试次数」,但再封顶 MAX_IMAGE_GEN_RETRIES=2(最多 3 次尝试)。理由:文本重试几乎无代价,生图几十秒一张且按张计费,超时/连接中断时服务端可能已生成并计费(Images API 无幂等键),重发就是赌重复扣费;429/5xx 这类服务端明确未受理的失败仍值得退避重试。用户设 0 照传 0 - Key 切换不受该上限约束(与文本路径一致:预算按候选 Key 数,切完立即重发) - 失败出口的补报改为带 triedKeys:不再把本次已试过的 Key 又选回来;自定义触发码把 5xx 也算作 Key 失败时(重试块内不走 onKeyFailure)仍保留原补报行为 - 行为变化:可恢复失败现在会重发成功并返回图片;不可恢复(400 等)仍一次都不重试 --- .../domain/tool/file/GenerateImageTool.kt | 65 ++++++++++++++++--- 1 file changed, 57 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/aicode/feature/agent/domain/tool/file/GenerateImageTool.kt b/app/src/main/java/com/aicode/feature/agent/domain/tool/file/GenerateImageTool.kt index 26fee189..677f22be 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/tool/file/GenerateImageTool.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/tool/file/GenerateImageTool.kt @@ -9,11 +9,13 @@ import com.aicode.feature.agent.data.remote.openai.ImageGenerationResponse import com.aicode.feature.agent.data.remote.openai.OpenAIApi import com.aicode.feature.agent.domain.model.AgentContext import com.aicode.feature.agent.domain.model.AgentImage +import com.aicode.feature.agent.domain.provider.AllKeysFailedException import com.aicode.feature.agent.domain.provider.enrichWithHttpErrorBody import com.aicode.feature.agent.domain.provider.isKeySwitchFailure import com.aicode.feature.agent.domain.provider.joinUrl import com.aicode.feature.agent.domain.provider.parseInteractionSteps import com.aicode.feature.agent.domain.provider.resolveCustomHeaders +import com.aicode.feature.agent.domain.provider.retryStaircase import com.aicode.feature.agent.domain.tool.AbstractContextualTool import com.aicode.feature.agent.domain.tool.ParameterType import com.aicode.feature.agent.domain.tool.PendingToolPermission @@ -21,6 +23,7 @@ import com.aicode.feature.agent.domain.tool.ToolCapability import com.aicode.feature.agent.domain.tool.ToolParameter import com.aicode.feature.agent.domain.tool.ToolPermissionPolicy import com.aicode.feature.agent.domain.tool.ToolResult +import com.aicode.feature.settings.data.repository.GeneralSettingsRepository import com.aicode.feature.settings.data.repository.ImageGenModelSettingsRepository import com.aicode.feature.settings.data.repository.ProviderKeyRotator import com.aicode.feature.settings.domain.model.ProviderType @@ -61,7 +64,8 @@ class GenerateImageTool @Inject constructor( private val openAIApi: OpenAIApi, private val geminiApi: GeminiApi, private val httpClient: OkHttpClient, - private val keyRotator: ProviderKeyRotator + private val keyRotator: ProviderKeyRotator, + private val generalSettingsRepository: GeneralSettingsRepository ) : AbstractContextualTool() { override val name = "generateImage" @@ -176,10 +180,13 @@ class GenerateImageTool @Inject constructor( var seq = 0 var lastProviderId = "" var activeApiKey = "" + // 本次已用过的 Key(含首次):重试时不再选它。声明在 try 外,失败出口还要用它上报。 + val triedKeys = mutableSetOf() return@withContext try { val provider = resolveImageGenProvider() lastProviderId = provider.id activeApiKey = keyRotator.activeKey(provider, context.sessionId) ?: provider.firstUsableApiKey + triedKeys += activeApiKey val model = provider.effectiveModel // Gemini 官方图像模型走 Interactions 协议(v1beta/interactions),OpenAI 的 // /v1/images/generations 端点打不通;设了 Gemini 生图模型时切独立通道, @@ -219,12 +226,33 @@ class GenerateImageTool @Inject constructor( seq = AILogger.logRequest(context.sessionId, provider.id, model, "POST", url, request) - val response = openAIApi.createImage( - url = url, - authorization = "Bearer $activeApiKey", - extraHeaders = resolveCustomHeaders(provider.customHeaders, context.sessionId, activeApiKey), - request = request - ) + val response = retryStaircase( + maxRetries = imageGenMaxRetries(), + onKeyFailure = { e, _ -> + // 与文本路径同一套判定:先看是否归因于 Key(鉴权/额度/限流),是才切; + // 切完返回 true,retryStaircase 重置重试计数并重发;无可切候选就报「N 个 Key 均失败」。 + if (activeApiKey.isEmpty() || !e.isKeySwitchFailure(provider.effectiveKeySwitchStatusCodes)) { + false + } else { + val switched = keyRotator.reportFailure(provider.id, context.sessionId, activeApiKey, triedKeys) + ?: throw AllKeysFailedException( + "「${provider.name}」的 ${provider.effectiveApiKeys.size} 个 Key 均失败:" + + (e.message ?: e.javaClass.simpleName), + e + ) + activeApiKey = switched.newKey + triedKeys += switched.newKey + true + } + } + ) { + openAIApi.createImage( + url = url, + authorization = "Bearer $activeApiKey", + extraHeaders = resolveCustomHeaders(provider.customHeaders, context.sessionId, activeApiKey), + request = request + ) + } AILogger.logResponse(context.sessionId, provider.id, response, seq) buildSuccess(response, n, outputPath, model) } catch (e: CancellationException) { @@ -232,7 +260,9 @@ class GenerateImageTool @Inject constructor( } catch (e: Exception) { val enriched = e.enrichWithHttpErrorBody() if (activeApiKey.isNotEmpty() && enriched.isKeySwitchFailure()) { - keyRotator.reportFailure(lastProviderId, context.sessionId, activeApiKey) + // 重试循环未走 onKeyFailure 的兜底(如自定义触发码把 5xx 也算作 Key 失败); + // 传 triedKeys,避免把本次已试过的 Key 又选回来。 + keyRotator.reportFailure(lastProviderId, context.sessionId, activeApiKey, triedKeys) } FileLogger.e(TAG, "generateImage 失败", enriched) AILogger.logError(context.sessionId, lastProviderId, enriched, seq) @@ -240,6 +270,16 @@ class GenerateImageTool @Inject constructor( } } + /** + * 生图这一路径的网络重试次数:在用户设置(设置 → 重试次数)之上再压一个生图专用上限。 + * + * 文本请求重试几乎无代价,生图则几十秒一张且按张计费:超时/连接中断时服务端可能已经生成并 + * 计费(Images API 无幂等键),重发就是在赌重复扣费。所以不改用户设置、只在生图侧封顶; + * 用户把重试设为 0 时照传 0。Key 切换不受这个上限约束(与文本路径一致,切完立即重发)。 + */ + private suspend fun imageGenMaxRetries(): Int = + generalSettingsRepository.maxNetworkRetries().coerceAtMost(MAX_IMAGE_GEN_RETRIES) + private suspend fun resolveImageGenProvider(): com.aicode.feature.settings.domain.model.AIProviderConfig { val providerId = imageGenModelSettingsRepository.getImageGenProviderId().trim() val model = imageGenModelSettingsRepository.getImageGenModel().trim() @@ -693,6 +733,15 @@ class GenerateImageTool @Inject constructor( const val MAX_IMAGES = 4 const val MAX_IMAGE_BYTES = 20L * 1024 * 1024 const val MAX_TOTAL_IMAGE_BYTES = 48L * 1024 * 1024 + + /** + * 生图网络失败的重试次数上限(不含首次请求)。 + * + * 文本路径直接用用户设置的次数(默认 6),生图不能照抄:一次生图几十秒且按张计费,而超时/ + * 连接中断时服务端可能已经生成并计费,重发就是在赌重复扣费。429、5xx 这类服务端明确未受理的 + * 失败值得退避重试,但最多 2 次(合计最多 3 次尝试);再多就只是把重复扣费的概率拉高。 + */ + const val MAX_IMAGE_GEN_RETRIES = 2 const val FORMAT_PNG = "png" const val FORMAT_JPEG = "jpeg" const val FORMAT_WEBP = "webp" From c6481ed47454465d012d4695d77dea8d3074ec9f Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:46:02 +0800 Subject: [PATCH 10/10] =?UTF-8?q?fix(agent):=20=E7=82=B9=E5=81=9C=E6=AD=A2?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=B8=85=E7=A9=BA=E5=BE=85=E9=80=81=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=EF=BC=8C=E6=B6=88=E6=81=AF=E4=B8=8D=E5=86=8D=E5=87=AD?= =?UTF-8?q?=E7=A9=BA=E6=B6=88=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stopAgentSession 在 cancel() 之后、若当时没有新 job 接管,会 agentNotificationCenter.clear(sessionId)。但同一处的注释自己写着「不预先清除待送通知—— 它们应由 finally 正常 flush 给新 job 处理」:cancel() 的 finally 未必同步跑完, 在那个窗口里 clear 会把待送通知(用户插话 / 后台任务完成 / 子代理结束)直接抹掉, finally 的 flushPendingNotifications 再 drain 就只剩空列表,用户那边就是「消息没了」。 去掉这处 clear,交回 finally 的既有兜底路径送达(作为消息发出去); 状态清理(置 Idle)保持不变。切工作区的 stopAllAgents 与回退清队列仍按原样清空—— 那两处是整段重置/回退,不是「停止任务」。 --- .../aicode/feature/agent/presentation/AIAgentViewModel.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt b/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt index fb560d3b..9499d25c 100644 --- a/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt +++ b/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt @@ -1915,12 +1915,14 @@ class AIAgentViewModel @Inject constructor( // cancel() 在 Dispatchers.Main.immediate 上可能立即恢复挂起协程 // (如 awaitApproval 的 CompletableDeferred.await),旧 job 的 finally → // flushPendingNotifications 在 cancel() 调用栈内同步执行并可能启动新 job。 - // 不预先清除待送通知——它们应由 finally 正常 flush 给新 job 处理。 + // 不预先清除待送通知——它们应由 finally 正常 flush 给新 job 处理; + // 也不在这里清:cancel() 的 finally 未必已跑完,此时清就是把待送通知凭空抹掉 + // (用户点的是「停止任务」,不是「丢掉我的消息 / 后台完成通知」), + // 而 finally 里的 flushPendingNotifications 无论如何都会把它们作为消息送出去。 job.cancel() // cancel 可能已同步执行完 finally(flush 启动了新 job 并注册到 sessionJobs), - // 此时不能再覆盖新 job 的状态;仅当无新 job 接管时才做清理。 + // 此时不能再覆盖新 job 的状态;仅当无新 job 接管时才做状态清理。 if (sessionJobs[sessionId]?.isActive != true) { - agentNotificationCenter.clear(sessionId) setAgentState(sessionId, AgentUIState.Idle) } _runningTools.value = _runningTools.value - sessionId