mirror of
https://github.com/Darkatse/TauriTavern.git
synced 2026-10-19 15:45:12 +00:00
feat: add Android back navigation
This commit is contained in:
parent
a84524e75b
commit
148f69741a
5 changed files with 265 additions and 0 deletions
|
|
@ -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`。
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
176
src/tauri/main/back-navigation.js
Normal file
176
src/tauri/main/back-navigation.js
Normal 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';
|
||||
}
|
||||
2
src/tauri/main/bootstrap.js
vendored
2
src/tauri/main/bootstrap.js
vendored
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue