Move Qt/Tauri/full Web SPA under archive/, ship hermit as CLI/TUI plus libcstcore and cst-webui/1 HTTP+SSE (turn lifecycle hooks, capabilities). Co-authored-by: Cursor <cursoragent@cursor.com>
989 lines
39 KiB
C++
989 lines
39 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 <QJniEnvironment>
|
|
#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();
|
|
}
|
|
|
|
/* The strings the activity was started with. Qt is meant to hand the
|
|
* "applicationArguments" extra to QCoreApplication::arguments() by itself; on
|
|
* the NX789J it does not -- `am start ... --esa applicationArguments
|
|
* "--workspace,/sdcard/x"` left the window on its default workspace, and so
|
|
* did the same extra as a plain string. So the intent is asked directly, which
|
|
* is what makes `cst-agent-app --uitest --workspace DIR` (or any other
|
|
* argument) reachable on a device with no keyboard attached.
|
|
*
|
|
* Everything here degrades to "no arguments": an invalid object, a missing
|
|
* method, or a context that is not an activity all come back empty. */
|
|
QStringList intentArguments() {
|
|
QStringList out;
|
|
QJniObject activity = appContext();
|
|
if (!activity.isValid())
|
|
return out;
|
|
QJniObject intent =
|
|
activity.callObjectMethod("getIntent", "()Landroid/content/Intent;");
|
|
if (!intent.isValid())
|
|
return out;
|
|
const QJniObject key =
|
|
QJniObject::fromString(QStringLiteral("applicationArguments"));
|
|
/* Three shapes for the same extra, and which one arrives depends on who
|
|
* sent it: `am start --esa applicationArguments "--uitest,--workspace,/x"`
|
|
* stores a String[] (an empty launch, arguments and all, is what that cost
|
|
* us -- getStringArrayListExtra answers null for an array), a launcher or
|
|
* Qt's own loader uses putStringArrayListExtra, and `--es` puts one
|
|
* comma-separated string there. Try each, in that order. */
|
|
const QJniObject array = intent.callObjectMethod(
|
|
"getStringArrayExtra", "(Ljava/lang/String;)[Ljava/lang/String;",
|
|
key.object<jstring>());
|
|
if (array.isValid()) {
|
|
QJniEnvironment env;
|
|
const jobjectArray a = reinterpret_cast<jobjectArray>(array.object());
|
|
const jsize n = env->GetArrayLength(a);
|
|
for (jsize i = 0; i < n; i++) {
|
|
const jstring item =
|
|
reinterpret_cast<jstring>(env->GetObjectArrayElement(a, i));
|
|
if (!item)
|
|
continue;
|
|
const char *c = env->GetStringUTFChars(item, nullptr);
|
|
if (c) {
|
|
out << QString::fromUtf8(c);
|
|
env->ReleaseStringUTFChars(item, c);
|
|
}
|
|
env->DeleteLocalRef(item);
|
|
}
|
|
if (!out.isEmpty())
|
|
return out;
|
|
}
|
|
QJniObject list = intent.callObjectMethod(
|
|
"getStringArrayListExtra", "(Ljava/lang/String;)Ljava/util/ArrayList;",
|
|
key.object<jstring>());
|
|
if (list.isValid()) {
|
|
const jint n = list.callMethod<jint>("size", "()I");
|
|
for (jint i = 0; i < n; i++) {
|
|
QJniObject item =
|
|
list.callObjectMethod("get", "(I)Ljava/lang/Object;", i);
|
|
if (item.isValid())
|
|
out << item.toString();
|
|
}
|
|
if (!out.isEmpty())
|
|
return out;
|
|
}
|
|
const QJniObject one = intent.callObjectMethod(
|
|
"getStringExtra", "(Ljava/lang/String;)Ljava/lang/String;",
|
|
key.object<jstring>());
|
|
if (one.isValid()) {
|
|
const QStringList parts =
|
|
one.toString().split(QLatin1Char(','), Qt::SkipEmptyParts);
|
|
out << parts;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/* --- 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
|
|
|
|
/* What main() asks for (ToolProvider.h): the reading itself is the JNI helper
|
|
* above, next to the Android context it needs. It sits outside the anonymous
|
|
* namespace so main.cpp can link to it. */
|
|
QStringList activityArguments() { return intentArguments(); }
|
|
|
|
/* 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;
|
|
}
|
|
|
|
/* The status bar -- and on a tablet in desktop mode the taskbar, which is a
|
|
* status bar full of icons -- is drawn over the window, and this window draws
|
|
* from its own first pixel, so the bars land on the app's top row and the
|
|
* controls there cannot be touched. Which call hides them depends on the API
|
|
* level: WindowInsetsController from Android 11 (API 30) on, the older
|
|
* systemUiVisibility flags before it. Measured on the tablet: the app's own
|
|
* title row was under the taskbar, this puts the window on the whole screen
|
|
* and the row is touchable again.
|
|
*
|
|
* Nothing here is fatal: an invalid context, a missing class or a null insets
|
|
* controller comes back as a reason (which main.cpp logs) and the window keeps
|
|
* working with the bars over it. */
|
|
QString requestFullscreen() {
|
|
QString err;
|
|
onGuiThread([&] {
|
|
QJniObject activity = appContext();
|
|
if (!activity.isValid()) {
|
|
err = QStringLiteral("no activity");
|
|
return;
|
|
}
|
|
QJniObject window =
|
|
activity.callObjectMethod("getWindow", "()Landroid/view/Window;");
|
|
if (!window.isValid()) {
|
|
err = QStringLiteral("no window");
|
|
return;
|
|
}
|
|
if (sdkInt() >= 30) {
|
|
/* Edge to edge plus the bars hidden: the window is told to fill the
|
|
* screen itself rather than lay out inside the bars. */
|
|
window.callMethod<void>("setDecorFitsSystemWindows", "(Z)V", false);
|
|
QJniObject control = window.callObjectMethod(
|
|
"getInsetsController", "()Landroid/view/WindowInsetsController;");
|
|
if (!control.isValid()) {
|
|
err = QStringLiteral("no insets controller");
|
|
return;
|
|
}
|
|
const jint bars = QJniObject::callStaticMethod<jint>(
|
|
"android/view/WindowInsets$Type", "systemBars", "()I");
|
|
control.callMethod<void>("hide", "(I)V", bars);
|
|
/* BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE: a swipe from the edge brings
|
|
* the bars back for a moment and they leave again by themselves. */
|
|
control.callMethod<void>("setSystemBarsBehavior", "(I)V", 2);
|
|
return;
|
|
}
|
|
QJniObject decor =
|
|
window.callObjectMethod("getDecorView", "()Landroid/view/View;");
|
|
if (!decor.isValid()) {
|
|
err = QStringLiteral("no decor view");
|
|
return;
|
|
}
|
|
/* SYSTEM_UI_FLAG_HIDE_NAVIGATION | FULLSCREEN | LAYOUT_STABLE |
|
|
* LAYOUT_HIDE_NAVIGATION | LAYOUT_FULLSCREEN | IMMERSIVE_STICKY */
|
|
const jint flags = 0x00000002 | 0x00000004 | 0x00000100 | 0x00000200 |
|
|
0x00000400 | 0x00001000;
|
|
decor.callMethod<void>("setSystemUiVisibility", "(I)V", flags);
|
|
});
|
|
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
|