Skip to content

Android Remote File Skill — 设计文档

状态:v1.0 已落地 (2026-05-17) 范围:Android remote-operate Plan C 路径下「浏览 PC 远程目录 / 上传到 PC / 下载到手机」三大能力 真机验证:Xiaomi 24115RA8EC + Windows 桌面 ✅ 浏览+上传+下载+app 内打开 已端到端走通

1. 三层定位

实现职责
Android UIFileTransferScreen.kt + 浏览面板 + 本地下载面板 + Snackbar action操作入口;MediaStore.Downloads 写公共目录;点击直接 Intent.ACTION_VIEW 拉系统 viewer
Android transportRemoteCommandClientSignalingRpcClient.invokePlan C 路径,复用 terminal 验证过的 DC fast-path + signaling fallback
PC handlerdesktop-app-vue/src/main/remote/handlers/android-file-handler.js11 个 action,无 sandbox(trusted paired peer),字段对齐 Android FileCommands.kt

2. 协议接口

PC handler handle(action, params, ctx) 派发 11 个 action:

Action用途关键参数关键返回
listDirectory列目录path, showHiddenentries[{name, path, type, size, modifiedTime, isHidden}]
getFileInfo单文件 metadatapath{exists, file{...}}
exists存在性检查path{exists, isFile, isDirectory, isSymlink}
delete删文件/目录path, recursive, force{success, path}
createDirectory建目录path, recursive{success, path}
requestUpload开始上传fileName, fileSize, metadata.targetDir?{transferId, chunkSize, totalChunks, resumeSupported}
uploadChunk上传一块transferId, chunkIndex, chunkData (base64){received, progress, remainingChunks}
completeUpload收尾上传transferId{status, fileName, filePath, fileSize, duration}
requestDownload开始下载filePath, fileName?{transferId, fileName, fileSize, chunkSize, totalChunks, checksum:null}
downloadChunk下载一块transferId, chunkIndex{chunkData (base64), chunkSize, isLastChunk, progress}
cancelTransfer取消传输transferId{transferId, status:"cancelled"}
listTransfers列在传任务limit, offset, status?{transfers[...], total}

字段对齐:所有响应字段命名与 Android FileCommands.kt @Serializable data class FileEntry 一致(type not isDirectorymodifiedTime not modifiedAtentries not items)。

3. 架构图

┌──────────────────────────────────────────────────────────────────┐
│  Android (24115RA8EC)                                           │
│                                                                  │
│  RemoteOperateScreen ──▶ FileTransferScreen                     │
│    ├ 📁 浏览远程目录   ──▶ RemoteBrowsePanel ──▶ ViewModel       │
│    │                                                             │
│    ├ ☁️↑ 上传          ──▶ ActivityResultContracts.GetContent() │
│    │                                                             │
│    ├ ☁️↓ 输入路径下载 ──▶ ViewModel                              │
│    │                                                             │
│    └ 📱 本机下载文件夹 ──▶ MediaStore.Downloads query ──▶ Intent│
│                                                                  │
│  FileTransferViewModel ──▶ FileTransferRepository ──▶ FileCommands │
│                                              │                    │
│         (MediaStore.Downloads 公共目录)      ▼                    │
│                                       RemoteCommandClient         │
│                                              │                    │
│  ─────────────────────────────────────────── │ ───────────────── │
│                                              ▼                    │
│              SignalingRpcClient.invoke(pcPeerId, method, params) │
│                                              │                    │
│                       ┌──────────────────────┴───────────────┐   │
│                       │  DC fast-path (WebRTC DataChannel)   │   │
│                       │  ↓ DC not open                       │   │
│                       │  signaling forward (WebSocket relay) │   │
│                       └──────────────────┬───────────────────┘   │
└──────────────────────────────────────────┼───────────────────────┘

┌──────────────────────────────────────────┼───────────────────────┐
│ PC desktop-app-vue                       │                       │
│                                           ▼                       │
│  mobile-bridge.js ──▶ handleMobileCommand ──▶ routeMobileCommand │
│                                            │                      │
│                                  case "file" ──▶ handleFileCommand│
│                                                          │        │
│                                            (delegate)    ▼        │
│                                         AndroidFileHandler.handle │
│                                                          │        │
│                              ┌───────────────────────────┤        │
│                              ▼                           ▼        │
│              listDirectory(real fs, no sandbox)   MediaStore... ⇧ │
│                                                                   │
│              Upload 落点: os.homedir()/Downloads/<name>          │
└───────────────────────────────────────────────────────────────────┘

4. 修复的 4 个互锁雷

Bug 1 — P2PClient.kt:538-542 chainlesschain:* skip guard 太宽

kotlin
// 旧:把 P2PClient 自己发的命令的响应也屏蔽
if (raw.contains("\"type\":\"chainlesschain:")) return

P2PClient.sendCommand 自己也用 chainlesschain:command:request envelope,但这个 guard 一刀切所有 chainlesschain:*,导致 P2PClient.pendingRequests 永远等不到 complete。

修法:缩窄成只 skip chainlesschain:command:request(让 incoming request 给 SignalingRpc 订阅者),放行 chainlesschain:command:response

Bug 2 — Plan C 路径 P2PClient.connectionState 永远 DISCONNECTED

P2PClient.sendCommand 第一行 if (_connectionState != CONNECTED) return failure("Not connected")。Plan C (RemoteOperateScreen → signaling forward) 根本没调 P2PClient.connect() → state 一直 DISCONNECTED → 所有 RemoteCommandClient 命令立即失败。

修法RemoteCommandClient.invokeTyped 改 delegate SignalingRpcClient.invoke(pcPeerId, ...),pcPeerId 从 PairedDesktopsStore.devices.firstOrNull()?.pcPeerId 取。

Bug 3 — PC handleFileCommand 是简陋 stub(弹框 + 缺 case)

desktop-app-vue/src/main/index.js:2378 的 switch 当时只有 case "list" (查 SQL 表) + case "requestUpload" (dialog.showOpenDialog 弹 PC 文件选择框)。其它全 default throw Unknown action

修法:新写 android-file-handler.jshandleFileCommand 整段替换为 delegate。

Bug 4 — FileTransferHandler(remote-gateway 注册的)sandbox + 字段不一致

_resolvePath 强 prefix app.getPath("userData")C:\Users\... 一律 Access denied。字段 dirPath/items/isDirectory 与 Android path/entries/type 不匹配。

修法:不复用,新写专用 handler。无 sandbox(trusted paired peer)。

5. 修复的 2 个 UX 坑

Bug 5 — checksum 算法不匹配 → repository 自删下载文件

第一版 requestDownload"sha256-prefix:abc..."(头部 32KB SHA256),但 FileTransferRepository.kt:264-276 期望 "md5:" + 完整 MD5,对不上立刻删本地文件 + 标 FAILED + 抛 Checksum mismatch

修法:返 checksum: null 跳过 Repository 验证。如要真验和,必须 "md5:" + crypto.createHash("md5").update(整个 fileBuffer).digest("hex")

Bug 6 — getExternalFilesDir(null) 用户找不到下载的文件

/sdcard/Android/data/com.chainlesschain.android.debug/files/downloads/ 受 Android 13+ scoped storage 限制,普通用户用文件管理器要点 5 层 + 开"显示隐藏"。

修法:API 29+ 用 MediaStore.Downloads.EXTERNAL_CONTENT_URI insert 写公共 Download 目录。返 content://media/external/downloads/<id> uri 直接喂 Intent.ACTION_VIEW 拉系统 viewer。无需 WRITE_EXTERNAL_STORAGE 权限。

6. UI 入口(TopAppBar 5 个 icon)

Icon触发功能
📁 / 📂 (Folder/FolderOpen)toggle showBrowsePanel浏览 PC 远程目录 — 顶部路径输入 + 上级/刷新 + LazyColumn 文件树
☁️↑ (CloudUpload)filePickerLauncher触发系统 GetContent 选本机文件 → 上传 PC
☁️↓ (CloudDownload)toggle showDownloadPanel输入远程路径 + 文件名手动下载
📱 (PhoneAndroid)toggle showLocalPanelApp 内 MediaStore.Downloads 列表,点击直接打开(不跳出 app)
🧹 (CleaningServices)cleanupOldTransfers(30)清理 30 天前历史

Snackbar action

  • 下载完成有 openUri → 「打开」按钮 → Intent.ACTION_VIEW(content://...) 拉 viewer
  • 上传完成有 pathHint → 「复制路径」按钮 → ClipboardManager.setText + Toast

7. 测试覆盖

7.1 PC 单测 (vitest)

desktop-app-vue/src/main/remote/__tests__/android-file-handler.test.js30 cases all passing:

  • _resolvePath ×5(~ 展开、.、绝对路径、null/非 string 抛)
  • listDirectory ×5(字段对齐 / dir 排前 / 隐藏过滤 / 非目录抛 / 断裂 symlink 跳过)
  • getFileInfo + exists ×3
  • createDirectory + delete ×3
  • Upload 全流程 ×5(3-chunk 重建 / 防覆盖 (1) 后缀 / unknown transferId / 非 base64 拒绝 / maxConcurrent 限 / metadata.targetDir
  • Download 全流程 ×3(chunk 重建 + isLastChunk + auto-cleanup / 非文件抛 / unknown transferId)
  • Bug 5 回归测requestDownload.checksum 必须为 null
  • cancelTransfer ×2(删半成品 / unknown 不抛)
  • handle() dispatch ×2(11 个已知 action 不抛 Unknown / unknown action 抛)
  • listTransfers ×1

7.2 Android 单测 (gradle :app:testDebugUnitTest)

android-app/app/src/test/java/com/chainlesschain/android/remote/client/RemoteCommandClientTest.kt4 cases all passing:

  • delegate 到 SignalingRpc + pcPeerId 取自 PairedDesktopsStore
  • 已配对桌面为空 → Result.failure("无已配对桌面")
  • SignalingRpc 失败原样传播
  • 多桌面取 firstOrNull
  • Bug 1+2 锁死coVerify(exactly = 0) 验证不走 p2pClient.sendCommand

7.3 真机 E2E 手动 reproducer

需 Xiaomi 24115RA8EC + Windows 桌面 + 同 WiFi 或公网中继。8 个场景

#场景操作通过标志
E1浏览家目录进入 📁 输入 ~ 点进入列出 PC 用户家目录子项;目录在前;隐藏文件不显示
E2浏览盘根C:\ 进入列出 C 盘根目录
E3进子目录Users<你>Downloads面包屑「当前: …」更新;面板内容刷新;↑ 上级返回
E4小文件上传☁️↑ 选 < 100KB 文件Snackbar「文件上传成功: …」+「PC: C:/Users/…/Downloads/<名>」+ 「复制路径」按钮;点复制 → Toast 路径已复制;PC 端真有文件
E5防覆盖重新上传同名文件PC 端落 (1) 后缀
E6小文件下载在 📁 里点一个 PC 端文件Snackbar「下载成功: …」+「手机 Download/<名>」+ 「打开」按钮
E7「打开」action上一步点「打开」图片→相册 / PDF→阅读器 / txt→记事本 lifecycle;不跳浏览器
E8本机下载面板点 📱 第 4 个图标LazyColumn 列出 Download 目录所有文件(含 E6 + 别处下的);每行点「打开」直接拉 viewer,不跳 DocumentsUI

8. 已知限制 / 后续工作

  • 大文件:当前走 signaling 转发 4 跳 + base64 chunk,> 10MB 可能 timeout。等 Plan A.1 WebRTC DataChannel 稳定后切 DC 路径。
  • API < 29:MediaStore.Downloads 不可用,fallback app-private 路径;普通用户找不到。未来加 FileProvider 适配。
  • destructive action (delete/writeFile) 当前无审批 gate;trusted paired peer 即可执行。未来应叠 mobileApprovalChannel(参考 marketplace.purchase / did.delegate 反向 RPC 模式)。
  • 没有 checksum 验证:靠 chunk 协议天然 1:1。下次如有错乱可加 "md5:" + 全量 MD5

9. 文件清单(新增/修改)

desktop-app-vue/src/main/remote/handlers/
  android-file-handler.js                  +460  新增
desktop-app-vue/src/main/remote/__tests__/
  android-file-handler.test.js             +330  新增 (30 cases)
desktop-app-vue/src/main/index.js          -68   handleFileCommand 替换为 delegate
android-app/app/src/main/java/com/chainlesschain/android/
  remote/ui/file/FileTransferScreen.kt     +330  浏览面板 + 本地下载面板 + Snackbar action + HelpBanner
  remote/ui/file/FileTransferViewModel.kt  +10   Success.pathHint + openUri
  remote/data/FileTransferRepository.kt    +80   MediaStore.Downloads 写公共目录 + getDownloadUri
  remote/ui/RemoteOperateScreen.kt         +9    「文件传输」按钮
  remote/client/RemoteCommandClient.kt     +28   SignalingRpc delegate + Gson roundtrip
  remote/p2p/P2PClient.kt                  -2    chainlesschain skip guard 缩窄
  navigation/NavGraph.kt                   +4    onOpenFileTransfer wire
android-app/app/src/test/java/com/chainlesschain/android/
  remote/client/RemoteCommandClientTest.kt +143  新增 (4 cases)

10. 相关文档

基于 MIT 许可发布