feat: add Android back navigation

This commit is contained in:
Darkatse 2026-03-04 00:42:51 -05:00
parent a84524e75b
commit 148f69741a
5 changed files with 265 additions and 0 deletions

View file

@ -243,3 +243,33 @@ https://v2.tauri.app/develop/resources/#android
- 仅作用于第三方浮层与运行时动态 `<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/gen/android/app/src/main/java/com/tauritavern/client/MainActivity.kt`
- `src-tauri/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` 不足以关闭抽屉。
维护原则:
- 不修改 auto-generated 的 `src-tauri/gen/android/.../generated/*`,避免升级冲突。
- UI 分层判断与关闭动作只写在 JS;Kotlin 不写 DOM/UI 规则,只做拦截/转发/退出决策。
- 若未来新增/变更 UI 层级,只在 `back-navigation.js` 增加一个分支即可;更详细设计见 `docs/AndroidBackNavigation.md`。

View file

@ -0,0 +1,50 @@
package com.tauritavern.client
import android.webkit.WebView
import androidx.annotation.MainThread
import androidx.activity.OnBackPressedCallback
import androidx.activity.OnBackPressedDispatcher
import androidx.lifecycle.LifecycleOwner
class AndroidBackNavigationController(
private val webViewProvider: () -> WebView?,
private val exitApp: () -> Unit,
) {
fun register(dispatcher: OnBackPressedDispatcher, owner: LifecycleOwner) {
dispatcher.addCallback(
owner,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
handleBackPressed()
}
},
)
}
@MainThread
fun handleBackPressed() {
val activeWebView = webViewProvider()
if (activeWebView == null) {
exitApp()
return
}
activeWebView.evaluateJavascript(HANDLE_BACK_SCRIPT) { value ->
if (value == "true") {
return@evaluateJavascript
}
exitApp()
}
}
companion object {
private val HANDLE_BACK_SCRIPT =
"""
(() => {
const fn = window.__TAURITAVERN_HANDLE_BACK__;
return Boolean(typeof fn === 'function' && fn());
})();
""".trimIndent()
}
}

View file

@ -23,6 +23,12 @@ class MainActivity : TauriActivity() {
Thread(runnable, "tauritavern-main-bg").apply { priority = Thread.NORM_PRIORITY - 1 }
}
private var isActivityDestroyed: Boolean = false
private val backNavigationController: AndroidBackNavigationController by lazy {
AndroidBackNavigationController(
webViewProvider = { webView },
exitApp = { finish() },
)
}
private val aiGenerationNotifier: AndroidAiGenerationNotifier by lazy {
AndroidAiGenerationNotifier(applicationContext)
}
@ -81,6 +87,7 @@ class MainActivity : TauriActivity() {
initRustlsPlatformVerifier(applicationContext)
enableEdgeToEdge()
super.onCreate(savedInstanceState)
backNavigationController.register(onBackPressedDispatcher, this)
// Keep a foreground service for the whole app session to reduce OEM background kills.
aiGenerationNotifier.ensureKeepAliveService()
insetsBridge.onCreate()

View file

@ -0,0 +1,176 @@
const BACK_HANDLER_KEY = '__TAURITAVERN_HANDLE_BACK__';
export function installBackNavigationBridge() {
window[BACK_HANDLER_KEY] = handleBackNavigation;
}
function handleBackNavigation() {
if (closeTopmostDialogPopup()) {
return true;
}
if (closeOverlayByCloseButton()) {
return true;
}
if (closeFloatingPanel()) {
return true;
}
if (triggerBlankClickIfNeeded()) {
return true;
}
if (closeChatIfOpen()) {
return true;
}
return false;
}
function closeTopmostDialogPopup() {
const dialogs = Array.from(document.querySelectorAll('dialog[open]:not([closing])'));
if (dialogs.length === 0) {
return false;
}
const dialog = dialogs[dialogs.length - 1];
const closeButton = dialog.querySelector('.popup-button-close');
if (closeButton instanceof HTMLElement) {
closeButton.click();
return true;
}
if (dialog instanceof HTMLDialogElement) {
dialog.close();
return true;
}
return false;
}
function closeOverlayByCloseButton() {
const dialoguePopup = document.getElementById('dialogue_popup');
if (isVisible(dialoguePopup)) {
const cancelButton = document.getElementById('dialogue_popup_cancel');
if (isVisible(cancelButton)) {
cancelButton.click();
return true;
}
const okButton = document.getElementById('dialogue_popup_ok');
if (isVisible(okButton)) {
okButton.click();
return true;
}
return false;
}
const selectChatPopup = document.getElementById('select_chat_popup');
if (isVisible(selectChatPopup)) {
const closeButton = document.getElementById('select_chat_cross');
if (closeButton instanceof HTMLElement) {
closeButton.click();
return true;
}
return false;
}
const characterPopup = document.getElementById('character_popup');
if (isVisible(characterPopup)) {
const closeButton = document.getElementById('character_cross');
if (closeButton instanceof HTMLElement) {
closeButton.click();
return true;
}
return false;
}
const deleteMessageCancel = document.getElementById('dialogue_del_mes_cancel');
if (isVisible(deleteMessageCancel)) {
deleteMessageCancel.click();
return true;
}
const logprobsViewer = document.getElementById('logprobsViewer');
if (isVisible(logprobsViewer)) {
const closeButton = document.getElementById('logprobsViewerClose');
if (closeButton instanceof HTMLElement) {
closeButton.click();
return true;
}
return false;
}
return false;
}
function closeFloatingPanel() {
const closeButtons = Array.from(document.querySelectorAll('#movingDivs .floating_panel_close'));
for (let index = closeButtons.length - 1; index >= 0; index -= 1) {
const closeButton = closeButtons[index];
if (isVisible(closeButton)) {
closeButton.click();
return true;
}
}
return false;
}
function triggerBlankClickIfNeeded() {
const hasOpenDrawer = Boolean(document.querySelector('.openDrawer:not(.pinnedOpen)'));
const hasOptionsMenu = isVisible(document.getElementById('options'));
const hasExtraMessageButtons = Boolean(document.querySelector('.extraMesButtons.visible'));
const hasActionModal = Array.from(document.querySelectorAll('.actionButtonsModal')).some(isVisible);
if (!hasOpenDrawer && !hasOptionsMenu && !hasExtraMessageButtons && !hasActionModal) {
return false;
}
dispatchBlankClick();
return true;
}
function closeChatIfOpen() {
const welcomePanel = document.querySelector('#chat > div.welcomePanel');
if (isVisible(welcomePanel)) {
return false;
}
const closeChatButtons = Array.from(document.querySelectorAll('#option_close_chat'));
if (closeChatButtons.length === 0) {
return false;
}
const closeChatButton = closeChatButtons[closeChatButtons.length - 1];
if (!(closeChatButton instanceof HTMLElement)) {
return false;
}
closeChatButton.click();
return true;
}
function dispatchBlankClick() {
const root = document.documentElement;
root.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
root.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}
function isVisible(element) {
if (!(element instanceof HTMLElement)) {
return false;
}
if (element.getClientRects().length === 0) {
return false;
}
const style = getComputedStyle(element);
return style.visibility !== 'hidden';
}

View file

@ -2,6 +2,7 @@ import { initializeBridge, invoke, isTauri as isTauriRuntime, convertFileSrc } f
import { createTauriMainContext } from './context.js';
import { createInterceptors } from './interceptors.js';
import { createRouteRegistry } from './router.js';
import { installBackNavigationBridge } from './back-navigation.js';
import { installNativeShareBridge } from './share-target-bridge.js';
import { installLanSyncPanel } from '../../scripts/tauri/sync/sync-panel.js';
import {
@ -183,6 +184,7 @@ export function bootstrapTauriMain() {
}
bootstrapped = true;
installBackNavigationBridge();
installNativeShareBridge();
installLanSyncPanel();