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:
+6
-1
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user