Skip to content

fix: 一批单点缺陷修复(Shizuku 状态判定、终端断线横幅、编辑器覆盖保护、凭据/附件/生图) - #47

Closed
Rely-xcy wants to merge 10 commits into
jieapi:mainfrom
Rely-xcy:pr/single-fixes
Closed

Rely-xcy wants to merge 10 commits into
jieapi:mainfrom
Rely-xcy:pr/single-fixes

Conversation

@Rely-xcy

@Rely-xcy Rely-xcy commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

单点修复批:Shizuku / 终端 / 编辑器 / 凭据 / 附件 / 生图 / 通知(10 条)

概述

这批是彼此独立的单点修复的集合:10 个提交、22 个文件(Kotlin 18 个、strings.xml 2 个、文档 2 篇),+482 / -77。
每条各自对应一个明确的用户可见缺陷,彼此没有依赖关系(可单独 review、可单独回退)。

其中两条带前置依赖关系,放在批内一起提交:

  • 「打开附件区分『文件不存在』与『这次取不到』」需要 RemoteSftpFileAccess.copyToLocal 先不再把断线异常
    也说成 NoSuchFileException,否则新的「这次取不到」分支在远程路径上永远走不到。这条前置只取了异常语义这一小块
    (见清单第 7 条),不是 fork 上那个跨 19 文件的混合提交。
  • 「Shizuku 未装设备的文案」需要以 binder 为准的状态机(清单第 3 条)先落地,否则 NOT_RUNNING 的语义对不上文案;
    同时它把第 3 条带进来的零调用 peerInfo() 接上了线(消除「新增公开 API 没有调用方」)。

全部 10 个提交都能从 upstream/main 干净摘取(无冲突),并已通过 CI 编译验证(见文末)。

分支:pr/single-fixes(基于 upstream/main 6c615e38),HEAD c6481ed4,已推到 origin。

修复清单

按提交顺序,每条给出:症状 → 机理 → 改法,并附原始 commit subject。

1. 保存前检测「文件已被外部改动」,二次确认后再覆盖

  • 症状:编辑器开着某个文件,同时在服务器上(或别的会话里)改了同一文件,回来点保存 → 服务器侧改动被静默覆盖。
  • 机理:save() 直接 writeFile(overwrite = true);接口里明明有 lastModified(),保存路径没用。
  • 改法:CodeEditorViewModel 打开文件时记下 mtime(取不到记 0),保存前比对:mtime 变新 → 发新增的
    SaveResult.Conflict 分支且不写盘,UI 弹 AlertDialog 二次确认,确认后带 force = true 重来;
    取消则留在编辑器继续改。自己写完会重新读一次 mtime 刷新基准(否则下次保存必然把自己的写入误判成外部改动);
    换文件时清空基准。只提示不硬拦(SFTP v3 的 mtime 只有秒级精度,硬拦会误报)。
    feat(editor): 保存前检测「文件已被外部改动」,二次确认后再覆盖

2. 断线导致的会话结束打出「连接已断开」横幅

  • 症状:断网 / 服务器重启后终端只打一句 [Process completed],与用户敲 exit 长得一模一样,看不出连接已经没了。
  • 机理:SshShellBackend.waitForExit() 恒返回 0(远程 shell 没有有意义的退出码),断开与正常退出在 UI 上不可区分。
  • 改法:不动 waitForExit 的返回值语义(它是 SessionBackend 接口约定,本地终端 SubprocessBackend 共用),
    改为 SshShellBackend 增加 closedByDisconnect 标记 + 可选 isConnectionAlive 回调(默认 { true },既有单参构造不受影响);
    RemoteTerminalSessionManager 记录标签 id → backend,onSessionFinished 时置 TerminalTab.droppedByDisconnect;
    TerminalScreen 在该标签下显示 errorContainer 横幅。
    已知局限:断网瞬间 sshj 的 transport 未必立刻判定失败,此时仍会被当成正常退出、没有横幅;连接级的断线由聊天页的连接指示器覆盖。
    feat(terminal): 断线导致的会话结束打出「连接已断开」横幅,不再伪装成正常退出

3. Shizuku 不再用官方包名判「装没装」

  • 症状:用户装的是 Stellar(包名 roro.stellar.manager)→ 设置页显示「未安装」、点了跳下载页,AI 也用不了 Shizuku。
  • 机理:computeState() 第一关是 isShizukuInstalled(),它按官方包名 moe.shizuku.privileged.api 查 getPackageInfo。
    而授权判定本身走 binder(checkSelfPermission),与包名无关;库层也不校验管理器包名
    (provider:13.1.5 的 aar 里该字符串零命中,ShizukuProvider.handleSendBinder 只判 pingBinder 后直接 onBinderReceived)——
    binder 其实早就被 Stellar 推进来了,只是代码从没走到 pingBinder()。Sui 是 Magisk 模块、没有独立管理器包,同样被这关判死。
  • 改法:computeState() 改成以 binder 为准(pingBinder → isPreV11 → checkSelfPermission),全程 runCatching,
    异常降为 NOT_RUNNING;删掉 isShizukuInstalled();_state 初值改 NOT_RUNNING。
    openShizukuApp() 改为 官方 → Stellar → 按 provider authority 精确匹配扫描(${pkg}.shizuku / ${pkg}.stellar,排除自身)→ 兜底下载页。
    新增只读 peerInfo()(uid / version / SELinux,区分 adb uid 2000 与 root uid 0)。
    两条信任锚未动:provider 的 INTERACT_ACROSS_USERS_FULL(只有 shell / root 能推 binder)与
    READY 只由 checkSelfPermission 决定——放宽包名检测不降低授权强度。
    fix(shizuku): 不再用官方包名判「装没装」,支持 Stellar / Sui 等分支

4. Shizuku 未装设备的文案不再让人去开不存在的应用,并显示当前连接身份

  • 症状:状态判定改成以 binder 为准后,NOT_RUNNING 既表示「服务没启动」也表示「设备上根本没有管理器」,
    文案却还写着「点击打开 Shizuku 并启动服务」——没装的设备上那个 App 根本不存在(点击实际会跳下载页,文案没提)。
    另外第 3 条带进来的 peerInfo() 没有任何调用方。
  • 机理:ShizukuState.NOT_INSTALLED 不再被产出,ShizukuTool / AppPermissionsSection 里两条「未安装」文案成为死代码。
  • 改法:删掉 NOT_INSTALLED 常量及其两条死文案(中英各一条),NOT_RUNNING 状态文案改「未就绪」、
    副标题改「点击打开 Shizuku 并启动服务;未安装会跳转下载页」(复用原 key,只改内容),工具侧 stateHint 同步。
    接线 peerInfo():设置 → 软件权限区在就绪时多一行「当前连接」,写明当前是以 adb shell(uid 2000)还是 root(uid 0)连上的
    (root 才读得到其它应用的私有目录,只写「已就绪」看不出这点)。新增四组文案,中英键集一致。
    枚举的 when 全套改为三档(编译期强制穷举)。
    fix(settings): Shizuku 未装设备的文案不再让人去开不存在的应用,并显示当前连接身份

5. 技能 / 子代理列表在工作区落定 / 切换后重扫

  • 症状:远程模式下「当前项目」技能组一直是空的。
  • 机理:不是来源写死本地(列 / 读 / 写 / 删 / 导入本来就经 DelegatingFileAccess 按 ExecutionModeHolder 转发),
    而是缺一个刷新触发点:建连与工作区加载是两条异步链,connectionState = CONNECTED 时工作区可能还没落定,
    此时 currentPath() 回退到「工作区根目录」而非某个工作区,ProjectDirectorySkillSource 扫的路径少一层 <工作区名>,
    必然空列表;工作区随后落定或用户切换工作区都不再触发重扫,页面就停在空/旧内容上。
  • 改法:补上 workspaceRepository.current 的收集器(与既有 executionMode / connectionState 两处同层),
    工作区一变就 refreshSkills() + refreshSubAgents();顺带修掉本地模式下「切工作区后项目技能不更新」的同类陈旧问题。
    技能来源根路径、仓库读写、UI 结构、文案分组均未改动。
    fix(skills): 工作区落定/切换后重扫技能与子代理列表

6. git 凭据不再被误判格式而读空

  • 症状:升级后已保存的 git 凭据全部读空。
  • 机理:android.util.Base64 的 DEFAULT 解码是「宽容」的——:、/、@ 等非法字符一律按 SKIP 忽略、不抛异常,
    于是旧版明文凭据也能被「解码」成乱码,代码据「未抛异常」判定为编码格式 → 明文凭据全被读空。
  • 改法:改用往返校验 encode(decode(raw)) == raw 判定格式,不成立就按明文处理(保留旧版明文回退解析)。
    fix(credentials): 修复 git 凭据编码格式误判导致凭据丢失

7. copyToLocal 异常语义:只对真「不存在」抛 NoSuchFileException

  • 症状(前置):远程 SSH 断线 / SFTP 通道异常时打开已发送附件,提示「文件不存在或已被移动」,
    而文件明明在服务器上——用户会去删 / 重建工作区,而不是重连。
  • 机理:RemoteSftpFileAccess.copyToLocal 的收尾 catch (e: Exception) 把一切异常都包成
    NoSuchFileException(File(remote)),调用方根本无法区分「文件没了」与「这次取不到」。
  • 改法(逐行,两处):
    1. RemoteSftpFileAccess.kt:249-255 的 catch 块末行 throw NoSuchFileException(File(remote)) →
      throw IOException(friendlySshError(e), e)。原有的 if (e is NoSuchFileException) throw e 保留:
      真「远端没有这个文件」(statExistence == null)仍抛 NoSuchFileException,语义不变;
      其余失败(断线、通道异常、SSH 未连接)改为 IOException,文案取 friendlySshError——
      该方法与本文件的 withSftp 已经在用,没有新增 import、没有引入新依赖。
    2. FileAccessProvider.kt:110 的 copyToLocal KDoc 补五行「异常契约」。接口里
      readFile / writeFile / rename / copy / move 都写了会抛什么,唯独 copyToLocal 没写,
      而调用方正是靠这段区分两类失败——不写清契约,调用侧的
      catch (NoSuchFileException) → Missing / catch (Exception) → Unavailable 就是无据可依的假设。
      文案按本基线的真实行为写(本地实现不检查存在性;远程实现 NoSuchFileException 与其它 IOException 分开),
      只改注释,无行为变化。
    • 来源:语义摘自上一条混合提交(跨 19 文件、改 4 个 repository 签名、与基线 5 处冲突),只取这一条语义;
      其余(repository 返回 Boolean、项目级写入提示、ManageMcpTool 错误、FTP/SFTP 文案中文化)一律不带。
      手工适配:copyToLocal 异常语义(摘自上 fork 的 c6b62ba1,仅此一语义)

8. 打开附件区分「文件不存在」与「这次取不到」

  • 症状:远程断线 / 工作区未就绪时一律提示「文件不存在或已被移动」。另外协程取消也会弹一次提示。
  • 机理:resolveLocalFile 用 runCatching 吞掉异常、只回 File?,调用方一律按「文件不存在」处理;
    runCatching 连 CancellationException 一起吞,取消协程也会触发一次提示。
  • 改法:新增 private sealed interface LocalFileResolution { Found / Missing / Unavailable }:
    本地模式 copyToLocal 对不存在的路径不抛异常(返回 isFile = false 的 File),仍归 Missing;
    远程模式 NoSuchFileException → Missing,其余异常 → Unavailable。CancellationException 显式重抛照常传播。
    Unavailable 走新文案 chat_open_file_unavailable(中英同键集),并记一条 FileLogger.w 让日志能看到真实失败原因。
    触发场景不变:localPath 有效仍直用,containerPath 为空仍算 Missing。
    fix(agent): 打开附件区分「文件不存在」与「这次取不到」,断线不再误报文件不存在

9. 生图 429 不再既不退避也不重发

  • 症状:生图遇到 429 / 5xx / 超时直接返回 IMAGE_GEN_FAILED,多 Key 下也只把当前 Key 打进冷却,本次调用照样失败。
  • 机理:GenerateImageTool 的 OpenAI 分支直接裸调 createImage,没走文本路径早就统一的 retryStaircase
    (maxRetries + onKeyFailure 切 Key 重发)——生图路径创建时就漏了。
  • 改法:用同一套 retryStaircase 包住 createImage,onKeyFailure 复用
    ProviderKeyRotator.reportFailure(providerId, sessionId, key, triedKeys),判定与文本路径一致,候选用尽抛 AllKeysFailedException。
    重试次数走用户设置的「重试次数」,但再封顶 MAX_IMAGE_GEN_RETRIES = 2(最多 3 次尝试):
    文本重试几乎无代价,生图几十秒一张且按张计费、Images API 无幂等键,超时重发就是赌重复扣费;
    429 / 5xx 这类服务端明确未受理的失败仍值得退避重试。Key 切换不受该上限约束(与文本路径一致)。
    失败出口的补报改为带 triedKeys,不再把已试过的 Key 又选回来。
    fix(agent): 生图 createImage 接进 retryStaircase,429 不再既不退避也不重发

10. 点「停止」不再清空待送通知,消息不再凭空消失

  • 症状:点「停止任务」后,后台任务完成 / 子代理结束 / 插话这类待送通知会凭空消失。
  • 机理:stopAgentSession 在 cancel() 之后、若当时没有新 job 接管,会 agentNotificationCenter.clear(sessionId);
    而同处注释自己写着「不预先清除待送通知——它们应由 finally 正常 flush 给新 job 处理」。
    cancel() 的 finally 未必同步跑完,在那个窗口里 clear 会把待送通知直接抹掉,finally 再 drain 就只剩空列表。
  • 改法:去掉这处 clear,交回 finally 的既有兜底路径送达(作为消息发出去);状态清理(置 Idle)保持不变。
    切工作区的 stopAllAgents 与回退清队列仍按原样清空——那两处是整段重置 / 回退,不是「停止任务」。
    fix(agent): 点停止不再清空待送通知,消息不再凭空消失

为什么对上游有价值

症状 影响
非官方管理器(Stellar / Sui)被当成「未安装」 功能对这部分用户完全不可用,且错误指向误导用户去装一个不存在的 App
编辑器保存静默覆盖外部改动 数据丢失,且用户完全无感知
终端断线伪装成正常退出 用户以为终端还活着,继续敲命令没有反馈,只会当成「App 坏了」
附件把连接问题说成「文件不存在」 归因错导致用户做错事(删 / 重建工作区,而不是重连)
git 凭据被读空 升级即失效,用户需要重新配置所有凭据
生图 429 直接失败 限流场景下用户唯一的选择是手动重试
点停止丢通知 后台任务完成通知消失,用户会以为任务没跑完

风险 / 边界(请 reviewer 重点看)

  1. Shizuku 是行为改动,不是文案调整:状态机不再产出 NOT_INSTALLED(本批把它连常量一起删了,
    所有 when 分支同步改为三档,编译期穷举保证不漏);初始状态从 NOT_INSTALLED 改为 NOT_RUNNING。
    未装管理器的设备现在看到的是「未就绪」。两条信任锚未动(provider 的 INTERACT_ACROSS_USERS_FULL、
    READY 只由 checkSelfPermission 决定),但「按包名判装没装」这条判定被彻底移除,属于安全边界的变化,
    建议 reviewer 明确认可。明确不做:客户端主动拉 binder(会让抢注 authority 的应用拿到命令原文)、QUERY_ALL_PACKAGES、引入 Stellar 原生 API。
    • 补充一个容易被追问的点:管理器扫描 getInstalledPackages(GET_PROVIDERS) 不用 QUERY_ALL_PACKAGES 也能看到所有包,
      是因为本仓库 targetSdk = 28(app/build.gradle.kts:114,低于 30),不触发包可见性过滤。
      若将来把 targetSdk 提到 30+,这段 authority 扫描会静默只看到部分包(官方 / Stellar 两个硬编码包名不受影响,
      因为它们在 KNOWN_MANAGER_PACKAGES 里先命中),届时需要加 <queries> 或接受「只认已知包名」。
  2. peerInfo() 在就绪时才问 binder:走 state.map { peerInfo() }.flowOn(Dispatchers.IO),
    ShizukuManager.peerInfo() 内部先判 state == READY 再调用;未就绪时返回 null,不触发权限申请。异常一律降级为 null 并记日志。
  3. 编辑器保存冲突只提示不硬拦:mtime 秒级精度 + 远端 FS 时间戳未必可靠,判定偏保守(宁可漏报不误报);
    取不到时间戳时(基准 0)直接跳过冲突检测。代价是每次保存多一次 stat。
  4. 生图重试上限是新增策略:文本路径仍用用户设置的重试次数,生图单独封顶 2 次(合计最多 3 次尝试),
    理由是 Images API 无幂等键、超时重发可能重复扣费。这是产品取舍,值得 reviewer 确认。
  5. 终端断线横幅有已知漏报窗口:断网瞬间 sshj 未必立刻判定 transport 失败,此时 shell 结束仍被当成正常退出。
    这版只在「join 抛异常」或「join 返回时连接已不在」时置位,不追求全覆盖。
  6. copyToLocal 的异常类型变化(重要):非「不存在」的失败路径上,抛出的异常从 NoSuchFileException
    变为 IOException(文案来自 friendlySshError)。仓库内调用点已逐个核查:
    StatefulAgentWorkflow.kt:1143、ChatImageLoader.kt:35、SendFileTool.kt:122、MarkdownImageSupport.kt:78、
    ImageTools.kt:351、GenerateImageTool.kt:563、ChatAttachmentUtils.kt:214、BrowserManager.kt:850 都没有
    按 NoSuchFileException 分支处理(只在意「拿到 / 没拿到」),行为不变;全仓唯一 catch
    NoSuchFileException 的调用点就是本批同改的 MessageAttachmentComponents.kt:324。
    若上游还有未合并的 copyToLocal 调用方按 NoSuchFileException 判定,需要一并复核。
  7. FileAccessProvider 的 KDoc 只是注释:不改变任何实现。契约文案已按本基线的真实行为书写
    (未引用基线不存在的类型)。
  8. FileCredentialRepository 的往返校验:Base64.encodeToString + reversed() 的编码格式保持不变,
    只把「是否编码格式」的判定从「解码未抛异常」换成「往返一致」。旧版明文凭据现在能正确回退解析。

本批明确不含

  • 不含 pr/nav-backstack(导航返回栈)与 pr/ui-scroll-input(键盘 / 滚动 / 草稿 / 渲染)两批。
  • 不含那条混合提交的其余部分:repository 返回 Boolean(SkillConfigRepository / AgentDefinitionConfigRepository /
    McpConfigRepository / PermissionRulesRepository)、项目级写入被跳过时的 Toast 提示、ManageMcpTool 的
    ToolResult.Error、FtpSyncClient / SftpSyncClient 的英文异常文案中文化、friendlySshError 的「已是中文则透传」分支,
    均未纳入——它们跨 4 个 repository 的签名与多处用户文案,超出本批「单点修复」的范围,需要单独一批。
  • 不含已核查为不必需的那条 KDoc 之外的下游提交:附件归因与生图重试都不依赖上游尚未存在的符号
    (retryStaircase、reportFailure(…, triedKeys) 在上游已有)。
  • 不含任何 fork 私有内容:更新源、包名 / applicationId 差异、CI 触发条件差异、FORK_PRIVATE 清单、
    备份 / 记忆相关改动,一律不在本批。

验证情况

  • 基线:upstream/main(6c615e38)。
  • 10 个提交逐个 cherry-pick,无冲突;结果文件与原始分支逐文件 blob 一致。
  • 静态核查:values/ 与 values-en/ 的 strings.xml 条目数一致(1482 = 1482,键集完全相同、无重复键);
    改动文件里新增 import 的项目内符号(ShizukuPeerInfo、AppTextField、retryStaircase、IOException、
    friendlySshError 等)全部可在仓库内找到定义;改动到的符号没有任何测试文件引用;
    Shizuku 相关改动依赖的 dev.rikka.shizuku:api:13.1.5 / provider:13.1.5 版本与本基线一致(app/build.gradle.kts:398-399,未改动)。
  • CI:beta.yml 手动 workflow_dispatch(分支 pr/single-fixes),跑 :app:assembleUniversalBeta
    (JDK 17 + R8 + 资源压缩)。
    • run:https://github.com/Rely-xcy/AiCode/actions/runs/37188970303,completed / success,attempt 1。
    • Build universal beta APK 步骤 success,整个 job 08:27:26Z → 08:34:35Z(约 7 分 9 秒)。
    • 产物 aicode-beta-ed330766995d812810a93d7f98f5425dbc8bf30d,25,402,701 字节(说明不止编译过了,R8 与资源处理也跑完了)。
  • 单测未跑:ci.yml 的单测门禁只在 push 到 main / fork/release 时触发,本轮只能跑 beta 编译;
    assembleUniversalBeta 不编译 app/src/test。
  • 未在真机验证:Shizuku 的 Stellar 场景、终端断线横幅、编辑器冲突对话框都只做了静态与编译验证,
    没有真机复现(容器内没有 Android 设备 / JDK)。

Rely-xcy and others added 10 commits October 4, 2026 16:25
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)。
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)。
用户用的是 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。
为什么:状态判定改成以 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 工具未就绪的报错文案同步改口径。
远程模式下「当前项目」技能组一直空着,真因不是来源写死本地(列/读/写/删/导入
本来就经 DelegatingFileAccess 按 ExecutionModeHolder 转发),而是缺一个刷新触发点:
建连与工作区加载是两条异步链,connectionState=CONNECTED 时工作区可能还没落定,
此时 currentPath() 回退到「工作区根目录」而非某个工作区(WorkspaceRepository.kt:501-503),
ProjectDirectorySkillSource 扫的路径少一层 <工作区名>,必然空列表;工作区随后落定
或用户切换工作区,都不再触发重扫,页面就停在空/旧内容上。

补上 workspaceRepository.current 的收集器(与既有 executionMode / connectionState
两处同层),工作区一变就 refreshSkills() + refreshSubAgents()。顺带修掉本地模式下
「切工作区后项目技能不更新」的同类陈旧问题。

技能来源根路径、仓库读写、UI 结构、文案分组均未改动 —— 它们本来就正确。
android.util.Base64 的 DEFAULT 解码是宽容的:非法字符按 SKIP 忽略、不抛异常,旧版明文凭据因此也能被
「成功解码」成乱码、被误判成本编码格式,凭据全部读空。改用往返校验判定格式:只有 encode(decode(raw)) == raw
才认定为编码格式,否则按明文处理(旧版明文仍需回退解析)。
为什么只摘这一小块:fork 上的 c6b62ba 是混合体(跨 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 私有的一切内容。
打开已发送附件时 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
生图 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 等)仍一次都不重试
stopAgentSession 在 cancel() 之后、若当时没有新 job 接管,会
agentNotificationCenter.clear(sessionId)。但同一处的注释自己写着「不预先清除待送通知——
它们应由 finally 正常 flush 给新 job 处理」:cancel() 的 finally 未必同步跑完,
在那个窗口里 clear 会把待送通知(用户插话 / 后台任务完成 / 子代理结束)直接抹掉,
finally 的 flushPendingNotifications 再 drain 就只剩空列表,用户那边就是「消息没了」。

去掉这处 clear,交回 finally 的既有兜底路径送达(作为消息发出去);
状态清理(置 Idle)保持不变。切工作区的 stopAllAgents 与回退清队列仍按原样清空——
那两处是整段重置/回退,不是「停止任务」。
@coderabbitai

coderabbitai Bot commented Oct 4, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 59 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 0784cff1-3251-4eaa-843b-cef885cf118a
📥 Commits

Reviewing files that changed from the base of the PR and between 6c615e3 and c6481ed.

📒 Files selected for processing (22)
  • app/src/main/java/com/aicode/feature/agent/domain/shizuku/ShizukuManager.kt
  • app/src/main/java/com/aicode/feature/agent/domain/tool/file/GenerateImageTool.kt
  • app/src/main/java/com/aicode/feature/agent/domain/tool/shizuku/ShizukuTool.kt
  • app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt
  • app/src/main/java/com/aicode/feature/agent/presentation/component/MessageAttachmentComponents.kt
  • app/src/main/java/com/aicode/feature/credentials/data/repository/FileCredentialRepository.kt
  • app/src/main/java/com/aicode/feature/editor/presentation/CodeEditorScreen.kt
  • app/src/main/java/com/aicode/feature/editor/presentation/CodeEditorViewModel.kt
  • app/src/main/java/com/aicode/feature/settings/presentation/SettingsViewModel.kt
  • app/src/main/java/com/aicode/feature/settings/presentation/ShizukuViewModel.kt
  • app/src/main/java/com/aicode/feature/settings/presentation/component/AppPermissionsSection.kt
  • app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt
  • app/src/main/java/com/aicode/feature/terminal/domain/RemoteTerminalSessionManager.kt
  • app/src/main/java/com/aicode/feature/terminal/domain/SshShellBackend.kt
  • app/src/main/java/com/aicode/feature/terminal/domain/TerminalTab.kt
  • app/src/main/java/com/aicode/feature/terminal/presentation/component/TerminalScreen.kt
  • app/src/main/java/com/aicode/feature/workspace/domain/FileAccessProvider.kt
  • app/src/main/java/com/aicode/feature/workspace/domain/RemoteSftpFileAccess.kt
  • app/src/main/res/values-en/strings.xml
  • app/src/main/res/values/strings.xml
  • docs-site/docs/guide/app-permissions.md
  • docs-site/docs/guide/shizuku.md
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Rely-xcy Rely-xcy closed this Oct 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants