# TauriTavern Android 端开发说明 本文档记录当前移 Android 端开发中已经踩过的关键问题、根因分析、已落地方案,以及对应的架构改动。目标是避免重复踩坑,并为后续替换官方修复留出清晰迁移路径。 当前支持 Android 8.0(API 26)及以上,并要求系统 WebView/Chrome 已更新到能执行 ES2020 的版本。 ## 构建工具链 Gradle 工程跟随 Tauri CLI 的 Android 模板,当前对齐 2.12.3:Gradle 9.6.1、AGP 9.3.1、Kotlin Gradle Plugin 2.2.10。升级 Tauri 时按模板差异同步;AGP 版本同时写在根 `build.gradle.kts` 和 `buildSrc/build.gradle.kts`,两处一起改。 - `compileSdk`、`targetSdk` 为 36。targetSdk 37 起访问局域网需要 `ACCESS_LOCAL_NETWORK` 运行时权限,提升前先建立统一的本地网络权限入口。 - 开启 configuration cache:`BuildTask` 经 `ExecOperations` 调用 CLI,执行阶段不访问 `Project`。 - `android.builtInKotlin=false`、`android.newDsl=false` 与模板一致,因为 Tauri 与官方插件仍应用 `org.jetbrains.kotlin.android`。 - release 沿用 `proguard-android-optimize.txt`,`@JavascriptInterface` 方法由其中的默认规则保留;`proguard-rules.pro` 只补依赖缺失的规则,原因写在规则旁。 ## 1. 原生内容视口与窗口背景 `AndroidWindowLayout` 拥有主 WebView 的矩形:系统栏、刘海和停靠键盘在原生层消费,网页按普通视口排版。`WindowLayoutPlugin` 供 Rust 读取窗口快照、提交背景条带。职责、提交时机与背景链路见 [CurrentState/MobileStyleAdaptation.md](CurrentState/MobileStyleAdaptation.md)。 清单中的 `adjustResize` 只供 androidx 在 API 26-29 上推导键盘 inset;布局不依赖系统缩放窗口。 ## 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`,生成 `default_content_manifest.json`(默认内容清单); - 需要内嵌资源时,把前端模板与内置扩展的 HTML 模板写入 `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 与 OpenHarmony:`build.rs` 发出 `embedded_resources`,只读嵌入映射; - 其他平台:走 `BaseDirectory::Resource` + fs 访问;portable 构建在磁盘未命中时回退到嵌入映射。 #### C. 前后端模板读取解耦 - 后端新增命令:`read_frontend_template` 文件:`src-tauri/crates/tauritavern/src/presentation/commands/bridge.rs` - 前端模板加载改为 Tauri 环境下优先 invoke: 文件:`src/scripts/templates.js` - 内置扩展模板只在嵌入映射中,桌面 bundle 不含,所以 `templates.js` 仅在 Android 上用 `read_frontend_extension_template` 读取,其他宿主走 fetch。 #### 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")` 分支,不需要修改各仓储。 --- ## 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`:按已经避让的浏览器视口排版,`--doc-height` 为静态 CSS 别名 --- ## 5. 后续迁移与清理建议 1. **Tauri 官方修复目录 API 后** `infrastructure/paths.rs` 会自动优先使用修复后的 `app_data_dir`,无需在仓储层做分散修补。 2. **新增移动端特性时** 优先复用现有单点抽象(`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`(仅移动宿主) - 行为:基础 API 仅在缺失时补齐,且只执行一次;桌面宿主不启用。 #### 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 第三方面板可达性 脚本、portal、iframe 与 `` 的 `top: 0` 就是内容视口顶部,`100vh` 随停靠键盘收缩。只有某个面板越界时,检查它自己的硬编码尺寸、负坐标或滚动裁剪;所有内容一起偏移时,检查原生 WebView 矩形。 --- ## 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 内容根节点; - 全屏期间隐藏系统栏;这是独立的展示状态,不改变用户沉浸偏好与主 WebView 矩形; - 暴露 `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 转发与 JSONL MIME 补充;Activity result 与权限 launcher 归上游 `WryActivity`,本类只调用其接口; - `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` 会在其中声明 `androidx.lifecycle:lifecycle-process`。tracked `app/build.gradle.kts` 只应用该脚本,不重复维护依赖版本;从空生成目录完成 canonical debug/release 构建用于证明生成顺序和依赖闭合。 --- ## 9. Android WebView 视频背景 Range 语义差异(SillyTavern-VideoBackgrounds) 现象: - Android 端 `