mirror of
https://github.com/Darkatse/TauriTavern.git
synced 2026-10-15 14:04:18 +00:00
516 lines
28 KiB
Markdown
516 lines
28 KiB
Markdown
# 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)。
|