From d4f65b17cf29adf9bf7bec7b8e048a4586de786e Mon Sep 17 00:00:00 2001 From: WHC Date: Sun, 11 Oct 2026 16:40:51 +0800 Subject: [PATCH] v0.9.3: notification_send posts on the phone, and Java helpers have a home The tool was the last Android one that never worked on the NX789J: it would fail with "Notification.Builder.build() failed: no exception detail" and the shade stayed empty. The detail was the whole story -- Qt clears a Java exception before C++ gets to look at it, so a build() that throws and a build() that returns null look exactly the same from here, and the only way to catch the difference is to let Java catch it. - app/android/src/xyz/whcself/cstagent/Notifier.java: the builder, the PendingIntent, build() and notify() in one try/catch that returns "" or the throwable's class and message. androiddeployqt copies app/android/ with the rest of the package source and Qt's Gradle template puts its src/ on java.srcDirs, so a class dropped there needs no build-script change (the first one is this; the foreground service of the next step goes there too). - AndroidTools.cpp keeps what it can see from C++ (the context, the channel on API 26+, the small icon looked up by name, the launch intent) and calls Notifier.post(); the result line now carries the Java failure when there is one. The javaErrorText() helper of the previous attempt is gone: reading a pending exception from C++ is exactly what does not work here. - On the device: the tool answers "notification sent", the system holds the record (channel cst-agent, importance 3, the framework small icon, the tap intent back into the app). Earlier attempts in the same session are in the transcript next to it: four failures from the old path, then this one. Docs: docs/android.md gets the Java-helper mechanism (and why the tool has to be wrapped in Java at all) and no longer lists notification_send as missing; app/README.md's platform table and the Android note say where a Java class goes. Version 0.9.3. --- app/README.md | 7 +- .../src/xyz/whcself/cstagent/Notifier.java | 60 +++++++++ app/src/platform/android/AndroidTools.cpp | 116 ++++-------------- docs/android.md | 22 ++-- include/cst_version.h | 2 +- 5 files changed, 107 insertions(+), 100 deletions(-) create mode 100644 app/android/src/xyz/whcself/cstagent/Notifier.java diff --git a/app/README.md b/app/README.md index 3b5747d..c63667a 100644 --- a/app/README.md +++ b/app/README.md @@ -362,13 +362,18 @@ GUI only through queued signals. |---|---|---| | Linux | `cmake -S app -B build-app` (libcurl via pkg-config) | clipboard (shell helpers), notification (`notify-send` / D-Bus) | | Windows | Qt's MinGW kit, `cmake -G Ninja -DCMAKE_PREFIX_PATH=/lib/cmake`; the engine needs libcurl for Windows (see `docs/README-windows.md`) | notification via the WinRT/tray API, clipboard through the core's Win32 path | -| Android | engine compiled with the NDK inside the APK (`scripts/build_apk_android.sh`, `-DCST_TUI=0`, `CST_NO_WEBUI=1`); no port, QML talks to `AgentCore` in-process | notification (`NotificationChannel` + permission probe), clipboard (`QClipboard` under the engine's own tool names), share sheet (`ACTION_SEND` + chooser), and the file card's open/share through the app's FileProvider (`AndroidTools.cpp`) | +| Android | engine compiled with the NDK inside the APK (`scripts/build_apk_android.sh`, `-DCST_TUI=0`, `CST_NO_WEBUI=1`); no port, QML talks to `AgentCore` in-process | notification (`Notifier.java`: channel + permission probe), clipboard (`QClipboard` under the engine's own tool names), share sheet (`ACTION_SEND` + chooser), and the file card's open/share through the app's FileProvider (`AndroidTools.cpp`) | | macOS | Qt from the installer, libcurl from the system | notification (`osascript`), clipboard (`pbpaste`/`pbcopy`) | Android's long-term shape: the core lives in the APK (no Termux needed), the platform tools are Kotlin/JNI behind the same `cst_tool_register()` contract. The build flag that keeps Unix helpers out of the picture is `CST_PLATFORM=android` (or the `__ANDROID__` detection inside the engine). +A Java class is added by dropping it in `app/android/src//`: +androiddeployqt copies `app/android/` with the rest of the package source and +Qt's Gradle template puts its `src/` on `java.srcDirs`, so no build-script +change is needed (`Notifier.java` is the first one; a Java helper is the only +way to catch a throwable where the platform throws instead of returning). ## Status diff --git a/app/android/src/xyz/whcself/cstagent/Notifier.java b/app/android/src/xyz/whcself/cstagent/Notifier.java new file mode 100644 index 0000000..729c1f3 --- /dev/null +++ b/app/android/src/xyz/whcself/cstagent/Notifier.java @@ -0,0 +1,60 @@ +package xyz.whcself.cstagent; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; + +/** + * The notification behind the engine's notification_send tool. + * + * It lives in Java because a Java exception is invisible to C++ by the time + * Qt's JNI wrapper is done with the call: Notification.Builder.build() can + * throw (a small icon Android refuses, a channel it does not know) and the + * JNI call then simply returns null -- the tool could only say "failed, no + * exception detail" on a device where the phone's own shade never saw + * anything. Catching it here turns the failure into the line the tool shows. + * + * Called from app/src/platform/android/AndroidTools.cpp through QJniObject. + * androiddeployqt copies this file with the rest of the package source + * (QT_ANDROID_PACKAGE_SOURCE_DIR, app/android/) and Qt's Gradle template adds + * "src" to java.srcDirs, so no build script change is needed for a file + * added here. + */ +public final class Notifier { + private Notifier() {} + + /** + * Post one notification. An empty channelId means an API below 26, which + * has no channels. Returns "" when it went out, the Java failure + * otherwise (class name and message), so the tool result can name it. + */ + public static String post(Context ctx, String channelId, String title, + String text, int iconId, Intent tap, int id) { + try { + Notification.Builder builder = channelId == null || channelId.isEmpty() + ? new Notification.Builder(ctx) + : new Notification.Builder(ctx, channelId); + builder.setContentTitle(title).setContentText(text).setSmallIcon(iconId); + if (tap != null) { + tap.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_CLEAR_TOP); + PendingIntent pending = PendingIntent.getActivity( + ctx, 0, tap, + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); + builder.setContentIntent(pending); + } + NotificationManager manager = (NotificationManager) + ctx.getSystemService(Context.NOTIFICATION_SERVICE); + if (manager == null) + return "no NotificationManager"; + manager.notify(id, builder.build()); + return ""; + } catch (Throwable t) { + String message = t.getMessage(); + return t.getClass().getSimpleName() + + (message == null || message.isEmpty() ? "" : ": " + message); + } + } +} diff --git a/app/src/platform/android/AndroidTools.cpp b/app/src/platform/android/AndroidTools.cpp index 4f5db86..b7e2f1b 100644 --- a/app/src/platform/android/AndroidTools.cpp +++ b/app/src/platform/android/AndroidTools.cpp @@ -10,8 +10,8 @@ * unregisterPlatformTools() drops again. * * WHERE THE CODE RUNS: `run_ex` is called on the turn's worker thread, never - * on the GUI thread. JNI calls are fine from any thread (QJniEnvironment - * attaches it), Qt GUI objects are not -- a tool that needs the widget tree + * on the GUI thread. JNI calls are fine from any thread (Qt attaches it), + * Qt GUI objects are not -- a tool that needs the widget tree * has to hop back with a blocking queued call (onGuiThread() below). * * The clipboard tools keep the names of the engine's built-ins: those are @@ -25,7 +25,6 @@ #include #include #include -#include #include #include #include @@ -101,39 +100,6 @@ int notifyProbe(char *why, size_t why_sz) { return -1; } -/* The message of the Java exception that is pending right now, if any. Qt - * clears exceptions on the way out of a call, and build() throws rather than - * returning: without this the tool could only say "it failed". */ -QString javaErrorText() { - QJniEnvironment env; - JNIEnv *e = env.jniEnv(); - if (!e || !e->ExceptionCheck()) - return QString(); - jthrowable th = e->ExceptionOccurred(); - e->ExceptionClear(); - QString text; - if (th) { - jclass cls = e->GetObjectClass(th); - if (cls) { - jmethodID mid = e->GetMethodID(cls, "getMessage", "()Ljava/lang/String;"); - if (mid) { - jstring js = static_cast(e->CallObjectMethod(th, mid)); - if (js) { - const char *utf = e->GetStringUTFChars(js, nullptr); - if (utf) { - text = QString::fromUtf8(utf); - e->ReleaseStringUTFChars(js, utf); - } - e->DeleteLocalRef(js); - } - } - e->DeleteLocalRef(cls); - } - e->DeleteLocalRef(th); - } - return text.isEmpty() ? QStringLiteral("a Java exception was thrown") : text; -} - bool postNotification(const QString &title, const QString &message, QString *err) { QJniObject ctx = appContext(); @@ -150,7 +116,6 @@ bool postNotification(const QString &title, const QString &message, } const QString channelId = QStringLiteral("cst-agent"); - QJniObject builder; if (sdkInt() >= 26) { QJniObject channel( "android/app/NotificationChannel", @@ -161,17 +126,6 @@ bool postNotification(const QString &title, const QString &message, manager.callMethod("createNotificationChannel", "(Landroid/app/NotificationChannel;)V", channel.object()); - builder = QJniObject("android/app/Notification$Builder", - "(Landroid/content/Context;Ljava/lang/String;)V", - ctx.object(), - QJniObject::fromString(channelId).object()); - } else { - builder = QJniObject("android/app/Notification$Builder", - "(Landroid/content/Context;)V", ctx.object()); - } - if (!builder.isValid()) { - *err = QStringLiteral("no Notification.Builder"); - return false; } /* The small icon. The R classes are not reachable from here, so the id is @@ -215,54 +169,34 @@ bool postNotification(const QString &title, const QString &message, "resources nor the APK's own"); return false; } - builder.callObjectMethod( - "setContentTitle", "(Ljava/lang/CharSequence;)Landroid/app/Notification$Builder;", - QJniObject::fromString(title).object()); - builder.callObjectMethod( - "setContentText", "(Ljava/lang/CharSequence;)Landroid/app/Notification$Builder;", - QJniObject::fromString(message).object()); - builder.callObjectMethod( - "setSmallIcon", "(I)Landroid/app/Notification$Builder;", icon); - /* Tapping it opens the app again (the launch intent of the package). */ QJniObject pm = ctx.callObjectMethod( "getPackageManager", "()Landroid/content/pm/PackageManager;"); - if (pm.isValid() && pkg.isValid()) { - QJniObject launch = pm.callObjectMethod( + /* The builder, the build and the notify happen in Java (Notifier.java): + * a Java exception is invisible to C++ once Qt's JNI wrapper has cleared + * it, so on the phone this only ever said "failed, no exception detail" + * while the shade stayed empty. Java catches it and hands back the line to + * show. */ + QJniObject launch; + if (pm.isValid() && pkg.isValid()) + launch = pm.callObjectMethod( "getLaunchIntentForPackage", "(Ljava/lang/String;)Landroid/content/Intent;", pkg.object()); - if (launch.isValid()) { - /* FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_CLEAR_TOP */ - launch.callObjectMethod("addFlags", "(I)Landroid/content/Intent;", - static_cast(0x10000000 | 0x04000000)); - QJniObject pending( - "android/app/PendingIntent", - "(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;", - ctx.object(), static_cast(0), launch.object(), - /* FLAG_IMMUTABLE | FLAG_UPDATE_CURRENT */ - static_cast(0x04000000 | 0x08000000)); - if (pending.isValid()) - builder.callObjectMethod( - "setContentIntent", - "(Landroid/app/PendingIntent;)Landroid/app/Notification$Builder;", - pending.object()); - } - } - - QJniObject note = builder.callObjectMethod("build", - "()Landroid/app/Notification;"); - if (!note.isValid()) { - const QString java = javaErrorText(); - *err = QStringLiteral("Notification.Builder.build() failed: %1") - .arg(java.isEmpty() ? QStringLiteral("no exception detail") - : java); - return false; - } - manager.callMethod("notify", "(ILandroid/app/Notification;)V", - static_cast(1), note.object()); - const QString after = javaErrorText(); - if (!after.isEmpty()) { - *err = QStringLiteral("NotificationManager.notify() failed: %1").arg(after); + QJniObject posted = QJniObject::callStaticObjectMethod( + "xyz/whcself/cstagent/Notifier", "post", + "(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;" + "Ljava/lang/String;ILandroid/content/Intent;I)Ljava/lang/String;", + ctx.object(), + QJniObject::fromString(channelId).object(), + QJniObject::fromString(title).object(), + QJniObject::fromString(message).object(), icon, + launch.isValid() ? launch.object() : nullptr, + static_cast(1)); + const QString java = + posted.isValid() ? posted.toString() + : QStringLiteral("the Notifier helper is not in this APK"); + if (!java.isEmpty()) { + *err = QStringLiteral("cannot post the notification: %1").arg(java); return false; } return true; diff --git a/docs/android.md b/docs/android.md index 3167a5d..99a2288 100644 --- a/docs/android.md +++ b/docs/android.md @@ -164,17 +164,25 @@ have, so the script rewrites the shebang of the build-tools wrappers. being asked for the key again. Rotation keeps the session; the sidebar appears in landscape. * Platform tools behind `app:android` (`app/src/platform/android/AndroidTools.cpp`): - `clipboard_get` / `clipboard_set` under the engine's own tool names and - `share` (`ACTION_SEND` + chooser) both work on the device. The file + `notification_send`, `clipboard_get` / `clipboard_set` under the engine's + own tool names and `share` (`ACTION_SEND` + chooser) all work on the device. + The file card's **Open** builds a `content://` URI through the app's FileProvider (`.qtprovider`, Qt's `@xml/qtprovider_paths`), checks `resolveActivity()` before starting `ACTION_VIEW` and grants the reader permission; **Share** reuses the share sheet. - `notification_send` still fails on the NX789J: the channel and the small - icon are found, `Notification.Builder.build()` returns null, and Qt clears - the Java exception before C++ can read it ("no exception detail"). Catching - it needs the call to be wrapped in Java (a small `app/android/src/...` - helper class), which is the next step there. +* **Java helpers**: a notification built entirely through `QJniObject` posted + nothing on the NX789J -- `Notification.Builder.build()` came back null and + the tool could only say "failed: no exception detail", because Qt clears a + Java exception before C++ gets to look at it. The one step that has to fail + visibly (a builder, a `PendingIntent`, `build()`, `notify()`) now lives in + `app/android/src/xyz/whcself/cstagent/Notifier.java`, which catches the + throwable and hands the text back for the tool result; the device then + reported `notification sent` and the shade had it. androiddeployqt copies + `app/android/` with the rest of the package source and Qt's Gradle template + puts its `src/` on `java.srcDirs`, so **a class dropped in that directory + needs no build-script change** -- the front service of the next step goes + there too. * A session's transcript replays a delivered file (`deliver_file`) as a card with Open and Share -- the copy lives next to the session, marked `for_user` so no request ever contains it (see `app/README.md`, "Files the agent diff --git a/include/cst_version.h b/include/cst_version.h index 1c605ab..a6c80ef 100644 --- a/include/cst_version.h +++ b/include/cst_version.h @@ -3,7 +3,7 @@ #define CST_VERSION_MAJOR 0 #define CST_VERSION_MINOR 9 -#define CST_VERSION_PATCH 2 +#define CST_VERSION_PATCH 3 #define CST_VERSION_STR_(x) #x #define CST_VERSION_STR(x) CST_VERSION_STR_(x)