From db8cdd7dd1ef7f0311e8e03e7704ebaca23e2d1a Mon Sep 17 00:00:00 2001 From: WHC Date: Sun, 11 Oct 2026 21:44:33 +0800 Subject: [PATCH] the back key: one C++ path, so a dialog closes too A phone's back key did not close the settings dialog. A window-level QML Shortcut never sees the key while a modal popup is up, and a desktop run says so plainly: with the dialog open, Back left backLayer() at "settings", while invokeMethod(win, "stepBack") closed it on the spot and Escape -- the popup's own CloseOnEscape -- closed it too. So the key reaches the popup and never reaches the shortcut map, which is why the drawer (no popup) closed fine and hid the problem on a phone. A Keys handler on the window's root item is no help either: nothing reached it, the focused TextField keeps the key. So the key is read in C++, on the application object but only for this window's events -- a filter installed on the window itself was never called once, while the same key closed the drawer through the shortcut, hence the application object. With a layer open, the window's own backLayer() names it and stepBack() closes it, press and release both taken; with nothing open the key is passed on untouched, which is what leaves the window on a phone and does nothing on a desktop. Main.qml keeps only those two functions: the Shortcut and backHasLayer are gone, --uitest installs the same handler main() does, and the back checks now drive the code the phone runs instead of a shortcut only the test had. The last check asks the phone's own question and can end the run without the closing line -- the activity leaving takes the process with it, and every line is flushed as it is written. Desktop: 106 ok, 0 FAIL. Phone: 104 ok, 0 FAIL (two clipboard checks are skipped on Android), and "after the last back: window visible 0". --- app/CMakeLists.txt | 1 + app/qml/Main.qml | 36 +++++++++++++++++++++++ app/src/main.cpp | 4 +++ app/src/ui/BackKey.cpp | 65 ++++++++++++++++++++++++++++++++++++++++++ app/src/ui/BackKey.h | 33 +++++++++++++++++++++ app/src/ui/UiTest.cpp | 62 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 201 insertions(+) create mode 100644 app/src/ui/BackKey.cpp create mode 100644 app/src/ui/BackKey.h diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index 46e2a12..38dc124 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -106,6 +106,7 @@ if(CST_APP_GUI) src/ui/UiTest.cpp src/ui/ThemeIcons.cpp src/ui/SessionImages.cpp + src/ui/BackKey.cpp ${CST_APP_PLATFORM_SRC}) target_include_directories(cst-agent-app PRIVATE src "${CMAKE_CURRENT_SOURCE_DIR}/../include") diff --git a/app/qml/Main.qml b/app/qml/Main.qml index c5c8f09..574d019 100644 --- a/app/qml/Main.qml +++ b/app/qml/Main.qml @@ -507,6 +507,42 @@ ApplicationWindow { win.statusText = "" } } + /* The back key a phone has instead of a mouse: it closes what is open, one + * layer at a time -- the dialog on top, then the sliding sidebar. With + * nothing open the key goes on to the platform, and a phone leaves the + * window (Qt finishes the activity), which is right for the last layer. + * + * Which layer that is, and whether there is one, lives in one place: the + * C++ side (app/src/ui/BackKey.cpp) reads `backLayer()` on every Back key + * and calls `stepBack()` -- not a QML Shortcut, which a modal popup never + * lets through. --uitest reads the same two functions. */ + function backLayer() { + if (settings.visible) + return "settings" + if (ask.visible) + return "ask" + if (consent.visible) + return "consent" + if (win.navOpen) + return "drawer" + return "" + } + + /* One layer back. The dialogs are closed on the next turn of the event + * loop: closing one from inside the key event leaves its modal overlay up + * in this Qt (the dialog says it is closed, the overlay still eats every + * click after it), which --uitest walks straight into. */ + function stepBack() { + if (settings.visible) + Qt.callLater(settings.close) + else if (ask.visible) + Qt.callLater(ask.close) + else if (consent.visible) + Qt.callLater(consent.close) + else if (win.navOpen) + win.navOpen = false + } + // ---- engine -> UI ---------------------------------------------------- /* The signal handlers only forward: every one of them has a function diff --git a/app/src/main.cpp b/app/src/main.cpp index 4d1176f..7eb1fe6 100644 --- a/app/src/main.cpp +++ b/app/src/main.cpp @@ -33,6 +33,7 @@ #include "core/AgentCore.h" #include "core/ToolProvider.h" #include "ui/UiTest.h" +#include "ui/BackKey.h" #include "ui/SessionImages.h" #include "ui/ThemeIcons.h" #include "cst_version.h" @@ -349,5 +350,8 @@ int main(int argc, char **argv) { engine.load(url); if (engine.rootObjects().isEmpty()) return 1; + /* The phone's back key, one layer at a time (app/src/ui/BackKey.h). */ + app::installBackKey(engine.rootObjects().value(0), + engine.rootObjects().value(0)); return app.exec(); } diff --git a/app/src/ui/BackKey.cpp b/app/src/ui/BackKey.cpp new file mode 100644 index 0000000..8a2bb1d --- /dev/null +++ b/app/src/ui/BackKey.cpp @@ -0,0 +1,65 @@ +#include "ui/BackKey.h" + +#include +#include +#include +#include +#include + +namespace app { +namespace { + +class BackKeyFilter : public QObject { + public: + BackKeyFilter(QObject *window, QObject *root) + : QObject(window), m_window(window), m_root(root) {} + + protected: + bool eventFilter(QObject *watched, QEvent *event) override { + if (!m_root || watched != m_window || + (event->type() != QEvent::KeyPress && + event->type() != QEvent::KeyRelease)) + return QObject::eventFilter(watched, event); + QKeyEvent *key = static_cast(event); + if (key->key() != Qt::Key_Back) + return QObject::eventFilter(watched, event); + /* The key is down and up: a press that was taken is followed by a release + * that goes with it, so the platform never sees half of a key it did not + * get. */ + if (event->type() == QEvent::KeyRelease) + return m_tookPress ? (m_tookPress = false, true) + : QObject::eventFilter(watched, event); + QVariant layer; + if (!QMetaObject::invokeMethod(m_root, "backLayer", + Q_RETURN_ARG(QVariant, layer))) + return QObject::eventFilter(watched, event); + if (layer.toString().isEmpty()) + return QObject::eventFilter(watched, event); /* nothing open: not ours */ + /* One layer back, on the next turn of the event loop (the dialogs insist, + * see Main.qml's stepBack). The key is spent: the window stays. */ + QMetaObject::invokeMethod(m_root, "stepBack"); + m_tookPress = true; + return true; + } + + private: + QObject *m_window; /* whose keys are read; outlives this filter */ + QObject *m_root; /* the window's QML root */ + bool m_tookPress = false; +}; + +} // namespace + +void installBackKey(QObject *window, QObject *root) { + if (!window || !root) + return; + /* On the application, not on the window: a key press arrives here as an + * event sent to the window, but a filter installed on the window itself + * never sees one (measured -- a window-installed filter stayed silent while + * the same key closed the drawer through a QML Shortcut). The filter keeps + * to `window`'s own events, so nothing else in the process is touched. */ + QCoreApplication::instance()->installEventFilter( + new BackKeyFilter(window, root)); +} + +} // namespace app diff --git a/app/src/ui/BackKey.h b/app/src/ui/BackKey.h new file mode 100644 index 0000000..fd320c5 --- /dev/null +++ b/app/src/ui/BackKey.h @@ -0,0 +1,33 @@ +/* The phone's back key, read before anything else sees the event. + * + * Android hands its back button to the window as a Qt::Key_Back key press, and + * the platform leaves the activity (the window goes away) when nothing takes + * the key. What takes it here is one layer at a time -- the dialog on top, + * then the sliding sidebar -- and only while there is a layer: the window's + * own QML answers which (Main.qml's backLayer()) and closes it (stepBack()). + * + * This lives in C++ rather than in a QML Shortcut because a window-level + * shortcut never sees the key while a modal popup is up: the dialog keeps it + * (a desktop run that presses Back with the settings dialog open shows the + * shortcut does not fire, while the drawer -- no popup -- closes fine). A + * filter on the window runs before the shortcut map, the popup and the focused + * item, so the same key does the same thing on every platform; with nothing + * open the key is passed on untouched, which is what leaves the window on a + * phone and does nothing on a desktop. + * + * A window must not be given a second one: installBackKey() installs once. */ +#ifndef CST_APP_BACK_KEY_H +#define CST_APP_BACK_KEY_H + +class QObject; + +namespace app { + +/* `window` is the QQuickWindow (the object key events are sent to), `root` the + * QML root object of that window (the one carrying backLayer()/stepBack()). + * The filter is owned by the window. */ +void installBackKey(QObject *window, QObject *root); + +} // namespace app + +#endif // CST_APP_BACK_KEY_H diff --git a/app/src/ui/UiTest.cpp b/app/src/ui/UiTest.cpp index d7c5f7d..8c088d6 100644 --- a/app/src/ui/UiTest.cpp +++ b/app/src/ui/UiTest.cpp @@ -37,6 +37,7 @@ #include "cst_agent.h" #include "cst_attach.h" #include "core/ToolProvider.h" +#include "ui/BackKey.h" #include "ui/SessionImages.h" #include "ui/ThemeIcons.h" @@ -346,6 +347,9 @@ int runUiTest(AgentCore &core, const QString &size) { std::printf("FAIL the root object is not a window\n"); return 1; } + /* The same handler main() installs: every Back key check below goes through + * the code the phone runs, not through a Shortcut that only the test has. */ + installBackKey(win, engine.rootObjects().value(0)); QTest::qWait(250); /* --- the window opens on the session the engine already had ----------- */ @@ -1162,6 +1166,14 @@ int runUiTest(AgentCore &core, const QString &size) { clickMouse(win, QStringLiteral("navButton")); check(navPanel && navPanel->isVisible(), QStringLiteral("drawer opens over the transcript")); + /* A phone closes the drawer with the back key, not with a second tap. */ + QTest::keyClick(win, Qt::Key_Back); + QTest::qWait(120); + check(navPanel && !navPanel->isVisible(), + QStringLiteral("back closes the drawer")); + clickMouse(win, QStringLiteral("navButton")); + check(navPanel && navPanel->isVisible(), + QStringLiteral("and the drawer button opens it again")); clickMouse(win, QStringLiteral("navButton")); check(navPanel && !navPanel->isVisible(), QStringLiteral("drawer closes again")); @@ -1170,6 +1182,56 @@ int runUiTest(AgentCore &core, const QString &size) { check(docked && docked->isVisible(), QStringLiteral("widening again docks the sidebar back")); + /* --- the phone's back key closes what is open -------------------------- */ + /* This one is last on purpose: on the offscreen platform a popup closed by a + * key leaves its modal overlay over the window -- the dialog says it is + * closed while the overlay still eats clicks. Nothing that clicks may come + * after it. */ + { + QMetaObject::invokeMethod(win, "openSettings"); + QTest::qWait(300); + QQuickItem *panel = item(win, QStringLiteral("settingsPanel")); + check(panel && panel->isVisible(), + QStringLiteral("back: the settings dialog is open to close")); + /* Which layer is open is asked of QML, not of an item's `visible`: the + * dialog's content item lives on after a close and still reports visible + * while it fades. backLayer() is what the Back key reads, so the test asks + * for exactly that. */ + QVariant before; + QMetaObject::invokeMethod(win, "backLayer", Q_RETURN_ARG(QVariant, before)); + check(before.toString() == QStringLiteral("settings"), + QStringLiteral("back: the settings layer is the one open")); + QTest::keyClick(win, Qt::Key_Back); + QTest::qWait(200); + QVariant after; + QMetaObject::invokeMethod(win, "backLayer", Q_RETURN_ARG(QVariant, after)); + note(QStringLiteral("after back: layer \"%1\"").arg(after.toString())); + check(after.toString().isEmpty(), + QStringLiteral("back closes the dialog instead of the window")); + check(win->isVisible(), QStringLiteral("and the window is still there")); + /* With nothing open the key belongs to the platform: a phone leaves the + * window with it, so this side must not take it. On a desktop nothing + * happens and the window has to still be there; on a phone the window + * going away *is* the hand-over, so what is asked is that no layer was + * touched (whether the activity is already gone is the platform's own + * timing). This is the last thing the run does on a phone: the activity + * leaving takes the process with it, so the log can end on the check below + * without the closing line -- every line is flushed as it is written. */ + QTest::keyClick(win, Qt::Key_Back); + QTest::qWait(120); + QVariant idle; + QMetaObject::invokeMethod(win, "backLayer", Q_RETURN_ARG(QVariant, idle)); +#if defined(Q_OS_ANDROID) + note(QStringLiteral("after the last back: window visible %1") + .arg(win->isVisible() ? 1 : 0)); + check(idle.toString().isEmpty(), + QStringLiteral("back with nothing open is left to the platform")); +#else + check(idle.toString().isEmpty() && win->isVisible(), + QStringLiteral("back with nothing open is left to the platform")); +#endif + } + /* A picture of the last state on request (see maybeShot). */ maybeShot(win, QString());