Android Studio now ships JBR 25, which Gradle 8.14 cannot run on. Follow the Tauri 2.12.3 template: Gradle 9.6.1, AGP 9.3.1 and Kotlin Gradle Plugin 2.2.10. The Rust build task runs the CLI through ExecOperations, since Gradle 9 removed Project.exec, and the build uses the configuration cache. Built-in Kotlin and the new DSL stay off while Tauri and its plugins apply kotlin-android. Keep the default ProGuard rules, which already keep every @JavascriptInterface method, and drop the redundant rule for one bridge. AGP 9 no longer keeps the constructors of kept classes, so keep the ComponentRegistrar constructors ML Kit instantiates by reflection. Remove test dependencies no sources use. compileSdk and targetSdk stay at 36: targeting 37 needs a local network permission flow first. Validation: pnpm run check; debug and minified release builds from an empty generated directory on JBR 25, and a release smoke test of both JS bridges and the native plugins on an x86_64 emulator.
21 KiB
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。
清单中的 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.jsonnot 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_bytesread_resource_textread_resource_jsoncopy_resource_to_filelist_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. 后续迁移与清理建议
-
Tauri 官方修复目录 API 后
infrastructure/paths.rs会自动优先使用修复后的app_data_dir,无需在仓储层做分散修补。 -
新增移动端特性时 优先复用现有单点抽象(
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。
- 先探测当前 WebView 是否支持
性能策略:
- 支持
@layer的环境走快路径,不改写 URL; - 不再在前端预取/Blob 注入,避免低端设备 CSS AST 处理导致的卡顿与超时。
6.3 第三方面板可达性
脚本、portal、iframe 与 <dialog> 的 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.ktsrc-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()退出。
- JS 返回
前端分层关闭策略:
- 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()转发到AndroidWebFullscreenHostonHideCustomView()转发到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 端
<video src="/backgrounds/*.mp4">无法进入播放。默认 poster 是透明的,不能凭播放按钮判断是否已加载。 - 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 文件导入与导出
Android 是文件传输的一个平台实现,与鸿蒙共用 URI 复制代码。ContentUriPlugin 负责查询原文件名。升级 Tauri 或 dialog/fs 插件后,要用 release 构建验收两点:Rust 调用原生插件是否正常;从云盘 provider 选取时,文件名与内容是否正确。
图片长按保存:Android WebView 没有图片上下文菜单,由 download-bridge.js 在页面层补上 <img> 的 contextmenu 默认行为,契约见 FrontendHostContract §5.7。之所以放在页面层,是因为原生长按菜单会先消费长按,页面和扩展就收不到 contextmenu。Chromium 将来如果默认提供图片菜单,这一补充即可删除。
11. Android 大型 byte ingress
Tauri Android 当前不支持 raw byte invoke;嵌套 Uint8Array 会被编码为数字数组,放大内存占用。
聊天与世界书使用共享提交会话:
- 按 host 帧上限仅编码当前帧的 base64,收到 ACK 后再发送下一帧;失败不回退 raw 传输。
- 前端只持有会话 ID,暂存与目标路径由 Rust 管理。
- 普通文件上传与 Blob 导出使用
stage_file_*,不承担文档发布。
升级 Tauri 后需在真机验证传输支持,不能仅依据 API 类型判断。
12. LAN Sync 多播发现
Android 的 Rust mDNS 发现需要 CHANGE_WIFI_MULTICAST_STATE 权限。宿主只在 Activity 处于前台时持有 MulticastLock:停止发现或插件收到 onPause(activity) 时释放,Activity resume 后由 Rust 为已启动的发现重新获取;网络逻辑由 Rust 负责。插件构造参数保持 Activity,与 Tauri 的 JNI 加载签名一致。
平台适配见 LanDiscoveryPlugin,功能边界见同步总览。
13. Tauri Pilot
pnpm run android:dev:pilot 构建带 Pilot 的 debug 包。插件在应用内监听 abstract socket,名字随每次启动变化,CLI 无法自动发现;页面加载后转发到私有目录,并用 TAURI_PILOT_SOCKET 指给 CLI:
name=$(adb shell cat /proc/net/unix | grep -o 'tauri-pilot-com\.tauritavern\.-[0-9a-f]*\.sock' | tail -1)
dir=$(mktemp -d /tmp/tauri-pilot.XXXXXX)
adb forward "localfilesystem:$dir/pilot.sock" "localabstract:$name"
export TAURI_PILOT_SOCKET="$dir/pilot.sock"
tauri-pilot ping
应用重启后旧转发失效,重新执行以上命令;结束时 adb forward --remove "localfilesystem:$dir/pilot.sock" 并删除该目录。多台设备时给每条 adb 命令加 -s <serial>。Android 上没有 press,文字输入用 fill 或 type。