# 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//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 脚本弹窗贴顶(关闭按钮落入状态栏) 现象: - 某些脚本运行后弹窗顶部被状态栏遮挡,关闭按钮不可点击。 根因: - 脚本运行时直接向主文档注入 `