v0.9.2: files the agent delivers come back as cards, and the saved key is used
A turn could write a report and say "here it is" -- and there was nothing to
open. The engine had no way to hand a finished file to the person, and the
front end had no way to show one. This release adds the whole path: a tool to
deliver, a store that keeps the copy, a card in the transcript, and open /
share through the platform. It also fixes two bugs the phone exposed while it
was being tried there.
deliver_file (engine)
- cst_attachment_t.for_user marks a copy that belongs to the person, not to
the model; cst_attach_import_artifact() stores any bytes up to
CST_ARTIFACT_MAX_BYTES (64 MB) with the mime taken from the name's
extension. Attachments are unchanged (content decides the type, 16 MB).
- It is a read-policy engine tool: path resolves inside the workspace, a
directory is refused, title (optional) is the display name.
- The wire is what keeps it out of the conversation: parts_add_attachments()
skips for_user entries without even a "not sent" note, and a turn that only
delivered a file no longer invents an empty user message. tests/test_artifacts.c
pins that down end to end, including a session-file round trip.
The card (Qt front end)
- filesText() replaced imagesText(): one line per file -- kind, stored name,
name, origin, mime, bytes, pixels -- decoded by ImageStrip.qml. A for_user
entry is a file card (extension badge, name, type and size, Open, Share);
a picture stays a thumbnail and opens on tap. Both shapes are read, so
sessions written by an older version still draw.
- AgentCore::openArtifact() / shareArtifact() accept a name only when one of
the session's own messages carries it, and hand the display name to the
platform layer for the status line.
- Android opens through the app's FileProvider (content:// + ACTION_VIEW,
resolveActivity() checked, read permission granted) and shares through the
share sheet; a desktop uses QDesktopServices / the clipboard.
CST_APP_NO_OPEN=1 makes --uitest run without opening anything.
Two bugs the device found
- The session writer had its own copy of the attachment JSON and forgot
`for_user`: the file survived the file, but came back as an attachment --
no card, and put back in front of the model. It now uses the one writer
(cst_attachments_to_json()), with the round trip covered by a test.
- engineToolImages() looked the card up with findTool(), which only finds a
card that is still running -- and the engine reports the result *before*
the files, so the card was already complete and the files were dropped.
A live turn therefore showed no card while a replay of the same session
did. It now matches on the call id.
- A tap that could not work said nothing: ImageStrip.qml's errorText was
never drawn. The card now carries the reason ("no app on the phone opens
report.md").
Also
- The saved provider key reaches the engine on every start: AgentCore::open()
was handed an empty key and the engine never resolves credentials itself
(only the CLI did), so every start asked for it again. It resolves the same
order main.c uses (saved key, provider environment, CST_API_KEY) and wipes
the copy it made; --selftest checks it against a scratch credentials file.
- Android: notification_send reads the Java exception text into its result,
and keeps failing on the NX789J -- the build() returns null and Qt clears
the exception before C++ can read it, so the call has to be wrapped in Java
next (docs/android.md says so).
- --uitest grew the delivered-file case (card, Open, Share, a forged name
refused), the live order of result-then-files, and the startup row count.
Version 0.9.2.
This commit is contained in:
+118
-16
@@ -16,6 +16,7 @@
|
||||
|
||||
#include <cstdlib>
|
||||
|
||||
#include "ToolProvider.h" /* openLocalFile / shareLocalFile */
|
||||
#include "cst_agent.h"
|
||||
#include "cst_attach.h"
|
||||
#include "cst_models.h"
|
||||
@@ -113,6 +114,22 @@ bool AgentCore::open(const QString &workspace, const QString &provider,
|
||||
snprintf(m_cfg.model, sizeof(m_cfg.model), "%s", m);
|
||||
}
|
||||
}
|
||||
/* The window is started without --api-key, and the engine's config never
|
||||
* resolves credentials on its own: without this the key the user saved for
|
||||
* the provider would be in the file but not in the turn, and every start
|
||||
* would ask for it again. Same order as the CLI (main.c): a key passed to
|
||||
* open() > the saved one > the provider's env var > CST_API_KEY. */
|
||||
if (m_cfg.api_key[0] == 0 && m_cfg.provider[0]) {
|
||||
char key[512] = "";
|
||||
if (cst_creds_resolve(m_cfg.provider, key, sizeof(key), nullptr) != 0) {
|
||||
const char *env = getenv("CST_API_KEY");
|
||||
if (env && env[0])
|
||||
snprintf(key, sizeof(key), "%s", env);
|
||||
}
|
||||
if (key[0])
|
||||
snprintf(m_cfg.api_key, sizeof(m_cfg.api_key), "%s", key);
|
||||
memset(key, 0, sizeof(key)); /* never let it outlive the call */
|
||||
}
|
||||
/* Sessions live in the workspace's store -- the same files the CLI and the
|
||||
* WebUI use. Without this the engine would open session.jsonl in the
|
||||
* process's working directory, next to a session list that stays empty. */
|
||||
@@ -334,28 +351,81 @@ QString field(const char *s) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/* The images of one message or tool result, one line per picture:
|
||||
/* The files of one message or tool result, one line each:
|
||||
*
|
||||
* <stored name>|<name>|<origin>|<w>x<h>
|
||||
* <kind>|<stored name>|<name>|<origin>|<mime>|<bytes>|<w>x<h>
|
||||
*
|
||||
* A list role is not an option: a ListModel keeps what fits in a QVariant of
|
||||
* a simple type -- a list reads back as undefined there, and setProperty drops
|
||||
* the role -- so the rows carry this text and ImageStrip.qml decodes it. A
|
||||
* document (PDF, text) is not a picture and stays out of it. */
|
||||
QString imagesText(const cst_attachment_t *list, int n) {
|
||||
* kind is "img" for a picture (a thumbnail the view can show) or "file" for a
|
||||
* file the agent delivered (a card with open and share). A list role is not an
|
||||
* option: a ListModel keeps what fits in a QVariant of a simple type -- a list
|
||||
* reads back as undefined there, and setProperty drops the role -- so the rows
|
||||
* carry this text and ImageStrip.qml decodes it. A document the user attached
|
||||
* (PDF, text) is named in the message text and stays out of it, and so does
|
||||
* anything older sessions wrote. */
|
||||
QString filesText(const cst_attachment_t *list, int n) {
|
||||
QStringList lines;
|
||||
for (int i = 0; list && i < n; i++) {
|
||||
const cst_attachment_t *a = &list[i];
|
||||
if (!cst_attach_is_image(a))
|
||||
const bool img = cst_attach_is_image(a) != 0;
|
||||
if (!img && !a->for_user)
|
||||
continue;
|
||||
lines << QStringLiteral("%1|%2|%3|%4x%5")
|
||||
.arg(field(a->rel), field(a->name), field(a->origin))
|
||||
.arg(a->width)
|
||||
.arg(a->height);
|
||||
QString line = QStringLiteral("%1|%2|%3|%4|%5|%6|%7x%8");
|
||||
line = line.arg(img ? QStringLiteral("img") : QStringLiteral("file"));
|
||||
line = line.arg(field(a->rel));
|
||||
line = line.arg(field(a->name));
|
||||
line = line.arg(field(a->origin));
|
||||
line = line.arg(field(a->mime));
|
||||
line = line.arg(a->bytes);
|
||||
line = line.arg(a->width);
|
||||
line = line.arg(a->height);
|
||||
lines << line;
|
||||
}
|
||||
return lines.join(QLatin1Char('\n'));
|
||||
}
|
||||
|
||||
/* A stored file of this session, named the way a file card names it: its full
|
||||
* path, or empty when the name is not one this session's store owns. A card
|
||||
* hands back a name it got from the engine (filesText), so a name no message
|
||||
* carries is not a file of this session at all -- never open it. `title`, when
|
||||
* asked for, gets the display name the person knows the file by. */
|
||||
QString artifactPathOf(cst_agent_t *agent, const QString &rel, QString *title) {
|
||||
if (title)
|
||||
title->clear();
|
||||
if (!agent)
|
||||
return QString();
|
||||
const QByteArray r = rel.trimmed().toUtf8();
|
||||
if (r.isEmpty() || !cst_attach_rel_ok(r.constData()))
|
||||
return QString();
|
||||
cst_session_t *s = cst_agent_session(agent);
|
||||
if (!s)
|
||||
return QString();
|
||||
bool mine = false;
|
||||
cst_session_lock(s);
|
||||
const int n = cst_session_count(s);
|
||||
for (int i = 0; !mine && i < n; i++) {
|
||||
const cst_message_t *m = cst_session_get(s, i);
|
||||
for (int a = 0; m && a < m->nattach; a++) {
|
||||
if (strcmp(m->attach[a].rel, r.constData()) != 0)
|
||||
continue;
|
||||
mine = true;
|
||||
if (title)
|
||||
*title = utf8(m->attach[a].name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
cst_session_unlock(s);
|
||||
if (!mine)
|
||||
return QString();
|
||||
cst_attachment_t a;
|
||||
memset(&a, 0, sizeof(a));
|
||||
snprintf(a.rel, sizeof(a.rel), "%s", r.constData());
|
||||
const char *session = cst_agent_session_path(agent);
|
||||
char full[4096] = "";
|
||||
if (!session || cst_attach_full_path(session, &a, full, sizeof(full)) != 0)
|
||||
return QString();
|
||||
return utf8(full);
|
||||
}
|
||||
|
||||
/* The open tool row this result belongs to, -1 when none. Rows are paired by
|
||||
* call id first: the same tool may run twice in a turn, and pairing by name
|
||||
* would hang the first card on "running" forever. Only a call without an id
|
||||
@@ -407,6 +477,38 @@ QString AgentCore::attachmentDir() const {
|
||||
return utf8(dir);
|
||||
}
|
||||
|
||||
/* A file card's "open": the file goes to whatever the system uses for its
|
||||
* type (Android ACTION_VIEW through the FileProvider, a desktop's own
|
||||
* application). "" when it went through. */
|
||||
QString AgentCore::openArtifact(const QString &rel) {
|
||||
QString title;
|
||||
const QString path = artifactPathOf(m_agent, rel, &title);
|
||||
if (path.isEmpty())
|
||||
return QStringLiteral("no such file in this session: %1").arg(rel);
|
||||
QString status;
|
||||
const QString err = app::openLocalFile(path, title, &status);
|
||||
if (!err.isEmpty())
|
||||
return err;
|
||||
if (!status.isEmpty())
|
||||
emit statusChanged(status);
|
||||
return QString();
|
||||
}
|
||||
|
||||
/* The same through the share sheet (a phone) or the clipboard (a desktop). */
|
||||
QString AgentCore::shareArtifact(const QString &rel) {
|
||||
QString title;
|
||||
const QString path = artifactPathOf(m_agent, rel, &title);
|
||||
if (path.isEmpty())
|
||||
return QStringLiteral("no such file in this session: %1").arg(rel);
|
||||
QString status;
|
||||
const QString err = app::shareLocalFile(path, title, &status);
|
||||
if (!err.isEmpty())
|
||||
return err;
|
||||
if (!status.isEmpty())
|
||||
emit statusChanged(status);
|
||||
return QString();
|
||||
}
|
||||
|
||||
QVariantList AgentCore::sessions() const {
|
||||
QVariantList out;
|
||||
if (m_workspace.isEmpty())
|
||||
@@ -457,14 +559,14 @@ QVariantList AgentCore::history() const {
|
||||
r["name"] = name;
|
||||
r["callId"] = callId;
|
||||
r["output"] = utf8(m->content);
|
||||
r["images"] = imagesText(m->attach, m->nattach);
|
||||
r["images"] = filesText(m->attach, m->nattach);
|
||||
r["state"] = "done";
|
||||
r["complete"] = true;
|
||||
rows.append(r);
|
||||
} else {
|
||||
QVariantMap r = rows.at(at).toMap();
|
||||
r["output"] = utf8(m->content);
|
||||
r["images"] = imagesText(m->attach, m->nattach);
|
||||
r["images"] = filesText(m->attach, m->nattach);
|
||||
r["state"] = "done";
|
||||
r["complete"] = true;
|
||||
rows[at] = r;
|
||||
@@ -490,7 +592,7 @@ QVariantList AgentCore::history() const {
|
||||
if (!names.isEmpty())
|
||||
text += QStringLiteral("\n\n[attached: %1]").arg(names.join(", "));
|
||||
r["text"] = text;
|
||||
r["images"] = imagesText(m->attach, m->nattach);
|
||||
r["images"] = filesText(m->attach, m->nattach);
|
||||
r["complete"] = true;
|
||||
rows.append(r);
|
||||
continue;
|
||||
@@ -1027,7 +1129,7 @@ void AgentCore::hookToolResult(const ctl_tool_call_t *call, const char *out,
|
||||
void AgentCore::hookToolImages(const ctl_tool_call_t *call,
|
||||
const cst_attachment_t *list, int n, void *ud) {
|
||||
AgentCore *self = static_cast<AgentCore *>(ud);
|
||||
const QString images = imagesText(list, n);
|
||||
const QString images = filesText(list, n);
|
||||
if (images.isEmpty())
|
||||
return; /* a call that made no picture needs no signal */
|
||||
const QString id = QString::fromUtf8(call && call->id ? call->id : "");
|
||||
|
||||
@@ -139,6 +139,13 @@ class AgentCore : public QObject {
|
||||
* image://cstattach/ serves (SessionImages.cpp). "" until the engine is
|
||||
* open. It follows the session. */
|
||||
Q_INVOKABLE QString attachmentDir() const;
|
||||
/* A file card's two buttons: the stored file goes to the system -- a phone
|
||||
* opens it with ACTION_VIEW or hands it to the share sheet, a desktop gives
|
||||
* it to its own application or to the clipboard. "" when it went through
|
||||
* (the header gets the line from statusChanged), else what to say. The name
|
||||
* is the stored one the card carries; anything else is refused. */
|
||||
Q_INVOKABLE QString openArtifact(const QString &rel);
|
||||
Q_INVOKABLE QString shareArtifact(const QString &rel);
|
||||
/* 1 the model is shown images, 0 it is not (they go as notes), -1 unknown. */
|
||||
Q_INVOKABLE int seesImages() const;
|
||||
|
||||
|
||||
@@ -54,6 +54,20 @@ int platformRegisterMac(AgentCore *core);
|
||||
int platformRegisterWindows(AgentCore *core);
|
||||
int platformRegisterAndroid(AgentCore *core);
|
||||
|
||||
/* Hand a file of the session's store to the system: a phone opens it with
|
||||
* ACTION_VIEW through the app's FileProvider, a desktop gives it to whatever
|
||||
* the desktop uses. `title` is the name a person knows the file by (the
|
||||
* stored copy is named by its id, which means nothing outside), used for the
|
||||
* status line. Both return "" on success and a reason to show the user
|
||||
* otherwise; *status (optional) gets the line to put in the header.
|
||||
* CST_APP_NO_OPEN=1 makes both do nothing (--uitest drives the window without
|
||||
* opening anything for real). */
|
||||
QString openLocalFile(const QString &path, const QString &title, QString *status);
|
||||
/* The same file through the share sheet where there is one (Android); a
|
||||
* desktop has no share sheet, so the path goes to the clipboard. */
|
||||
QString shareLocalFile(const QString &path, const QString &title,
|
||||
QString *status);
|
||||
|
||||
} // namespace app
|
||||
|
||||
#endif /* CST_APP_TOOLPROVIDER_H */
|
||||
|
||||
+61
-2
@@ -10,6 +10,7 @@
|
||||
#include <QCommandLineParser>
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QGuiApplication>
|
||||
#include <QQmlApplicationEngine>
|
||||
@@ -20,6 +21,11 @@
|
||||
#include <QTextStream>
|
||||
#include <QUrl>
|
||||
|
||||
#if defined(Q_OS_ANDROID)
|
||||
#include <QJniObject>
|
||||
#include <QtCore/private/qandroidextras_p.h>
|
||||
#endif
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
@@ -50,13 +56,41 @@ namespace {
|
||||
int runSelftest() {
|
||||
QTextStream out(stdout);
|
||||
int bad = 0;
|
||||
QTemporaryDir tmp;
|
||||
if (!tmp.isValid()) {
|
||||
QTemporaryDir tmp, cfg;
|
||||
if (!tmp.isValid() || !cfg.isValid()) {
|
||||
out << "selftest: cannot create a scratch dir\n";
|
||||
return 1;
|
||||
}
|
||||
/* The window passes no --api-key, so the key the user saved for the provider
|
||||
* has to come back from <config>/cst-agent/credentials -- the file the
|
||||
* settings dialog writes. Planted in a scratch config dir, never in the
|
||||
* real one. */
|
||||
const QString cfgdir = cfg.path() + QStringLiteral("/cst-agent");
|
||||
QDir().mkpath(cfgdir);
|
||||
QFile creds(cfgdir + QStringLiteral("/credentials"));
|
||||
if (!creds.open(QIODevice::WriteOnly | QIODevice::Truncate) ||
|
||||
creds.write("deepseek.api_key=selftest-saved\n") < 0) {
|
||||
out << "selftest: cannot write a scratch credentials file\n";
|
||||
return 1;
|
||||
}
|
||||
creds.close();
|
||||
qputenv("XDG_CONFIG_HOME", cfg.path().toUtf8());
|
||||
|
||||
AgentCore core;
|
||||
QString err;
|
||||
if (!core.open(tmp.path(), QStringLiteral("deepseek"), QString(), QString(),
|
||||
&err)) {
|
||||
out << "selftest: engine did not start: " << err << "\n";
|
||||
return 1;
|
||||
}
|
||||
const cst_agent_config_t *live =
|
||||
core.agent() ? cst_agent_config(core.agent()) : nullptr;
|
||||
if (!live || strcmp(live->api_key, "selftest-saved") != 0) {
|
||||
out << "selftest: the saved key did not reach the engine config\n";
|
||||
bad++;
|
||||
} else {
|
||||
out << "selftest: the saved key is in the engine config\n";
|
||||
}
|
||||
if (!core.open(tmp.path(), QStringLiteral("deepseek"),
|
||||
QStringLiteral("deepseek-flash"), QStringLiteral("test-key"),
|
||||
&err)) {
|
||||
@@ -86,6 +120,18 @@ int runSelftest() {
|
||||
<< (ok ? "" : QStringLiteral(" (%1)").arg(QString::fromUtf8(why)))
|
||||
<< "\n";
|
||||
}
|
||||
/* The engine's own deliver_file: the file a tool hands to the person. */
|
||||
const cst_tool_def_t *dv = cst_tool_find("deliver_file");
|
||||
if (!dv) {
|
||||
out << "selftest: deliver_file is not in the registry\n";
|
||||
bad++;
|
||||
} else {
|
||||
char why[160] = "";
|
||||
const bool ok = cst_tool_available(dv, why, sizeof(why)) == 0;
|
||||
out << "selftest: deliver_file available=" << (ok ? "yes" : "no")
|
||||
<< (ok ? "" : QStringLiteral(" (%1)").arg(QString::fromUtf8(why)))
|
||||
<< "\n";
|
||||
}
|
||||
/* the mode comes from the engine, not from the UI */
|
||||
core.setMode(QStringLiteral("general"));
|
||||
if (core.mode() != QLatin1String("general")) {
|
||||
@@ -199,6 +245,19 @@ int main(int argc, char **argv) {
|
||||
return 1;
|
||||
}
|
||||
app::registerPlatformTools(&core); /* the app's own tools */
|
||||
#if defined(Q_OS_ANDROID)
|
||||
/* notification_send sits behind POST_NOTIFICATIONS from Android 13 on, and
|
||||
* the tool is hidden while it is not granted (its probe says so): ask once,
|
||||
* here, so the tool is there for the first turn rather than a prompt that
|
||||
* shows up mid-answer. Denying it only costs the tool. */
|
||||
if (QNativeInterface::QAndroidApplication::context().isValid() &&
|
||||
QtAndroidPrivate::checkPermission(
|
||||
QStringLiteral("android.permission.POST_NOTIFICATIONS"))
|
||||
.result() != QtAndroidPrivate::Authorized) {
|
||||
QtAndroidPrivate::requestPermission(
|
||||
QStringLiteral("android.permission.POST_NOTIFICATIONS"));
|
||||
}
|
||||
#endif
|
||||
if (parser.isSet(planOpt))
|
||||
core.setPlanMode(true);
|
||||
|
||||
|
||||
@@ -12,32 +12,715 @@
|
||||
* 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
|
||||
* has to hop back with a blocking queued call.
|
||||
* has to hop back with a blocking queued call (onGuiThread() below).
|
||||
*
|
||||
* WHAT GOES HERE (one at a time, each with its own probe):
|
||||
* notification_send NotificationChannel + NotificationManager through
|
||||
* JNI; hidden until POST_NOTIFICATIONS is granted
|
||||
* (Android 13+), which the UI asks for on first use.
|
||||
* clipboard_get/set the phone clipboard through Qt's QClipboard, taking
|
||||
* the name of the desktop tool that cannot work here
|
||||
* (see app/README.md: a front end may shadow a built-in
|
||||
* that has no working backend on its host).
|
||||
* share ACTION_SEND of a file or text through the system share
|
||||
* sheet -- the phone's answer to "show this to the user".
|
||||
*
|
||||
* Nothing is registered yet: the first APK ships the engine, the window and
|
||||
* the tool contract, and the tools above arrive next (docs/android.md). */
|
||||
* The clipboard tools keep the names of the engine's built-ins: those are
|
||||
* gated to CST_PLAT_DESKTOP and the registry lets an app tool of the same
|
||||
* name shadow the built-in (src/tools/registry.c: cst_tool_find()), so the
|
||||
* model sees one capability either way. */
|
||||
#include <QByteArray>
|
||||
#include <QClipboard>
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QFuture>
|
||||
#include <QGuiApplication>
|
||||
#include <QJniEnvironment>
|
||||
#include <QJniObject>
|
||||
#include <QMetaObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QThread>
|
||||
|
||||
#include <QtCore/private/qandroidextras_p.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <utility>
|
||||
|
||||
#include "core/AgentCore.h"
|
||||
#include "core/ToolProvider.h"
|
||||
|
||||
namespace app {
|
||||
namespace {
|
||||
|
||||
/* --- small helpers ------------------------------------------------------- */
|
||||
|
||||
const char *argOf(const ctl_tool_call_t *call, const char *key) {
|
||||
for (int i = 0; i < call->nargs; i++)
|
||||
if (strcmp(call->args[i].key, key) == 0)
|
||||
return call->args[i].value;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void setOut(cst_tool_result_t *r, const QByteArray &text, int rc) {
|
||||
r->out = strdup(text.constData());
|
||||
r->out_len = r->out ? strlen(r->out) : 0;
|
||||
r->exit_code = rc;
|
||||
}
|
||||
|
||||
/* Anything that touches a Qt GUI object (the clipboard) or an Activity has to
|
||||
* run on the GUI thread; the turn's worker thread waits for it. */
|
||||
template <typename Fn>
|
||||
void onGuiThread(Fn &&fn) {
|
||||
if (!qApp || QThread::currentThread() == qApp->thread()) {
|
||||
fn();
|
||||
return;
|
||||
}
|
||||
QMetaObject::invokeMethod(qApp, std::forward<Fn>(fn),
|
||||
Qt::BlockingQueuedConnection);
|
||||
}
|
||||
|
||||
jint sdkInt() {
|
||||
static const jint sdk = QJniObject::getStaticField<jint>(
|
||||
"android/os/Build$VERSION", "SDK_INT");
|
||||
return sdk;
|
||||
}
|
||||
|
||||
QJniObject appContext() {
|
||||
return QNativeInterface::QAndroidApplication::context();
|
||||
}
|
||||
|
||||
/* --- notification_send --------------------------------------------------- */
|
||||
|
||||
/* Android 13 (API 33) put notifications behind a runtime permission; before
|
||||
* that the permission does not exist and they always work. main.cpp asks for
|
||||
* it once at startup, so the probe is normally green on a device where the
|
||||
* user said yes. */
|
||||
int notifyProbe(char *why, size_t why_sz) {
|
||||
if (sdkInt() < 33)
|
||||
return 0;
|
||||
const QFuture<QtAndroidPrivate::PermissionResult> f =
|
||||
QtAndroidPrivate::checkPermission(
|
||||
QStringLiteral("android.permission.POST_NOTIFICATIONS"));
|
||||
if (f.result() == QtAndroidPrivate::Authorized)
|
||||
return 0;
|
||||
snprintf(why, why_sz,
|
||||
"POST_NOTIFICATIONS is not granted (Android 13+ asks the user the "
|
||||
"first time the app runs)");
|
||||
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();
|
||||
if (!ctx.isValid()) {
|
||||
*err = QStringLiteral("no Android context");
|
||||
return false;
|
||||
}
|
||||
QJniObject manager = ctx.callObjectMethod(
|
||||
"getSystemService", "(Ljava/lang/String;)Ljava/lang/Object;",
|
||||
QJniObject::fromString(QStringLiteral("notification")).object<jstring>());
|
||||
if (!manager.isValid()) {
|
||||
*err = QStringLiteral("no NotificationManager");
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString channelId = QStringLiteral("cst-agent");
|
||||
QJniObject builder;
|
||||
if (sdkInt() >= 26) {
|
||||
QJniObject channel(
|
||||
"android/app/NotificationChannel",
|
||||
"(Ljava/lang/String;Ljava/lang/CharSequence;I)V",
|
||||
QJniObject::fromString(channelId).object<jstring>(),
|
||||
QJniObject::fromString(QStringLiteral("CST-agent")).object<jstring>(),
|
||||
static_cast<jint>(3) /* IMPORTANCE_DEFAULT */);
|
||||
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
|
||||
* looked up by name, and build() throws IllegalArgumentException ("Invalid
|
||||
* notification (no valid small icon)") when there is none at all: every
|
||||
* candidate that can be there is tried -- the framework drawables first,
|
||||
* then the launcher icon the APK itself carries (AGP puts it in mipmap, a
|
||||
* hand made APK in drawable/icon). */
|
||||
QJniObject res = ctx.callObjectMethod("getResources",
|
||||
"()Landroid/content/res/Resources;");
|
||||
QJniObject pkg = ctx.callObjectMethod("getPackageName",
|
||||
"()Ljava/lang/String;");
|
||||
jint icon = 0;
|
||||
if (res.isValid()) {
|
||||
auto idOf = [&res, &pkg](const char *name, const char *type,
|
||||
const char *owner) {
|
||||
return res.callMethod<jint>(
|
||||
"getIdentifier", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)I",
|
||||
QJniObject::fromString(QString::fromLatin1(name)).object<jstring>(),
|
||||
QJniObject::fromString(QString::fromLatin1(type)).object<jstring>(),
|
||||
owner ? QJniObject::fromString(QString::fromLatin1(owner)).object<jstring>()
|
||||
: pkg.object<jstring>());
|
||||
};
|
||||
for (const char *n : {"ic_dialog_info", "ic_popup_reminder",
|
||||
"stat_notify_chat", "sym_def_app_icon"}) {
|
||||
icon = idOf(n, "drawable", "android");
|
||||
if (icon != 0)
|
||||
break;
|
||||
}
|
||||
for (const char *n : {"ic_launcher", "icon", "app_icon", "qt_icon"}) {
|
||||
if (icon == 0)
|
||||
icon = idOf(n, "mipmap", nullptr);
|
||||
if (icon == 0)
|
||||
icon = idOf(n, "drawable", nullptr);
|
||||
if (icon != 0)
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (icon == 0) {
|
||||
*err = QStringLiteral("no small icon: it is in neither the framework "
|
||||
"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(
|
||||
"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);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Runs on the turn's worker thread: no GUI objects here. */
|
||||
int notifyRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call,
|
||||
cst_tool_result_t *r, void *ud) {
|
||||
(void)ctx;
|
||||
(void)ud;
|
||||
const char *message = argOf(call, "message");
|
||||
const char *title = argOf(call, "title");
|
||||
if (!message || !message[0]) {
|
||||
setOut(r, QByteArrayLiteral("error: notification_send needs a message"), 1);
|
||||
return 0;
|
||||
}
|
||||
const QString t = title && title[0] ? QString::fromUtf8(title)
|
||||
: QStringLiteral("CST-agent");
|
||||
const QString m = QString::fromUtf8(message);
|
||||
QString err;
|
||||
bool ok = false;
|
||||
onGuiThread([&] { ok = postNotification(t, m, &err); });
|
||||
if (ok)
|
||||
setOut(r, QByteArrayLiteral("notification sent"), 0);
|
||||
else
|
||||
setOut(r, QByteArrayLiteral("error: ") + err.toUtf8(), 1);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* --- clipboard_read / clipboard_write ----------------------------------- */
|
||||
|
||||
int clipboardProbe(char *why, size_t why_sz) {
|
||||
if (QGuiApplication::clipboard())
|
||||
return 0;
|
||||
snprintf(why, why_sz, "no QClipboard (no GUI application)");
|
||||
return -1;
|
||||
}
|
||||
|
||||
int clipboardReadRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call,
|
||||
cst_tool_result_t *r, void *ud) {
|
||||
(void)ctx;
|
||||
(void)ud;
|
||||
const char *cap = argOf(call, "max_bytes");
|
||||
const long max =
|
||||
cap && cap[0] ? strtol(cap, nullptr, 10) : 65536L;
|
||||
QString text;
|
||||
bool have = false;
|
||||
onGuiThread([&] {
|
||||
if (QClipboard *cb = QGuiApplication::clipboard()) {
|
||||
text = cb->text();
|
||||
have = true;
|
||||
}
|
||||
});
|
||||
if (!have) {
|
||||
setOut(r, QByteArrayLiteral("error: no clipboard"), 1);
|
||||
return 0;
|
||||
}
|
||||
QByteArray body = text.toUtf8();
|
||||
const bool cut = max > 0 && body.size() > max;
|
||||
if (cut)
|
||||
body.truncate(static_cast<int>(max));
|
||||
QByteArray out =
|
||||
QByteArrayLiteral("clipboard text (") + QByteArray::number(body.size()) +
|
||||
QByteArrayLiteral(" bytes") +
|
||||
(cut ? QByteArrayLiteral(", truncated") : QByteArray()) +
|
||||
QByteArrayLiteral("):\n") + body + QByteArrayLiteral("\n");
|
||||
setOut(r, out, 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int clipboardWriteRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call,
|
||||
cst_tool_result_t *r, void *ud) {
|
||||
(void)ctx;
|
||||
(void)ud;
|
||||
const char *text = argOf(call, "text");
|
||||
if (!text) {
|
||||
setOut(r, QByteArrayLiteral("error: clipboard_write needs text"), 1);
|
||||
return 0;
|
||||
}
|
||||
const QString t = QString::fromUtf8(text);
|
||||
bool ok = false;
|
||||
onGuiThread([&] {
|
||||
if (QClipboard *cb = QGuiApplication::clipboard()) {
|
||||
cb->setText(t);
|
||||
ok = true;
|
||||
}
|
||||
});
|
||||
if (ok)
|
||||
setOut(r, QByteArrayLiteral("clipboard: replaced with ") +
|
||||
QByteArray::number(t.toUtf8().size()) +
|
||||
QByteArrayLiteral(" bytes"),
|
||||
0);
|
||||
else
|
||||
setOut(r, QByteArrayLiteral("error: no clipboard"), 1);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* --- share --------------------------------------------------------------- */
|
||||
|
||||
/* A path inside the workspace, resolved the way the engine's file tools do:
|
||||
* relative to the workspace and never outside it. */
|
||||
QString resolveInWorkspace(const cst_tool_ctx_t *ctx, const char *rel) {
|
||||
if (!ctx || !ctx->workspace_root || !rel || !rel[0])
|
||||
return QString();
|
||||
const QString root = QDir::cleanPath(QString::fromUtf8(ctx->workspace_root));
|
||||
const QString full = QDir::cleanPath(root + QLatin1Char('/') +
|
||||
QString::fromUtf8(rel));
|
||||
if (full != root && !full.startsWith(root + QLatin1Char('/')))
|
||||
return QString();
|
||||
return full;
|
||||
}
|
||||
|
||||
QString mimeOf(const QString &path) {
|
||||
const QString ext = QFileInfo(path).suffix().toLower();
|
||||
if (ext == QLatin1String("png")) return QStringLiteral("image/png");
|
||||
if (ext == QLatin1String("jpg") || ext == QLatin1String("jpeg"))
|
||||
return QStringLiteral("image/jpeg");
|
||||
if (ext == QLatin1String("webp")) return QStringLiteral("image/webp");
|
||||
if (ext == QLatin1String("gif")) return QStringLiteral("image/gif");
|
||||
if (ext == QLatin1String("pdf")) return QStringLiteral("application/pdf");
|
||||
if (ext == QLatin1String("json")) return QStringLiteral("application/json");
|
||||
if (ext == QLatin1String("txt") || ext == QLatin1String("md") ||
|
||||
ext == QLatin1String("log"))
|
||||
return QStringLiteral("text/plain");
|
||||
return QStringLiteral("*/*");
|
||||
}
|
||||
|
||||
/* ACTION_SEND through the system share sheet. `text` shares a sentence (the
|
||||
* usual case: the user takes it to another app), `file` shares one file out of
|
||||
* the workspace by way of the FileProvider Qt's manifest template declares
|
||||
* (androidx.core.content.FileProvider, authority <package>.qtprovider). */
|
||||
bool shareText(const QString &text, const QString &subject, QString *err) {
|
||||
QJniObject ctx = appContext();
|
||||
if (!ctx.isValid()) {
|
||||
*err = QStringLiteral("no Android context");
|
||||
return false;
|
||||
}
|
||||
QJniObject send = QJniObject::fromString(QStringLiteral("android.intent.action.SEND"));
|
||||
QJniObject intent("android/content/Intent", "(Ljava/lang/String;)V",
|
||||
send.object<jstring>());
|
||||
intent.callObjectMethod("setType",
|
||||
"(Ljava/lang/String;)Landroid/content/Intent;",
|
||||
QJniObject::fromString(QStringLiteral("text/plain"))
|
||||
.object<jstring>());
|
||||
intent.callObjectMethod(
|
||||
"putExtra",
|
||||
"(Ljava/lang/String;Ljava/lang/CharSequence;)Landroid/content/Intent;",
|
||||
QJniObject::fromString(QStringLiteral("android.intent.extra.TEXT"))
|
||||
.object<jstring>(),
|
||||
QJniObject::fromString(text).object<jstring>());
|
||||
if (!subject.isEmpty())
|
||||
intent.callObjectMethod(
|
||||
"putExtra",
|
||||
"(Ljava/lang/String;Ljava/lang/CharSequence;)Landroid/content/Intent;",
|
||||
QJniObject::fromString(QStringLiteral("android.intent.extra.SUBJECT"))
|
||||
.object<jstring>(),
|
||||
QJniObject::fromString(subject).object<jstring>());
|
||||
QJniObject chooser("android/content/Intent", "(Ljava/lang/String;)V",
|
||||
QJniObject::fromString(QStringLiteral("android.intent.action.CHOOSER"))
|
||||
.object<jstring>());
|
||||
/* Intent.createChooser(Intent, CharSequence) -- the share sheet itself. */
|
||||
QJniObject sheet = QJniObject::callStaticObjectMethod(
|
||||
"android/content/Intent", "createChooser",
|
||||
"(Landroid/content/Intent;Ljava/lang/CharSequence;)Landroid/content/Intent;",
|
||||
intent.object<jobject>(),
|
||||
QJniObject::fromString(QStringLiteral("Share with")).object<jstring>());
|
||||
QJniObject toStart = sheet.isValid() ? sheet : intent;
|
||||
toStart.callObjectMethod("addFlags", "(I)Landroid/content/Intent;",
|
||||
static_cast<jint>(0x10000000) /* NEW_TASK */);
|
||||
ctx.callMethod<void>("startActivity", "(Landroid/content/Intent;)V",
|
||||
toStart.object<jobject>());
|
||||
return true;
|
||||
}
|
||||
|
||||
/* The content:// uri of a file inside the app's own data directory. The
|
||||
* FileProvider and its paths xml come from Qt's manifest template
|
||||
* (authority <package>.qtprovider, files-path "/"), which covers the whole
|
||||
* workspace and the session store. */
|
||||
QJniObject contentUriFor(const QString &path, QString *err) {
|
||||
QJniObject ctx = appContext();
|
||||
if (!ctx.isValid()) {
|
||||
*err = QStringLiteral("no Android context");
|
||||
return QJniObject();
|
||||
}
|
||||
QJniObject pkg = ctx.callObjectMethod("getPackageName", "()Ljava/lang/String;");
|
||||
const QString authority = pkg.toString() + QStringLiteral(".qtprovider");
|
||||
QJniObject file("java/io/File", "(Ljava/lang/String;)V",
|
||||
QJniObject::fromString(path).object<jstring>());
|
||||
QJniObject uri = QJniObject::callStaticObjectMethod(
|
||||
"androidx/core/content/FileProvider", "getUriForFile",
|
||||
"(Landroid/content/Context;Ljava/lang/String;Ljava/io/File;)Landroid/net/Uri;",
|
||||
ctx.object<jobject>(),
|
||||
QJniObject::fromString(authority).object<jstring>(),
|
||||
file.object<jobject>());
|
||||
if (!uri.isValid())
|
||||
*err = QStringLiteral("the FileProvider did not hand out a uri for ") + path;
|
||||
return uri;
|
||||
}
|
||||
|
||||
bool shareFile(const QString &path, const QString &text, QString *err) {
|
||||
QJniObject ctx = appContext();
|
||||
if (!ctx.isValid()) {
|
||||
*err = QStringLiteral("no Android context");
|
||||
return false;
|
||||
}
|
||||
QJniObject uri = contentUriFor(path, err);
|
||||
if (!uri.isValid())
|
||||
return false;
|
||||
QJniObject action = QJniObject::fromString(QStringLiteral("android.intent.action.SEND"));
|
||||
QJniObject intent("android/content/Intent", "(Ljava/lang/String;)V",
|
||||
action.object<jstring>());
|
||||
intent.callObjectMethod("setType",
|
||||
"(Ljava/lang/String;)Landroid/content/Intent;",
|
||||
QJniObject::fromString(mimeOf(path)).object<jstring>());
|
||||
intent.callObjectMethod(
|
||||
"putExtra",
|
||||
"(Ljava/lang/String;Landroid/os/Parcelable;)Landroid/content/Intent;",
|
||||
QJniObject::fromString(QStringLiteral("android.intent.extra.STREAM"))
|
||||
.object<jstring>(),
|
||||
uri.object<jobject>());
|
||||
if (!text.isEmpty())
|
||||
intent.callObjectMethod(
|
||||
"putExtra",
|
||||
"(Ljava/lang/String;Ljava/lang/CharSequence;)Landroid/content/Intent;",
|
||||
QJniObject::fromString(QStringLiteral("android.intent.extra.TEXT"))
|
||||
.object<jstring>(),
|
||||
QJniObject::fromString(text).object<jstring>());
|
||||
intent.callObjectMethod(
|
||||
"addFlags", "(I)Landroid/content/Intent;",
|
||||
static_cast<jint>(0x10000000 /* NEW_TASK */ | 0x00000001 /* GRANT_READ_URI_PERMISSION */));
|
||||
QJniObject sheet = QJniObject::callStaticObjectMethod(
|
||||
"android/content/Intent", "createChooser",
|
||||
"(Landroid/content/Intent;Ljava/lang/CharSequence;)Landroid/content/Intent;",
|
||||
intent.object<jobject>(),
|
||||
QJniObject::fromString(QStringLiteral("Share with")).object<jstring>());
|
||||
QJniObject toStart = sheet.isValid() ? sheet : intent;
|
||||
toStart.callObjectMethod("addFlags", "(I)Landroid/content/Intent;",
|
||||
static_cast<jint>(0x10000000));
|
||||
ctx.callMethod<void>("startActivity", "(Landroid/content/Intent;)V",
|
||||
toStart.object<jobject>());
|
||||
return true;
|
||||
}
|
||||
|
||||
int shareRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call,
|
||||
cst_tool_result_t *r, void *ud) {
|
||||
(void)ud;
|
||||
const char *text = argOf(call, "text");
|
||||
const char *file = argOf(call, "file");
|
||||
const char *subject = argOf(call, "subject");
|
||||
if ((!text || !text[0]) && (!file || !file[0])) {
|
||||
setOut(r, QByteArrayLiteral("error: share needs text or file"), 1);
|
||||
return 0;
|
||||
}
|
||||
QString path;
|
||||
if (file && file[0]) {
|
||||
path = resolveInWorkspace(ctx, file);
|
||||
if (path.isEmpty() || !QFileInfo::exists(path)) {
|
||||
setOut(r, QByteArrayLiteral("error: ") + QByteArray(file) +
|
||||
QByteArrayLiteral(" is not inside the workspace"),
|
||||
1);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
const QString body = text ? QString::fromUtf8(text) : QString();
|
||||
const QString subjectLine = subject ? QString::fromUtf8(subject) : QString();
|
||||
QString err;
|
||||
bool ok = false;
|
||||
onGuiThread([&] {
|
||||
ok = path.isEmpty() ? shareText(body, subjectLine, &err)
|
||||
: shareFile(path, body, &err);
|
||||
});
|
||||
if (!ok) {
|
||||
setOut(r, QByteArrayLiteral("error: ") + err.toUtf8(), 1);
|
||||
return 0;
|
||||
}
|
||||
setOut(r, path.isEmpty()
|
||||
? QByteArrayLiteral("the share sheet is open with the text")
|
||||
: QByteArrayLiteral("the share sheet is open with ") +
|
||||
path.toUtf8(),
|
||||
0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int shareProbe(char *why, size_t why_sz) {
|
||||
if (appContext().isValid())
|
||||
return 0;
|
||||
snprintf(why, why_sz, "no Android activity to share from");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Registered once; the engine keeps a copy until the owner is unregistered. */
|
||||
cst_tool_def_t g_notify;
|
||||
cst_tool_def_t g_clip_read;
|
||||
cst_tool_def_t g_clip_write;
|
||||
cst_tool_def_t g_share;
|
||||
|
||||
} // namespace
|
||||
|
||||
/* A file card's "open": whatever app the phone has for the type, with the read
|
||||
* permission granted to the app that picks it up. */
|
||||
QString openLocalFile(const QString &path, const QString &title,
|
||||
QString *status) {
|
||||
const QString name = title.isEmpty() ? QFileInfo(path).fileName() : title;
|
||||
if (qEnvironmentVariableIsSet("CST_APP_NO_OPEN")) {
|
||||
if (status)
|
||||
*status = QStringLiteral("opened %1 (test mode)").arg(name);
|
||||
return QString();
|
||||
}
|
||||
if (!QFileInfo::exists(path))
|
||||
return QStringLiteral("the file is gone: %1").arg(name);
|
||||
QString err;
|
||||
bool ok = false;
|
||||
onGuiThread([&] {
|
||||
QJniObject ctx = appContext();
|
||||
if (!ctx.isValid()) {
|
||||
err = QStringLiteral("no Android context");
|
||||
return;
|
||||
}
|
||||
QJniObject uri = contentUriFor(path, &err);
|
||||
if (!uri.isValid())
|
||||
return;
|
||||
QJniObject action =
|
||||
QJniObject::fromString(QStringLiteral("android.intent.action.VIEW"));
|
||||
QJniObject intent("android/content/Intent", "(Ljava/lang/String;)V",
|
||||
action.object<jstring>());
|
||||
intent.callObjectMethod(
|
||||
"setDataAndType",
|
||||
"(Landroid/net/Uri;Ljava/lang/String;)Landroid/content/Intent;",
|
||||
uri.object<jobject>(),
|
||||
QJniObject::fromString(mimeOf(path)).object<jstring>());
|
||||
intent.callObjectMethod("addFlags", "(I)Landroid/content/Intent;",
|
||||
static_cast<jint>(0x10000000 /* NEW_TASK */ |
|
||||
0x00000001 /* GRANT_READ_URI */));
|
||||
/* Nothing installed for the type: say so rather than fail silently. */
|
||||
QJniObject pm = ctx.callObjectMethod(
|
||||
"getPackageManager", "()Landroid/content/pm/PackageManager;");
|
||||
QJniObject found =
|
||||
pm.isValid()
|
||||
? pm.callObjectMethod(
|
||||
"resolveActivity",
|
||||
"(Landroid/content/Intent;I)Landroid/content/pm/ResolveInfo;",
|
||||
intent.object<jobject>(),
|
||||
static_cast<jint>(0x00010000) /* MATCH_DEFAULT_ONLY */)
|
||||
: QJniObject();
|
||||
if (!found.isValid()) {
|
||||
err = QStringLiteral("no app on the phone opens %1").arg(name);
|
||||
return;
|
||||
}
|
||||
ctx.callMethod<void>("startActivity", "(Landroid/content/Intent;)V",
|
||||
intent.object<jobject>());
|
||||
ok = true;
|
||||
});
|
||||
if (!ok)
|
||||
return err.isEmpty() ? QStringLiteral("cannot open %1").arg(name) : err;
|
||||
if (status)
|
||||
*status = QStringLiteral("opened %1").arg(name);
|
||||
return QString();
|
||||
}
|
||||
|
||||
/* The share sheet, the same way the share tool opens it. */
|
||||
QString shareLocalFile(const QString &path, const QString &title,
|
||||
QString *status) {
|
||||
const QString name = title.isEmpty() ? QFileInfo(path).fileName() : title;
|
||||
if (qEnvironmentVariableIsSet("CST_APP_NO_OPEN")) {
|
||||
if (status)
|
||||
*status = QStringLiteral("shared %1 (test mode)").arg(name);
|
||||
return QString();
|
||||
}
|
||||
if (!QFileInfo::exists(path))
|
||||
return QStringLiteral("the file is gone: %1").arg(name);
|
||||
QString err;
|
||||
bool ok = false;
|
||||
onGuiThread([&] { ok = shareFile(path, QString(), &err); });
|
||||
if (!ok)
|
||||
return err.isEmpty() ? QStringLiteral("cannot share %1").arg(name) : err;
|
||||
if (status)
|
||||
*status = QStringLiteral("share sheet: %1").arg(name);
|
||||
return QString();
|
||||
}
|
||||
|
||||
int platformRegisterAndroid(AgentCore *core) {
|
||||
if (!core)
|
||||
return 0;
|
||||
return 0; /* no Android tools yet */
|
||||
int n = 0;
|
||||
|
||||
{
|
||||
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
|
||||
{"message", "string", 1, "text of the notification"},
|
||||
{"title", "string", 0, "title line (default: CST-agent)"},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr}};
|
||||
memset(&g_notify, 0, sizeof(g_notify));
|
||||
setMeta(&g_notify, "notification_send",
|
||||
"Show a notification in the phone's shade (what the user sees "
|
||||
"outside the app).",
|
||||
CST_TP_ASK, "notify", kAndroidOnly, notifyProbe);
|
||||
memcpy(g_notify.params, params, sizeof(params));
|
||||
g_notify.run_ex = notifyRun;
|
||||
g_notify.owner = "app:android";
|
||||
n += core->registerTool(&g_notify) ? 1 : 0;
|
||||
}
|
||||
{
|
||||
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
|
||||
{"max_bytes", "integer", 0, "cap on the returned text (default 65536)"},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr}};
|
||||
memset(&g_clip_read, 0, sizeof(g_clip_read));
|
||||
setMeta(&g_clip_read, "clipboard_read",
|
||||
"Read the text on the phone's clipboard — what the user copied "
|
||||
"outside the workspace. Returns UTF-8 text.",
|
||||
CST_TP_READ, "read", kAndroidOnly, clipboardProbe);
|
||||
memcpy(g_clip_read.params, params, sizeof(params));
|
||||
g_clip_read.run_ex = clipboardReadRun;
|
||||
g_clip_read.owner = "app:android";
|
||||
n += core->registerTool(&g_clip_read) ? 1 : 0;
|
||||
}
|
||||
{
|
||||
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
|
||||
{"text", "string", 1, "text to put on the clipboard"},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr}};
|
||||
memset(&g_clip_write, 0, sizeof(g_clip_write));
|
||||
setMeta(&g_clip_write, "clipboard_write",
|
||||
"Replace the phone's clipboard with text, so the user can paste it "
|
||||
"into another app. Overwrites what they had copied.",
|
||||
CST_TP_WRITE, "write", kAndroidOnly, clipboardProbe);
|
||||
memcpy(g_clip_write.params, params, sizeof(params));
|
||||
g_clip_write.run_ex = clipboardWriteRun;
|
||||
g_clip_write.owner = "app:android";
|
||||
n += core->registerTool(&g_clip_write) ? 1 : 0;
|
||||
}
|
||||
{
|
||||
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
|
||||
{"text", "string", 0, "text to share"},
|
||||
{"file", "string", 0, "workspace-relative file to share"},
|
||||
{"subject", "string", 0, "subject line of the shared item"},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr}};
|
||||
memset(&g_share, 0, sizeof(g_share));
|
||||
setMeta(&g_share, "share",
|
||||
"Hand text or a workspace file to the system share sheet, so the "
|
||||
"user can send it to another app.",
|
||||
CST_TP_ASK, "share", kAndroidOnly, shareProbe);
|
||||
memcpy(g_share.params, params, sizeof(params));
|
||||
g_share.run_ex = shareRun;
|
||||
g_share.owner = "app:android";
|
||||
n += core->registerTool(&g_share) ? 1 : 0;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
} // namespace app
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
* the capability -- platforms + probe decide whether the tool is offered. */
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QClipboard>
|
||||
#include <QDesktopServices>
|
||||
#include <QFileInfo>
|
||||
#include <QGuiApplication>
|
||||
#include <QProcess>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QUrl>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
@@ -86,6 +90,43 @@ cst_tool_def_t g_notify;
|
||||
|
||||
} // namespace
|
||||
|
||||
/* A file card's "open": xdg-open through QDesktopServices, so the desktop's
|
||||
* own application for the type comes up. */
|
||||
QString openLocalFile(const QString &path, const QString &title,
|
||||
QString *status) {
|
||||
const QString name = title.isEmpty() ? QFileInfo(path).fileName() : title;
|
||||
if (qEnvironmentVariableIsSet("CST_APP_NO_OPEN")) {
|
||||
if (status)
|
||||
*status = QStringLiteral("opened %1 (test mode)").arg(name);
|
||||
return QString();
|
||||
}
|
||||
if (!QFileInfo::exists(path))
|
||||
return QStringLiteral("the file is gone: %1").arg(name);
|
||||
if (!QDesktopServices::openUrl(QUrl::fromLocalFile(path)))
|
||||
return QStringLiteral("no application on this desktop opens %1").arg(name);
|
||||
if (status)
|
||||
*status = QStringLiteral("opened %1").arg(name);
|
||||
return QString();
|
||||
}
|
||||
|
||||
/* No share sheet here: the path is what another program (or another window)
|
||||
* needs, so it goes to the clipboard. */
|
||||
QString shareLocalFile(const QString &path, const QString &title,
|
||||
QString *status) {
|
||||
const QString name = title.isEmpty() ? QFileInfo(path).fileName() : title;
|
||||
if (qEnvironmentVariableIsSet("CST_APP_NO_OPEN")) {
|
||||
if (status)
|
||||
*status = QStringLiteral("shared %1 (test mode)").arg(name);
|
||||
return QString();
|
||||
}
|
||||
if (!QFileInfo::exists(path))
|
||||
return QStringLiteral("the file is gone: %1").arg(name);
|
||||
QGuiApplication::clipboard()->setText(path);
|
||||
if (status)
|
||||
*status = QStringLiteral("%1: the path is on the clipboard").arg(name);
|
||||
return QString();
|
||||
}
|
||||
|
||||
int platformRegisterLinux(AgentCore *core) {
|
||||
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
|
||||
{"message", "string", 1, "text of the notification"},
|
||||
|
||||
+140
-12
@@ -45,16 +45,18 @@ void check(bool ok, const QString &what) {
|
||||
g_fail++;
|
||||
}
|
||||
|
||||
QQuickItem *byName(QQuickItem *root, const QString &name) {
|
||||
/* Every item with this objectName in the visual tree. A view that has just
|
||||
* been replayed can still hold the delegates it is about to drop, so a name
|
||||
* can match more than one item for a moment. */
|
||||
void collectNamed(QQuickItem *root, const QString &name,
|
||||
QList<QQuickItem *> *out) {
|
||||
if (!root)
|
||||
return nullptr;
|
||||
return;
|
||||
if (root->objectName() == name)
|
||||
return root;
|
||||
out->append(root);
|
||||
const QList<QQuickItem *> kids = root->childItems();
|
||||
for (QQuickItem *c : kids)
|
||||
if (QQuickItem *hit = byName(c, name))
|
||||
return hit;
|
||||
return nullptr;
|
||||
collectNamed(c, name, out);
|
||||
}
|
||||
|
||||
int countNamed(QQuickItem *root, const QString &prefix) {
|
||||
@@ -71,9 +73,16 @@ int countNamed(QQuickItem *root, const QString &prefix) {
|
||||
* controls); a view's delegates can be owned by the model instead, so the
|
||||
* visual tree -- what a finger can actually reach -- is walked as well. */
|
||||
QQuickItem *item(QQuickWindow *win, const QString &name) {
|
||||
QList<QQuickItem *> hits;
|
||||
collectNamed(win->contentItem(), name, &hits);
|
||||
/* What a finger can reach is the one the test means: a delegate on its way
|
||||
* out is still in the tree but invisible. */
|
||||
for (QQuickItem *it : hits)
|
||||
if (it->isVisible())
|
||||
return it;
|
||||
if (QQuickItem *it = win->findChild<QQuickItem *>(name))
|
||||
return it;
|
||||
return byName(win->contentItem(), name);
|
||||
return hits.isEmpty() ? nullptr : hits.first();
|
||||
}
|
||||
|
||||
/* The centre of an item, in window coordinates. */
|
||||
@@ -156,6 +165,33 @@ QString writeFixtureSession(const QString &ws, const QString &id) {
|
||||
snprintf(m.tool_call_id, sizeof(m.tool_call_id), "%s", "call_1");
|
||||
snprintf(m.tool_name, sizeof(m.tool_name), "%s", "read_file");
|
||||
cst_session_append(s, &m);
|
||||
/* A delivered file (deliver_file): the copy the agent hands to the person,
|
||||
* marked for_user -- the row a file card is drawn from. */
|
||||
memset(&m, 0, sizeof(m));
|
||||
m.role = CST_ROLE_ASSISTANT;
|
||||
m.tool_calls_json = strdup(
|
||||
"[{\"id\":\"call_2\",\"type\":\"function\",\"function\":"
|
||||
"{\"name\":\"deliver_file\",\"arguments\":\"{\\\"path\\\":"
|
||||
"\\\"report.md\\\"}\"}}]");
|
||||
cst_session_append(s, &m);
|
||||
memset(&m, 0, sizeof(m));
|
||||
{
|
||||
const QString src = ws + QStringLiteral("/report.md");
|
||||
QFile rf(src);
|
||||
if (rf.open(QIODevice::WriteOnly))
|
||||
rf.write("# report\n");
|
||||
rf.close();
|
||||
char aerr[256] = "";
|
||||
m.attach = cst_attach_import_artifact(path, src.toUtf8().constData(), NULL,
|
||||
aerr, sizeof(aerr));
|
||||
m.nattach = m.attach ? 1 : 0;
|
||||
}
|
||||
m.role = CST_ROLE_TOOL;
|
||||
m.content = strdup("delivered report.md\n");
|
||||
snprintf(m.tool_call_id, sizeof(m.tool_call_id), "%s", "call_2");
|
||||
snprintf(m.tool_name, sizeof(m.tool_name), "%s", "deliver_file");
|
||||
cst_session_append(s, &m);
|
||||
cst_attachments_free(m.attach, m.nattach);
|
||||
memset(&m, 0, sizeof(m));
|
||||
m.role = CST_ROLE_ASSISTANT;
|
||||
m.reasoning = strdup("checked the file");
|
||||
@@ -200,6 +236,9 @@ int runUiTest(AgentCore &core, const QString &size) {
|
||||
* session of the store (cst_agent_open()), so the window is created with a
|
||||
* transcript already in the engine. Put one there before the QML loads,
|
||||
* which is exactly what a restart looks like. */
|
||||
/* A file card hands its file to the system: in a test run nothing may open
|
||||
* for real, and the platform layer itself honours this (see ToolProvider.h). */
|
||||
qputenv("CST_APP_NO_OPEN", "1");
|
||||
const QString resumed =
|
||||
writeFixtureSession(core.workspace(), QStringLiteral("20260101-115959-on"));
|
||||
check(!resumed.isEmpty() && core.switchSession(resumed),
|
||||
@@ -232,7 +271,7 @@ int runUiTest(AgentCore &core, const QString &size) {
|
||||
{
|
||||
QQuickItem *tr = item(win, QStringLiteral("transcript"));
|
||||
const int rows = tr ? tr->property("count").toInt() : -1;
|
||||
check(rows == 5,
|
||||
check(rows == 6,
|
||||
QStringLiteral("the open session is on screen at startup (%1 rows)")
|
||||
.arg(rows));
|
||||
}
|
||||
@@ -304,11 +343,12 @@ int runUiTest(AgentCore &core, const QString &size) {
|
||||
check(core.sessions().size() >= 2,
|
||||
QStringLiteral("the store lists it next to the open one"));
|
||||
|
||||
/* the fixture is: user, agent + call, result, thought, agent */
|
||||
/* the fixture is: user, agent + call, result, agent + call, delivered
|
||||
* file, thought, agent */
|
||||
clickMouse(win, QStringLiteral("sessionRow:") + fixture);
|
||||
check(core.sessionId() == fixture,
|
||||
QStringLiteral("a click on a row switches the engine"));
|
||||
check(list && list->property("count").toInt() == 5,
|
||||
check(list && list->property("count").toInt() == 6,
|
||||
QStringLiteral("the reopened transcript is replayed into the view"));
|
||||
{
|
||||
bool toolOk = false;
|
||||
@@ -324,6 +364,58 @@ int runUiTest(AgentCore &core, const QString &size) {
|
||||
check(toolOk, QStringLiteral("a tool result is paired with its call"));
|
||||
}
|
||||
|
||||
/* --- a delivered file: a card with open and share --------------------- */
|
||||
{
|
||||
/* The view is re-laid out off the click; give it the frame it needs. */
|
||||
QTest::qWait(150);
|
||||
const int cards =
|
||||
countNamed(win->contentItem(), QStringLiteral("fileCard"));
|
||||
check(cards == 1,
|
||||
QStringLiteral("a delivered file is a card in the tool row (%1)")
|
||||
.arg(cards));
|
||||
check(item(win, QStringLiteral("fileOpen0")) != nullptr &&
|
||||
item(win, QStringLiteral("fileShare0")) != nullptr,
|
||||
QStringLiteral("the card carries open and share"));
|
||||
win->setProperty("statusText", QString());
|
||||
clickMouse(win, QStringLiteral("fileOpen0"));
|
||||
const QString opened = win->property("statusText").toString();
|
||||
check(opened.startsWith(QLatin1String("opened ")),
|
||||
QStringLiteral("Open hands the file to the system (%1)").arg(opened));
|
||||
clickMouse(win, QStringLiteral("fileShare0"));
|
||||
const QString shared = win->property("statusText").toString();
|
||||
check(shared.contains(QLatin1String("report.md")),
|
||||
QStringLiteral("Share names the file (%1)").arg(shared));
|
||||
const QString refused =
|
||||
core.openArtifact(QStringLiteral("art-not-one-of-ours.md"));
|
||||
check(refused.contains(QLatin1String("no such file")),
|
||||
QStringLiteral("a name this session does not own is refused (%1)")
|
||||
.arg(refused));
|
||||
/* And the card says it: a tap that cannot work must not look dead. */
|
||||
QQuickItem *strip = nullptr;
|
||||
{
|
||||
QList<QQuickItem *> hits;
|
||||
collectNamed(win->contentItem(), QStringLiteral("imageStrip"), &hits);
|
||||
for (QQuickItem *it : hits)
|
||||
if (it->isVisible())
|
||||
strip = it;
|
||||
}
|
||||
bool called = false;
|
||||
if (strip)
|
||||
called = QMetaObject::invokeMethod(
|
||||
strip, "openFile",
|
||||
Q_ARG(QVariant, QStringLiteral("art-not-one-of-ours.md")));
|
||||
const QVariant err = strip ? strip->property("errorText") : QVariant();
|
||||
check(called && err.toString().contains(QLatin1String("no such file")),
|
||||
QStringLiteral("the card shows why it could not open it (%1)")
|
||||
.arg(err.toString()));
|
||||
QTest::qWait(150);
|
||||
QQuickItem *errText = item(win, QStringLiteral("fileError"));
|
||||
check(errText && errText->isVisible() &&
|
||||
errText->property("text").toString().contains(
|
||||
QLatin1String("no such file")),
|
||||
QStringLiteral("and the line is where the card is"));
|
||||
}
|
||||
|
||||
/* new sessions: the engine moves, the view empties, the store grows */
|
||||
const int before = core.sessions().size();
|
||||
clickMouse(win, QStringLiteral("newSessionButton"));
|
||||
@@ -345,10 +437,11 @@ int runUiTest(AgentCore &core, const QString &size) {
|
||||
check(core.sessionId() == second,
|
||||
QStringLiteral("deleting another session leaves the open one alone"));
|
||||
|
||||
/* and back to the fixture, which still has its five rows */
|
||||
/* and back to the fixture, which still has its six rows */
|
||||
QTest::qWait(150);
|
||||
clickMouse(win, QStringLiteral("sessionRow:") + fixture);
|
||||
check(core.sessionId() == fixture && list && list->property("count").toInt() == 5,
|
||||
check(core.sessionId() == fixture && list &&
|
||||
list->property("count").toInt() == 6,
|
||||
QStringLiteral("switching back replays it again"));
|
||||
{
|
||||
bool promptImage = false;
|
||||
@@ -703,6 +796,41 @@ int runUiTest(AgentCore &core, const QString &size) {
|
||||
maybeShot(win, QStringLiteral("-toolimage"));
|
||||
}
|
||||
|
||||
/* --- the order the engine uses on a live turn: the result first, the
|
||||
* files after it (a card is complete by then, which used to lose them) --- */
|
||||
{
|
||||
const int before = list->property("count").toInt();
|
||||
const int cardsBefore =
|
||||
countNamed(win->contentItem(), QStringLiteral("fileCard"));
|
||||
event4("engineToolEvent", QStringLiteral("open"),
|
||||
QStringLiteral("call-file"), QStringLiteral("deliver_file"),
|
||||
QStringLiteral("{\"path\":\"report.md\"}"));
|
||||
event4("engineToolEvent", QStringLiteral("done"),
|
||||
QStringLiteral("call-file"), QStringLiteral("deliver_file"),
|
||||
QStringLiteral("exit=0 bytes=120"));
|
||||
event4("engineToolResult", QStringLiteral("call-file"),
|
||||
QStringLiteral("deliver_file"), QVariant(0),
|
||||
QStringLiteral("delivered report.md (text/markdown, 120 B)"));
|
||||
const QString files =
|
||||
QStringLiteral("file|art-0123456789abcdef.md|report.md|tool|"
|
||||
"text/markdown|120|0x0");
|
||||
check(QMetaObject::invokeMethod(win, "engineToolImages",
|
||||
Q_ARG(QVariant, QStringLiteral("call-file")),
|
||||
Q_ARG(QVariant, files)),
|
||||
QStringLiteral("the files a tool delivered reach the view"));
|
||||
QTest::qWait(150);
|
||||
const QVariantMap card = rowAt(before);
|
||||
check(card.value(QStringLiteral("images"))
|
||||
.toString()
|
||||
.contains(QLatin1String("art-0123456789abcdef.md")),
|
||||
QStringLiteral("a card that is already complete still takes them"));
|
||||
check(countNamed(win->contentItem(), QStringLiteral("fileCard")) >
|
||||
cardsBefore,
|
||||
QStringLiteral("so the delivered file is on the card (%1 -> %2)")
|
||||
.arg(cardsBefore)
|
||||
.arg(countNamed(win->contentItem(), QStringLiteral("fileCard"))));
|
||||
}
|
||||
|
||||
/* --- a narrow window: dock -> drawer, and the button opens it --- */
|
||||
resizeTo(win, 460, 900);
|
||||
check(docked && !docked->isVisible(),
|
||||
|
||||
Reference in New Issue
Block a user