TauriTavern/docs/AndroidDevelopment.md

516 lines
28 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# TauriTavern Android 端开发说明
本文档记录当前移 Android 端开发中已经踩过的关键问题、根因分析、已落地方案,以及对应的架构改动。目标是避免重复踩坑,并为后续替换官方修复留出清晰迁移路径。
当前支持 Android 8.0(API 26)及以上,并要求系统 WebView/Chrome 已更新到能执行 ES2020 的版本。
## 1. Android WebView 安全区注入时机竞态
### 1.1 现象
- `#top-settings-holder` 偶发沉入状态栏。
- 现象不稳定:同一版本在不同启动时机下表现不同。
- 简单删除延时重试后,问题明显回归。
### 1.2 根因
根因不是 inset 数值计算本身,而是 **注入时机竞态**:
- Android WebView 启动阶段常经历 `about:blank -> tauri.localhost` 的页面切换。
- 若在 `about:blank` 或前端根容器(`#sheld`)尚未出现时注入 CSS 变量,后续导航/重置会丢失变量。
- 表层看是“safe area 失效”,本质是“注入到了错误上下文或过早上下文”。
参考问题:
https://github.com/tauri-apps/tauri/issues/14240
### 1.3 当前实现
核心入口仍是 `src-tauri/crates/tauritavern/gen/android/app/src/main/java/com/tauritavern/client/MainActivity.kt`,但职责已拆分为:
- `AndroidInsetsBridge.kt`:系统栏/IME inset 监听与 CSS 变量注入;
- `WebViewReadinessPoller.kt`:页面就绪轮询;
- `ShareIntentParser.kt`:分享 Intent 解析与导入文件持久化;
- `SharePayloadDispatcher.kt`:分享 payload 队列与前端 bridge 分发;
- `MainActivity.kt`:仅保留生命周期编排与模块协作。
- 保留 edge-to-edge 与透明系统栏配置(沉浸基础);
- 监听系统栏与 IME inset;
- 在 native 侧消费 IME 语义并把底部避让作为 CSS 变量注入;不再让 descendant WebView 将 IME 继续解释为 viewport resize;
- Android native 注入的 CSS 变量(provider 层):
- `--tt-inset-top/right/left/bottom`(布局应避开的有效 inset:system bars + cutout 等,沉浸模式下为 0)
- `--tt-ime-bottom`(输入法可见时的底部 inset)
- `--tt-base-viewport-height`(无 IME 时的基准 viewport 高度)
- 前端布局消费的 CSS 变量(contract 层):
- `--tt-inset-top/right/left/bottom`(有效避让 inset;iOS 由 `env()` 提供,Android 由 native 注入覆盖)
- `--tt-viewport-bottom-inset`(前端通过 `max()` 合成有效底部 inset)
Android 语义说明(以 contract 层为准):
- `--tt-inset-*` 表示**当前布局应避开的有效 inset**;
- 非沉浸模式下,它反映 system bars + `displayCutout`(刘海/打孔)的可见/稳定 inset;
- 沉浸模式下,它会回落为 `0`,允许应用顶部 UI 与第三方 fixed 浮层以 full-bleed 方式沉入状态栏区域。
- `--tt-ime-bottom` 是 Android 上唯一的键盘布局信号;WebView 本身不应再把 IME 当作页面 viewport 缩放来源。
注入时序约束:
- 注入前先检查页面就绪:
- `location.href !== 'about:blank'`
- `Boolean(document.getElementById('sheld'))`
- 未满足时进行有限次短重试。
说明:
- 这里**不以 `readyState` 作为硬门槛**:SillyTavern 启动阶段可能在 `readyState=loading` 时就触发 popup/onboarding 的 focus 流;IME ownership 路由依赖早期 bridge 可用,因此以“`#sheld` 已挂载”作为最小可靠前置条件更稳。
前端消费变量在:
- `src/style.css`(变量定义与 fallback)
- `src/css/mobile-styles.css`(顶部栏与容器定位使用 contract 变量)
### 1.4 维护原则
- 不要把“就绪态判断”误删为一次性注入。
- 不要把此问题误判为纯 CSS 问题;先验证变量是否被注入到正确页面上下文。
- 若后续 Tauri 官方修复 WebView safe-area 注入时序,可再评估收敛逻辑。
---
## 2. Android 资源访问语义差异(APK assets)
### 2.1 官方语义
Tauri 官方说明:Android 资源位于 APK assets,不是普通文件系统路径,返回值可能为 `asset://localhost/...`,需要通过 fs 插件语义访问。
https://v2.tauri.app/develop/resources/#android
### 2.2 过去的问题
- 模板文件读取失败(如 popup/template 相关异常)。
- 默认内容索引读取失败(`default/content/index.json` not found)。
- 直接按“普通路径”处理资源导致跨平台行为不一致。
### 2.3 架构改动(资源层收敛)
#### A. 构建期生成资源索引与嵌入映射
`src-tauri/crates/tauritavern/build.rs` 现在会:
- 扫描 `../default/content` 和 `../src/scripts/templates`;
- 生成 `default_content_manifest.json`(默认内容清单);
- 生成 `embedded_resources.rs`(虚拟路径 -> `include_bytes!` 映射)。
#### B. 运行时统一资源访问入口
`src-tauri/crates/tauritavern/src/infrastructure/assets.rs` 提供统一 API:
- `read_resource_bytes`
- `read_resource_text`
- `read_resource_json`
- `copy_resource_to_file`
- `list_default_content_files_under`
平台策略:
- Android:优先走构建期嵌入资源映射;
- 非 Android:走 `BaseDirectory::Resource` + fs 访问。
#### C. 前后端模板读取解耦
- 后端新增命令:`read_frontend_template`
文件:`src-tauri/crates/tauritavern/src/presentation/commands/bridge.rs`
- 前端模板加载改为 Tauri 环境下优先 invoke:
文件:`src/scripts/templates.js`
#### D. 默认内容初始化改为“资源 -> 真实文件”复制流程
`src-tauri/crates/tauritavern/src/infrastructure/repositories/file_content_repository.rs` 不再依赖资源目录的直接文件路径语义,改用统一资源接口复制到用户目录。
---
## 3. iOS / Android 应用数据目录解析异常
### 3.1 问题背景
在移动端,Tauri 提供的目录 API 在不同平台/版本可能与预期目录不一致。
已确认 Android 存在已知问题:`appDataDir/localDataDir` 可能返回内部路径(如 `/data/user/0/...`)而非外部 app 目录(如 `/storage/emulated/0/Android/data/...`)。
### 3.2 当前方案:单点路径解析抽象
新增单点路径解析模块:
`src-tauri/crates/tauritavern/src/infrastructure/paths.rs`
统一入口:
- `resolve_app_data_dir(app_handle)`
当前行为:
- Android:优先使用 `app_data_dir`,仅当其落在内部目录(如 `/data/user/0/...`)时,自动回退到从 `document_dir` 推导外部 app data 目录;
- 其他平台(含 iOS):回退到标准 `app_data_dir`。
### 3.3 架构收益
- 所有仓储与应用数据根路径都通过同一函数解析;
- 平台差异被收敛到一个模块,不向业务层扩散;
- 未来若 iOS 出现类似目录异常,可在同一模块增加 `cfg(target_os = "ios")` 分支,不需要修改各仓储。
### 3.4 公共 Downloads 导出不是应用数据目录问题
角色卡、Preset、WorldInfo 等前端普通导出遵循浏览器下载语义:用户点击导出后,文件应出现在 Android 公共下载目录(通常显示为 `/storage/emulated/0/Download`),而不是 app-scoped 外部目录。
需要特别区分:
- `appDataDir/localDataDir` 问题处理的是应用数据根目录;
- Tauri Android 的 `downloadDir()` 可能解析到 `/storage/emulated/0/Android/data/<package>/files/Download`,这对应用私有文件是合理位置,但不是用户导出文件的成功目标;
- Tauri fs capability 只表达 Tauri 插件 allowlist,不等价于 Android scoped storage 的系统级公共目录写入授权。
当前方案:
- 普通 Blob 下载主链仍收口在前端 `download()` / `downloadBlobWithRuntime()`,保持 SillyTavern 上游调用语义;
- 前端先把 Blob 分块写入 app cache 下的 `tauritavern-export-staging`,native bridge 只接受该 staging 根下的 canonical file;
- Android 10+ 使用 native `MediaStore.Downloads` 写入公共 Downloads;
- Android 7-9 无可靠的无权限公共 Downloads 裸路径写入语义,回退到 SAF `ACTION_CREATE_DOCUMENT`,由用户选择保存目标;
- 不再把 app-scoped `Download` 作为普通导出的 fallback;公共下载写入失败必须向前端暴露错误。
维护原则:
- 不要通过修改 `infrastructure/paths.rs` 或强行拼接 `/storage/emulated/0/Download` 来实现普通导出;
- 不要给普通导出引入 `MANAGE_EXTERNAL_STORAGE` 或宽泛存储权限;
- 如需新增 Android 用户可见文件导出,优先复用 native public download bridge,而不是直接使用 Tauri `downloadDir()`。
---
## 4. 与上述问题相关的关键架构调整
### 4.1 基础设施层
- 新增 `infrastructure::assets`(资源读取/复制统一抽象)
- 新增 `infrastructure::paths`(应用数据目录统一抽象)
- `infrastructure::mod.rs` 导出上述模块
### 4.2 应用初始化与数据根目录
- `src-tauri/crates/tauritavern/src/app.rs` 的 `resolve_data_root` / `resolve_log_root` 已改为依赖 `resolve_app_data_dir`
### 4.3 资源协议访问权限
- `src-tauri/crates/tauritavern/src/lib.rs` 在 setup 阶段对 `data_root` 执行:
- `asset_protocol_scope().allow_directory(&data_root, true)`
- 目的:允许 WebView 通过 asset 协议访问用户数据文件,避免前端资源加载 403。
### 4.4 前端接入点
- `src/scripts/templates.js`:模板读取在 Tauri 环境下走 `invoke('read_frontend_template')`
- `src/css/mobile-styles.css` + `src/style.css`:通过 `--tt-inset-*` 消费布局契约
---
## 5. 后续迁移与清理建议
1. **Tauri 官方修复目录 API 后**
`infrastructure/paths.rs` 会自动优先使用修复后的 `app_data_dir`,无需在仓储层做分散修补。
2. **Tauri 官方修复 WebView safe-area 注入后**
可评估简化 `MainActivity` 的“页面就绪后注入”逻辑,但必须先验证不会回归 `about:blank` 时序竞态。
3. **新增移动端特性时**
优先复用现有单点抽象(`assets.rs` / `paths.rs` / `MainActivity.kt`),避免再次把平台差异扩散到业务代码。
---
## 6. AI 生成后台执行与通知生命周期
Android AI 生成使用任务级 `dataSync` Foreground Service。Rust `ChatCompletionService` 持有生成任务与后台执行租约;WebView 只负责消费流事件,并在 Android 16+ 可用时补充非关键的 token 进度。
执行契约:
- 第一个 Chat Completion 任务开始时,由 Rust 通过原生 Tauri plugin 启动 `AiGenerationForegroundService`;
- 并发任务通过稳定 task id 计数,共享一个 FGS;
- 最后一个任务成功、失败或取消后立即 `stopForeground()` + `stopSelfResult()`;
- Service 使用 `START_NOT_STICKY`,不会在没有真实生成任务时被系统复活;
- Android 15+ `dataSync` 超时时通过 `onTimeout()` 立即释放 FGS,但不把平台保护到期升级为生成失败;
- 应用启动本身不再启动保活服务,避免无任务时消耗 Android 的后台 FGS 配额。
- 原生插件不可用或 FGS 启停失败只记录警告;Chat Completion 仍按真实 provider 结果继续,不阻塞应用启动或生成。
当前通知槽位:
- `42000`:前台服务保活通知,只维持 Android 后台执行契约;
- `42001`:生成完成通知,表示需要用户知晓的一次完成/失败结果。
生命周期契约:
- 应用前台可交互态定义为 `Activity resumed && window focused`;
- 应用进入前台可交互态、冷启动、或收到新的 launch intent 时,只清除 `42001`;
- Rust 任务结束时,如果应用已经前台可交互、请求为 quiet 或任务被取消,则不发布完成通知;
- 发布新的完成通知前先清除旧的 `42001`,避免 fixed notification id 上的静默复用;
- 完成通知不使用 `onlyAlertOnce`;保活/进度通知仍可使用,避免 token 进度频繁打扰。
维护原则:
- 不要用 `cancelAll()` 清通知,避免误伤系统或未来扩展通知;
- 不要把 native completion 能力绑定到 Android 16+ `ProgressStyle`,旧版 Android 也需要完成通知生命周期;
- 不要把 FGS 的结束重新绑定到 WebView 回调;WebView 被系统挂起时,Rust 任务仍必须能够独立释放原生租约;
- 不要让前端承担 Android 通知栏清理职责,前端应继续保持上游 SillyTavern 的事件语义。
---
## 7. 插件系统(前端)移动端兼容补丁
以下问题仅在 Android 旧 WebView 上高概率出现,桌面端通常不复现。
### 7.1 JavaScript 运行时兼容
#### `*.at is not a function`
现象:
- 第三方插件初始化报错(典型如 `g.at is not a function`)。
根因:
- 插件构建产物使用了较新的 JS API(`Array/String.at`、`toSorted`、`findLastIndex` 等)。
- 旧 Android WebView 缺少这些 API。
已落地方案:
- 在 Tauri mobile 启动期安装运行时兼容层:
- 实现:`src/tauri/main/compat/mobile/mobile-runtime-compat.js`
- 入口:`src/tauri/main/bootstrap.js`(仅 Android/iOS UA)
- 行为:基础 API 仅在缺失时补齐,且只执行一次;桌面端/移动端 Web 不启用。
#### Web Clipboard 写入被拒绝
Android WebView 可能暴露 `navigator.clipboard.writeText()`,却在调用时以 `NotAllowedError` 拒绝写入。TauriTavern 第一方复制在所有平台统一走 `writeClipboardText()`;Android 兼容层只把上游 Web Clipboard 的 `writeText` 映射到同一原生写入器,并保留 Clipboard 对象上的其他方法。
原生侧只授予 `clipboard-manager:allow-write-text`。写入剪贴板不需要 Android manifest 或运行时权限,也不开放读取能力;失败直接返回调用方,不静默回退。
### 7.2 插件面板样式大面积失效(如 `TH-custom-tailwind` 布局错乱)
现象:
- 插件 CSS 文件请求成功,但大量样式未生效,界面排布混乱。
根因:
- 旧 Android WebView 对 CSS Cascade Layers(`@layer`)支持不完整。
- 采用 Tailwind v4 打包的插件会把大量规则放在 `@layer` 中,导致整层失效。
已落地方案:
- 在 `src/scripts/extensions/runtime/third-party-runtime.js` 的样式加载链路中:
- 先探测当前 WebView 是否支持 `@layer`;
- 不支持时为样式 URL 附加 `ttCompat=layer`;由 Rust 端点返回展平后的 CSS bytes。
性能策略:
- 支持 `@layer` 的环境走快路径,不改写 URL;
- 不再在前端预取/Blob 注入,避免低端设备 CSS AST 处理导致的卡顿与超时。
### 6.3 JS-Slash-Runner 脚本弹窗贴顶(关闭按钮落入状态栏)
现象:
- 某些脚本运行后弹窗顶部被状态栏遮挡,关闭按钮不可点击。
根因:
- 脚本运行时直接向主文档注入 `<style>`;
- 规则常见为 `position: fixed` + `top: 0`,绕过了扩展 CSS 资源链路中的现有修正。
已落地方案:
- 在 Tauri mobile 安装第三方 surface classifier + CSS contract:
- JS classifier(admission-time):
- 分类/契约输出:`src/tauri/main/compat/mobile/mobile-overlay-surface-admission.js`
- 观察与有界 settle window:`src/tauri/main/compat/mobile/mobile-overlay-compat-controller.js`
- 同源 iframe bridge:`src/tauri/main/compat/mobile/mobile-iframe-viewport-contract-bridge.js`
- 入口:`src/tauri/main/bootstrap.js`(仅 Android/iOS UA)
- 策略:观察 `document.body` 直系子节点增删,并对 `script_id` portal root 扫描其子树;对已跟踪候选仅监听自身生命周期属性(`class/style/hidden/open/aria-hidden`)以撤销/恢复 host-admitted contract,属性重算按 animation frame 合并;稳定的 `free-window` 只响应 inline lifecycle style(`display/visibility/position/pointer-events/cursor/touch-action`)变化,几何类 style 写入保持在拖动热路径之外;对命中元素分类并输出:
- `data-tt-mobile-surface="backdrop|viewport-host|fullscreen-window|free-window|edge-window"`
- `data-tt-mobile-surface-admitted="1"`(host-private sentinel)
- `--tt-original-top=<px>`(仅 edge-window)
- 显式 opt-in:若节点已带 `data-tt-mobile-surface`,将尊重并不再改写。
- CSS contract:由 `src/tauri/main/compat/mobile/mobile-geometry-firewall.js` 提供 `[data-tt-mobile-surface="..."]` 的几何规则,统一执行 safe-area 约束(backdrop 保持 full-bleed)。
- 依赖:`--tt-inset-* / --tt-viewport-bottom-inset` 表示当前布局策略;非沉浸模式下会提供 safe-area 避让,沉浸模式下回落为 `0`,因此 contract 会自然退化为 full-bleed。
设计约束:
- 仅 Tauri mobile 生效;
- 仅作用于第三方顶层浮层/窗口 surface,不改写全局 `<style>` 文本与静态主样式文件;
- 明确排除 `body/#sheld/#chat` 等应用核心容器,避免牵连应用本体布局;
- 不侵入第三方扩展资源加载链路(与 `third-party-runtime.js` 解耦)。
---
## 7. Android 返回键分层返回(Back Navigation)
问题:
- Android 端按系统返回键可能直接退出应用(未能按“退一层 UI”关闭弹窗/抽屉/聊天)。
- 不要依赖覆盖 `Activity.onBackPressed()`:在较新的 Android(predictive back / `OnBackInvokedDispatcher`)路径下,Back 分发优先走 `OnBackPressedDispatcher`,`super.onBackPressed()` 也可能绕开子类 override。
当前方案(Native→JS Back Bridge):
- `MainActivity` 在 `onCreate()` 里向 `onBackPressedDispatcher` 注册回调,并把 Back 交给 `AndroidBackNavigationController`:
- `src-tauri/crates/tauritavern/gen/android/app/src/main/java/com/tauritavern/client/MainActivity.kt`
- `src-tauri/crates/tauritavern/gen/android/app/src/main/java/com/tauritavern/client/AndroidBackNavigationController.kt`
- controller 通过 `WebView.evaluateJavascript` 调用前端全局函数:`window.__TAURITAVERN_HANDLE_BACK__()`。
- JS 返回 `true`:表示已消费 Back(关闭了一层 UI),原生不退出。
- JS 返回 `false`:表示前端未消费,原生执行 `finish()` 退出。
前端分层关闭策略:
- Back 逻辑集中在 `src/tauri/main/back-navigation.js`,并在 `src/tauri/main/bootstrap.js` 启动早期安装。
- 关闭动作必须复用现有 UI 的关闭入口(点击 close/cancel 或触发既有的“点空白收起”逻辑),避免引入新的状态机。
- “点空白收起”要模拟 `mousedown`(SillyTavern 绑定在 `html` 的 `touchstart/mousedown` 上),仅派发 `click` 不足以关闭抽屉。
维护原则:
- `src-tauri/crates/tauritavern/gen/android/.../generated/*` 是由 Cargo.lock 锁定的 Tauri/Wry 构建脚本重建的派生物,不纳入版本控制,也不承载本地语义。
- UI 分层判断与关闭动作只写在 JS;Kotlin 不写 DOM/UI 规则,只做拦截/转发/退出决策。
- 若未来新增/变更 UI 层级,只在 `back-navigation.js` 增加一个分支即可;更详细设计见 `docs/AndroidBackNavigation.md`。
---
## 8. Android WebView 页面 Fullscreen API
问题:
- 桌面端嵌入页面的全屏按钮可正常进入全屏;
- Android 端同一路径报错:`Fullscreen is not supported (TypeError)`。
根因:
- Android WebView 的 DOM Fullscreen 最终依赖 `WebChromeClient.onShowCustomView/onHideCustomView`;
- 当前生成的 `RustWebChromeClient.kt` 直接在 `onShowCustomView()` 里调用 `callback.onCustomViewHidden()`,等价于显式拒绝网页全屏;
- `src/scripts/html-code-preview.js` 创建的预览 `iframe` 未声明 fullscreen 权限,嵌入页面即使调用 `requestFullscreen()` 也缺少宿主授权。
原始问题定位:
- 新增 `AndroidWebFullscreenController.kt`,负责:
- 将 WebView 请求的 custom view 挂到 Activity 内容根节点;
- 全屏期间强制开启 immersive system bars,退出时恢复先前状态;
- 暴露 `hide()`,让 Android 返回键优先退出网页全屏。
- `MainActivity.kt` 实现 `AndroidWebFullscreenHost`,只做生命周期编排与 controller 委托。
- `AndroidBackNavigationController.kt` 新增 native back 优先消费点,先尝试退出网页全屏,再决定是否把返回键交给前端/退出应用。
- `RustWebChromeClient.kt` 仅保留最小补丁:
- `onShowCustomView()` 转发到 `AndroidWebFullscreenHost`
- `onHideCustomView()` 转发到 `AndroidWebFullscreenHost`
- `src/scripts/html-code-preview.js` 为预览 `iframe` 增加 `allowfullscreen` / `allow="fullscreen"`。
### 8.1 进一步的架构收敛
上面的 fullscreen 逻辑本身没有问题,真正的问题是挂载位置:
- `RustWebChromeClient.kt` 来自 Wry Android 生成链;
- 直接改 `src-tauri/crates/tauritavern/gen/android/.../generated/RustWebChromeClient.kt` 会在重新生成 Android 工程时被覆盖;
- `MainActivity.onWebViewCreate()` 又不是一个可靠的运行时替换点,因为 Wry 后续仍会再次调用 `setWebChromeClient(...)`。
因此,fullscreen 的正式方案不应继续依赖“修改 generated 文件”,而应改为:
- 在项目源码中提供本地 `com.tauritavern.client.RustWebChromeClient`;
- 在 Android Gradle 构建中排除 generated 版本参与编译;
- 让 Wry native 侧继续通过原有类名加载,但实际落到项目自维护实现。
### 8.2 正式维护原则
- 不再手改 `generated/RustWebChromeClient.kt`;
- local `RustWebChromeClient.kt` 只承担 Wry fullscreen 边界转发,不承载 fullscreen 状态机;
- fullscreen 业务逻辑必须继续留在自维护文件(`MainActivity.kt` / `AndroidWebFullscreenController.kt`),不要把状态机堆回 generated 文件;
- 不做前端 fullscreen polyfill 或静默降级,失败直接暴露,便于定位真实链路问题;
- 未来升级 Tauri / Wry 时,只需要对比 upstream 的 `RustWebChromeClient.kt` 与本地替代版本的差异。
### 8.3 Wry Android 生成层所有权
`generated/*` 只是一份可重建的构建输出。项目实际维护的 Wry 分叉只有 generated 目录外、同 package 同类名的两个文件,Gradle 排除对应 generated 类以避免重复编译:
- `RustWebChromeClient.kt`:fullscreen 转发与结构化 WebView 日志;
- `RustWebViewClient.kt`:主文档导航通知、拦截失败响应,以及 Host Resource 显式缓存策略优先级。
两个文件头必须记录当前 Wry baseline。升级 Wry 时逐文件与锁文件解析到的 upstream 模板比较;缺少显式 `Cache-Control` 的自定义协议响应采用 Wry 的 `no-store` 默认值,Host Resource 已明确返回的 `private, no-cache` 或错误 `no-store` 不得被 transport 层覆盖。删除 generated 目录后,debug 与 minified release 构建都必须能够从零重建。
`app/tauri.build.gradle.kts` 同样是 ignored 派生物:`tauri-build` 2.6.3会在其中声明 `androidx.lifecycle:lifecycle-process:2.10.0`。tracked `app/build.gradle.kts` 只应用该脚本,不重复维护依赖版本;从空生成目录完成 canonical debug/release构建用于证明生成顺序和依赖闭合。
---
## 9. Android WebView 视频背景 Range 语义差异(SillyTavern-VideoBackgrounds)
现象:
- Android 端 `<video src="/backgrounds/*.mp4">` 无法进入播放,页面表现为“只有一个大播放按钮”。
- DevTools 常见表现为:
- 早期出现 `416 Range Not Satisfiable` 或某个 `Range: bytes=...-` 请求被快速 canceled;
- `<video>` 长时间停留在 `readyState=HAVE_NOTHING`,无法触发 `loadedmetadata`。
根因(运行时语义差异):
- Android WebView 在 `shouldInterceptRequest` 的资源拦截链路中,会对“拦截返回的响应流”再次应用请求 Range 语义。
- 若宿主已经按 Range 做了 seek/slice(返回已经截取过的 bytes),WebView 的二次 Range 会把非 0 起点范围再次应用到截取后的流上,导致不可满足(历史上表现为 `416` / canceled)。
当前已落地 workaround(仅背景视频):
- 实现:`src-tauri/crates/tt-application/src/services/host_resource_service/user_data.rs`
- 策略:对 Android + `/backgrounds/*` + `video/*` + `Range start != 0`:
- 返回 `206` + 正确 `Content-Range/Content-Length`
- body 提供完整文件 bytes,让 WebView 自己在流上执行 Range(skip)
更多细节与全平台媒体契约见:`docs/CurrentState/MediaAssetContract.md`。
---
## 10. Android Skill 导入文件选择
现象:
- 桌面端 Agent System 的 Skill 导入会弹出系统文件选择器;
- Android 端不能依赖桌面 `dialog.open` 返回普通文件路径,系统选择器返回的是 `content://` URI。
当前契约:
- `api.skill.pickImportArchive()` 与 `pickImportArchives()` 统一使用 Tauri dialog 的 Android 文件选择能力,分别取得一个或多个 `content://` URI;
- 该能力由 `capabilities/system-file-picker.json` 授予 android 平台的 `dialog:allow-open`;
- 前端 `android-archive-service.js` 逐个把 URI 物化到 app cache/temp 下的 `tauritavern-skill-import-staging`;
- Host API 对 UI 只返回一个或多个 `{ kind: 'archiveFile', path }`,保持 Skill 后端只消费普通文件路径;
- 如果用户放弃某个输入,UI 调用 `api.skill.discardPickedImport(input)`;放弃整个批次时调用无参数的 `discardPickedImport()`。`installImport()` 完成后会自动清理对应输入。
维护原则:
- 新增平台分支调用 `plugin:*|*` 时,必须同步扩展对应 capability 的 `platforms`。`removeUnusedCommands` 会把未授权平台的命令直接裁掉,缺口只会在实机上以 ACL 错误暴露。
- 不把 `content://` 透传到 Rust Skill repository;仓储层只处理真实路径与归档内容。
- 导入不把完整文件整体 base64 物化,避免把内存占用集中到 JS heap 与单个 IPC payload。
- 选择器取消不是错误;staging、预览、安装、清理失败都应直接暴露,避免静默遗留坏状态。
---
## 11. Android 大型 byte ingress
Tauri 2.11.5 在 Android 上仍不支持 `InvokeBody::Raw`。业务 payload 进入完整 invoke envelope 后,nested `Uint8Array` 会被 JSON serializer 展开为 `number[]`;大型 payload 会因此产生不可接受的逐 byte 对象化内存开销。
聊天 full-save 的正式契约是:
```text
begin_chat_commit(logical target, force)
-> bounded append_chat_commit_chunk
-> Android: { data: base64 }, one-in-flight
-> Desktop/iOS: root raw Uint8Array
-> finish_chat_commit(expectedSize, commitReason)
-> target-local strict rename publish
```
维护原则:
- Android 的大型 JS -> Rust bytes 不得以 raw/numeric-array payload 穿过 Tauri invoke。
- base64 只能按 host 返回的 frame cap 逐帧生成和发送,不得先物化完整文件的 base64 表示。
- 每个文件严格逐帧 await,并校验 raw byte offset ACK 与 finish size;失败不得回退旧 plugin-fs raw 路径。
- renderer 只持有 opaque session ID,不接收 staging/target host path;Rust repository 在目标卷的 `.staging/chat-commits` 内独占 staging 生命周期。
- finish 只允许 strict rename;失败显式返回,不得 copy 到 current。启动时仅清理该专用 staging 子树。
- generic `stage_upload_*` 继续服务 avatar、import 与普通 Blob 物化,不参与聊天 full-save。
- bounded base64 达到实机目标后即停止;只有 profile 证明其仍是主要热点时,才评估 text frame 或 AndroidX binary bridge。
Tauri 版本升级后也必须重新验证运行时 envelope,不能仅凭 API 表面接受 `Uint8Array` 就假定 Android Raw IPC 已可用。
## 12. LAN Sync 多播发现
Android 的 Rust mDNS 发现需要 `CHANGE_WIFI_MULTICAST_STATE` 权限。宿主只在前台持有 `MulticastLock`,进入后台或停止发现时释放,回到前台后为已启动的发现重新获取;网络逻辑由 Rust 负责。
平台适配见 [LanDiscoveryPlugin](../src-tauri/crates/tauritavern/gen/android/app/src/main/java/com/tauritavern/client/LanDiscoveryPlugin.kt),功能边界见[同步总览](CurrentState/Sync.md)。