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.
This commit is contained in:
WHC
2026-10-11 16:40:51 +08:00
parent 60cbce3c5c
commit d4f65b17cf
5 changed files with 107 additions and 100 deletions
+6 -1
View File
@@ -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) | | 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=<Qt>/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 | | Windows | Qt's MinGW kit, `cmake -G Ninja -DCMAKE_PREFIX_PATH=<Qt>/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`) | | 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 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. platform tools are Kotlin/JNI behind the same `cst_tool_register()` contract.
The build flag that keeps Unix helpers out of the picture is The build flag that keeps Unix helpers out of the picture is
`CST_PLATFORM=android` (or the `__ANDROID__` detection inside the engine). `CST_PLATFORM=android` (or the `__ANDROID__` detection inside the engine).
A Java class is added by dropping it in `app/android/src/<package>/`:
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 ## Status
@@ -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);
}
}
}
+25 -91
View File
@@ -10,8 +10,8 @@
* unregisterPlatformTools() drops again. * unregisterPlatformTools() drops again.
* *
* WHERE THE CODE RUNS: `run_ex` is called on the turn's worker thread, never * 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 * on the GUI thread. JNI calls are fine from any thread (Qt attaches it),
* attaches it), Qt GUI objects are not -- a tool that needs the widget tree * Qt GUI objects are not -- a tool that needs the widget tree
* has to hop back with a blocking queued call (onGuiThread() below). * 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 * The clipboard tools keep the names of the engine's built-ins: those are
@@ -25,7 +25,6 @@
#include <QFileInfo> #include <QFileInfo>
#include <QFuture> #include <QFuture>
#include <QGuiApplication> #include <QGuiApplication>
#include <QJniEnvironment>
#include <QJniObject> #include <QJniObject>
#include <QMetaObject> #include <QMetaObject>
#include <QString> #include <QString>
@@ -101,39 +100,6 @@ int notifyProbe(char *why, size_t why_sz) {
return -1; 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<jstring>(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, bool postNotification(const QString &title, const QString &message,
QString *err) { QString *err) {
QJniObject ctx = appContext(); QJniObject ctx = appContext();
@@ -150,7 +116,6 @@ bool postNotification(const QString &title, const QString &message,
} }
const QString channelId = QStringLiteral("cst-agent"); const QString channelId = QStringLiteral("cst-agent");
QJniObject builder;
if (sdkInt() >= 26) { if (sdkInt() >= 26) {
QJniObject channel( QJniObject channel(
"android/app/NotificationChannel", "android/app/NotificationChannel",
@@ -161,17 +126,6 @@ bool postNotification(const QString &title, const QString &message,
manager.callMethod<void>("createNotificationChannel", manager.callMethod<void>("createNotificationChannel",
"(Landroid/app/NotificationChannel;)V", "(Landroid/app/NotificationChannel;)V",
channel.object<jobject>()); channel.object<jobject>());
builder = QJniObject("android/app/Notification$Builder",
"(Landroid/content/Context;Ljava/lang/String;)V",
ctx.object<jobject>(),
QJniObject::fromString(channelId).object<jstring>());
} else {
builder = QJniObject("android/app/Notification$Builder",
"(Landroid/content/Context;)V", ctx.object<jobject>());
}
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 /* 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"); "resources nor the APK's own");
return false; return false;
} }
builder.callObjectMethod(
"setContentTitle", "(Ljava/lang/CharSequence;)Landroid/app/Notification$Builder;",
QJniObject::fromString(title).object<jstring>());
builder.callObjectMethod(
"setContentText", "(Ljava/lang/CharSequence;)Landroid/app/Notification$Builder;",
QJniObject::fromString(message).object<jstring>());
builder.callObjectMethod(
"setSmallIcon", "(I)Landroid/app/Notification$Builder;", icon);
/* Tapping it opens the app again (the launch intent of the package). */ /* Tapping it opens the app again (the launch intent of the package). */
QJniObject pm = ctx.callObjectMethod( QJniObject pm = ctx.callObjectMethod(
"getPackageManager", "()Landroid/content/pm/PackageManager;"); "getPackageManager", "()Landroid/content/pm/PackageManager;");
if (pm.isValid() && pkg.isValid()) { /* The builder, the build and the notify happen in Java (Notifier.java):
QJniObject launch = pm.callObjectMethod( * 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", "getLaunchIntentForPackage",
"(Ljava/lang/String;)Landroid/content/Intent;", pkg.object<jstring>()); "(Ljava/lang/String;)Landroid/content/Intent;", pkg.object<jstring>());
if (launch.isValid()) { QJniObject posted = QJniObject::callStaticObjectMethod(
/* FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_CLEAR_TOP */ "xyz/whcself/cstagent/Notifier", "post",
launch.callObjectMethod("addFlags", "(I)Landroid/content/Intent;", "(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;"
static_cast<jint>(0x10000000 | 0x04000000)); "Ljava/lang/String;ILandroid/content/Intent;I)Ljava/lang/String;",
QJniObject pending( ctx.object<jobject>(),
"android/app/PendingIntent", QJniObject::fromString(channelId).object<jstring>(),
"(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;", QJniObject::fromString(title).object<jstring>(),
ctx.object<jobject>(), static_cast<jint>(0), launch.object<jobject>(), QJniObject::fromString(message).object<jstring>(), icon,
/* FLAG_IMMUTABLE | FLAG_UPDATE_CURRENT */ launch.isValid() ? launch.object<jobject>() : nullptr,
static_cast<jint>(0x04000000 | 0x08000000)); static_cast<jint>(1));
if (pending.isValid()) const QString java =
builder.callObjectMethod( posted.isValid() ? posted.toString()
"setContentIntent", : QStringLiteral("the Notifier helper is not in this APK");
"(Landroid/app/PendingIntent;)Landroid/app/Notification$Builder;", if (!java.isEmpty()) {
pending.object<jobject>()); *err = QStringLiteral("cannot post the notification: %1").arg(java);
}
}
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<void>("notify", "(ILandroid/app/Notification;)V",
static_cast<jint>(1), note.object<jobject>());
const QString after = javaErrorText();
if (!after.isEmpty()) {
*err = QStringLiteral("NotificationManager.notify() failed: %1").arg(after);
return false; return false;
} }
return true; return true;
+15 -7
View File
@@ -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 being asked for the key again. Rotation keeps the session; the sidebar
appears in landscape. appears in landscape.
* Platform tools behind `app:android` (`app/src/platform/android/AndroidTools.cpp`): * Platform tools behind `app:android` (`app/src/platform/android/AndroidTools.cpp`):
`clipboard_get` / `clipboard_set` under the engine's own tool names and `notification_send`, `clipboard_get` / `clipboard_set` under the engine's
`share` (`ACTION_SEND` + chooser) both work on the device. The file 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 card's **Open** builds a `content://` URI through the app's FileProvider
(`<package>.qtprovider`, Qt's `@xml/qtprovider_paths`), checks (`<package>.qtprovider`, Qt's `@xml/qtprovider_paths`), checks
`resolveActivity()` before starting `ACTION_VIEW` and grants the reader `resolveActivity()` before starting `ACTION_VIEW` and grants the reader
permission; **Share** reuses the share sheet. permission; **Share** reuses the share sheet.
`notification_send` still fails on the NX789J: the channel and the small * **Java helpers**: a notification built entirely through `QJniObject` posted
icon are found, `Notification.Builder.build()` returns null, and Qt clears nothing on the NX789J -- `Notification.Builder.build()` came back null and
the Java exception before C++ can read it ("no exception detail"). Catching the tool could only say "failed: no exception detail", because Qt clears a
it needs the call to be wrapped in Java (a small `app/android/src/...` Java exception before C++ gets to look at it. The one step that has to fail
helper class), which is the next step there. 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 * 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` 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 so no request ever contains it (see `app/README.md`, "Files the agent
+1 -1
View File
@@ -3,7 +3,7 @@
#define CST_VERSION_MAJOR 0 #define CST_VERSION_MAJOR 0
#define CST_VERSION_MINOR 9 #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) #x
#define CST_VERSION_STR(x) CST_VERSION_STR_(x) #define CST_VERSION_STR(x) CST_VERSION_STR_(x)