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) |
| 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`) |
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/<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
@@ -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.
*
* 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 <QFileInfo>
#include <QFuture>
#include <QGuiApplication>
#include <QJniEnvironment>
#include <QJniObject>
#include <QMetaObject>
#include <QString>
@@ -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<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,
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<void>("createNotificationChannel",
"(Landroid/app/NotificationChannel;)V",
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
@@ -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<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). */
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<jstring>());
if (launch.isValid()) {
/* FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_CLEAR_TOP */
launch.callObjectMethod("addFlags", "(I)Landroid/content/Intent;",
static_cast<jint>(0x10000000 | 0x04000000));
QJniObject pending(
"android/app/PendingIntent",
"(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;",
ctx.object<jobject>(), static_cast<jint>(0), launch.object<jobject>(),
/* FLAG_IMMUTABLE | FLAG_UPDATE_CURRENT */
static_cast<jint>(0x04000000 | 0x08000000));
if (pending.isValid())
builder.callObjectMethod(
"setContentIntent",
"(Landroid/app/PendingIntent;)Landroid/app/Notification$Builder;",
pending.object<jobject>());
}
}
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);
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<jobject>(),
QJniObject::fromString(channelId).object<jstring>(),
QJniObject::fromString(title).object<jstring>(),
QJniObject::fromString(message).object<jstring>(), icon,
launch.isValid() ? launch.object<jobject>() : nullptr,
static_cast<jint>(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;
+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
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
(`<package>.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
+1 -1
View File
@@ -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)