- consent: on_permission / on_ask_user wait at most two / five minutes, and in
the background (a phone off screen, a window that is gone) the answer is
refused at once with a reason -- a dialog nobody can see blocked the worker
for good, which is what "the app hangs while the agent works" looked like.
- finishTurn() no longer joins the thread it is called from; the event loop
reaps the Turn instead.
- AndroidTools: the hop to the GUI thread pings first and gives up after 3 s
instead of waiting forever on a busy GUI thread.
- the session list stopped reading every transcript: cst_agent_store_info()
counts newlines in 64 KiB blocks (first_user only within the first 256 KiB),
and AgentCore::sessions() reuses a row while (mtime, size) is unchanged.
- session_search gets a budget (max_bytes 32 MB, max_seconds 5 s), skips lines
over 64 KiB, and says when the budget stopped the walk.
- the header shows the phase ("shell: make -j8"), so a long turn no longer
looks like a stuck one.
- md_to_pdf: the front end's own Qt tool (owner app:qt, every platform):
markdown or a workspace .md/.html becomes an A4 PDF with 15 mm margins,
paginated by hand, written into the workspace and delivered as a file card.
No PrintSupport, so the phone gets it too.
- --uitest runs md_to_pdf through the registry with Chinese text and checks the
bytes are a PDF; app/CMakeLists.txt no longer drops src/web_md.c from the
standalone engine build, which the window needs to render markdown.
Version 0.9.7.
850 lines
33 KiB
C++
850 lines
33 KiB
C++
/* Platform tools on Android, implemented by the app: the same contract as
|
|
* app/src/platform/linux/NotifyTool.cpp, with Android APIs behind it.
|
|
*
|
|
* The engine only ever sees the capability: `platforms = kAndroidOnly` (the
|
|
* engine reports "android" when it was compiled with the NDK, see
|
|
* src/tools/registry.c: cst_platform_name()) plus an `available` probe decide
|
|
* whether a tool reaches the prompt and tools[] at all -- a Termux build on
|
|
* the same device reports "termux" and gets the android tools too, an APK
|
|
* never gets the desktop ones. `owner = "app:android"` is what
|
|
* 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 (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
|
|
* 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 <QJniObject>
|
|
#include <QMetaObject>
|
|
#include <QSemaphore>
|
|
#include <QStandardPaths>
|
|
#include <QString>
|
|
#include <QStringList>
|
|
#include <QThread>
|
|
|
|
#include <QtCore/private/qandroidextras_p.h>
|
|
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <memory>
|
|
#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;
|
|
}
|
|
|
|
/* How long the worker is willing to wait for the GUI thread to pick its work
|
|
* up. The hop itself is always waited for to the end (the closure writes into
|
|
* the caller's stack), so what this bounds is the case that makes a turn look
|
|
* stuck: a GUI thread that is not getting to the event loop at all. */
|
|
constexpr int kGuiStallMs = 3000;
|
|
|
|
/* 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.
|
|
*
|
|
* The wait is bounded: a blocking queued call to a GUI thread that is busy
|
|
* (the session list reading transcripts used to do exactly that) would park
|
|
* the worker for as long as it lasts. A cheap ping first tells whether the
|
|
* GUI thread is there at all; only then is the real work handed over and
|
|
* waited for, which keeps the closure valid for its whole life. */
|
|
template <typename Fn>
|
|
bool onGuiThread(Fn &&fn) {
|
|
if (!qApp || QThread::currentThread() == qApp->thread()) {
|
|
fn();
|
|
return true;
|
|
}
|
|
auto pinged = std::make_shared<QSemaphore>(0);
|
|
QMetaObject::invokeMethod(qApp, [pinged] { pinged->release(); },
|
|
Qt::QueuedConnection);
|
|
if (!pinged->tryAcquire(1, kGuiStallMs))
|
|
return false; /* a GUI thread that is not answering: do not wait on it */
|
|
QMetaObject::invokeMethod(qApp, std::forward<Fn>(fn),
|
|
Qt::BlockingQueuedConnection);
|
|
return true;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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");
|
|
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>());
|
|
}
|
|
|
|
/* 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;
|
|
}
|
|
/* Tapping it opens the app again (the launch intent of the package). */
|
|
QJniObject pm = ctx.callObjectMethod(
|
|
"getPackageManager", "()Landroid/content/pm/PackageManager;");
|
|
/* 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>());
|
|
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;
|
|
}
|
|
|
|
/* 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();
|
|
}
|
|
|
|
/* An Intent aimed at one of our own classes, by name: the Java side is
|
|
* reached through an explicit component, so no other app can call it (and no
|
|
* intent filter has to exist). Empty action means "start". */
|
|
QJniObject serviceIntent(const QJniObject &ctx, const QString &action) {
|
|
QJniObject pkg = ctx.callObjectMethod("getPackageName",
|
|
"()Ljava/lang/String;");
|
|
if (!pkg.isValid())
|
|
return QJniObject();
|
|
QJniObject comp(
|
|
"android/content/ComponentName", "(Ljava/lang/String;Ljava/lang/String;)V",
|
|
pkg.object<jstring>(),
|
|
QJniObject::fromString(
|
|
QStringLiteral("xyz.whcself.cstagent.CstService"))
|
|
.object<jstring>());
|
|
if (!comp.isValid())
|
|
return QJniObject();
|
|
QJniObject intent("android/content/Intent");
|
|
intent.callObjectMethod(
|
|
"setComponent",
|
|
"(Landroid/content/ComponentName;)Landroid/content/Intent;",
|
|
comp.object<jobject>());
|
|
if (!action.isEmpty())
|
|
intent.callObjectMethod("setAction",
|
|
"(Ljava/lang/String;)Landroid/content/Intent;",
|
|
QJniObject::fromString(action).object<jstring>());
|
|
return intent;
|
|
}
|
|
|
|
/* A turn can outlive the screen, and a phone freezes or kills an app it
|
|
* cannot see: CstService (app/android/src/xyz/whcself/cstagent/CstService.java)
|
|
* is started as a foreground service for as long as one runs, and it takes the
|
|
* partial wake lock that keeps the CPU awake with the screen off. The explicit
|
|
* component means nothing outside the app can start it. */
|
|
QString holdForeground(const QString &text) {
|
|
if (qEnvironmentVariableIsSet("CST_APP_NO_SERVICE"))
|
|
return QString(); /* a test run leaves the phone alone */
|
|
QString err;
|
|
onGuiThread([&] {
|
|
QJniObject ctx = appContext();
|
|
if (!ctx.isValid()) {
|
|
err = QStringLiteral("no Android context");
|
|
return;
|
|
}
|
|
QJniObject intent = serviceIntent(ctx, QString());
|
|
if (!intent.isValid()) {
|
|
err = QStringLiteral("no ComponentName for CstService");
|
|
return;
|
|
}
|
|
intent.callObjectMethod(
|
|
"putExtra",
|
|
"(Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent;",
|
|
QJniObject::fromString(QStringLiteral("text")).object<jstring>(),
|
|
QJniObject::fromString(text).object<jstring>());
|
|
QJniObject started =
|
|
sdkInt() >= 26
|
|
? ctx.callObjectMethod(
|
|
"startForegroundService",
|
|
"(Landroid/content/Intent;)Landroid/content/ComponentName;",
|
|
intent.object<jobject>())
|
|
: ctx.callObjectMethod(
|
|
"startService",
|
|
"(Landroid/content/Intent;)Landroid/content/ComponentName;",
|
|
intent.object<jobject>());
|
|
if (!started.isValid())
|
|
err = QStringLiteral("the phone refused the foreground service");
|
|
});
|
|
return err;
|
|
}
|
|
|
|
void releaseForeground() {
|
|
if (qEnvironmentVariableIsSet("CST_APP_NO_SERVICE"))
|
|
return;
|
|
onGuiThread([&] {
|
|
QJniObject ctx = appContext();
|
|
if (!ctx.isValid())
|
|
return;
|
|
QJniObject intent = serviceIntent(
|
|
ctx, QStringLiteral("xyz.whcself.cstagent.STOP"));
|
|
if (intent.isValid())
|
|
ctx.callMethod<jboolean>("stopService",
|
|
"(Landroid/content/Intent;)Z",
|
|
intent.object<jobject>());
|
|
});
|
|
}
|
|
|
|
/* What the picker gave us -> a path the engine can open. A SAF URI is copied
|
|
* into the app's own cache (the URI's grant covers the read; no permission is
|
|
* involved), a plain path is left alone. */
|
|
QString materializeAttachment(const QString &pathOrUri, QString *err) {
|
|
if (err)
|
|
err->clear();
|
|
if (pathOrUri.isEmpty())
|
|
return QString();
|
|
if (!pathOrUri.startsWith(QLatin1String("content://"))) {
|
|
QString p = pathOrUri;
|
|
if (p.startsWith(QLatin1String("file://")))
|
|
p = p.mid(7);
|
|
return p;
|
|
}
|
|
const QString dest = QStandardPaths::writableLocation(
|
|
QStandardPaths::CacheLocation) +
|
|
QStringLiteral("/picked");
|
|
QString result, failure;
|
|
onGuiThread([&] {
|
|
QJniObject ctx = appContext();
|
|
if (!ctx.isValid()) {
|
|
failure = QStringLiteral("no Android context to copy the file with");
|
|
return;
|
|
}
|
|
QJniObject out = QJniObject::callStaticObjectMethod(
|
|
"xyz/whcself/cstagent/AttachFile", "materialize",
|
|
"(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)"
|
|
"Ljava/lang/String;",
|
|
ctx.object<jobject>(), QJniObject::fromString(pathOrUri).object<jstring>(),
|
|
QJniObject::fromString(dest).object<jstring>());
|
|
if (!out.isValid()) {
|
|
failure = QStringLiteral("the phone could not hand that file over");
|
|
return;
|
|
}
|
|
const QString s = out.toString();
|
|
if (s.startsWith(QLatin1String("error: ")))
|
|
failure = s.mid(7);
|
|
else if (!s.isEmpty())
|
|
result = s;
|
|
/* "" means it was a path all along; the caller keeps what it had */
|
|
});
|
|
if (!failure.isEmpty()) {
|
|
if (err)
|
|
*err = failure;
|
|
return QString();
|
|
}
|
|
return result.isEmpty() ? pathOrUri : result;
|
|
}
|
|
|
|
bool hasAllFilesAccess() {
|
|
bool granted = true;
|
|
onGuiThread([&] {
|
|
QJniObject ctx = appContext();
|
|
if (!ctx.isValid())
|
|
return;
|
|
granted = QJniObject::callStaticMethod<jboolean>(
|
|
"xyz/whcself/cstagent/AttachFile", "hasAllFilesAccess",
|
|
"(Landroid/content/Context;)Z", ctx.object<jobject>());
|
|
});
|
|
return granted;
|
|
}
|
|
|
|
QString requestAllFilesAccess() {
|
|
if (qEnvironmentVariableIsSet("CST_APP_NO_PERM"))
|
|
return QString(); /* a test run touches no settings */
|
|
QString err;
|
|
onGuiThread([&] {
|
|
QJniObject ctx = appContext();
|
|
if (!ctx.isValid())
|
|
return;
|
|
QJniObject out = QJniObject::callStaticObjectMethod(
|
|
"xyz/whcself/cstagent/AttachFile", "requestAllFilesAccess",
|
|
"(Landroid/content/Context;)Ljava/lang/String;",
|
|
ctx.object<jobject>());
|
|
if (out.isValid()) {
|
|
const QString s = out.toString();
|
|
if (!s.isEmpty())
|
|
err = s;
|
|
}
|
|
});
|
|
return err;
|
|
}
|
|
|
|
int platformRegisterAndroid(AgentCore *core) {
|
|
if (!core)
|
|
return 0;
|
|
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
|