refactor: archive GUI clients; keep minimal TUI with hooks/API
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>
This commit is contained in:
@@ -0,0 +1,988 @@
|
||||
/* 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
|
||||
Reference in New Issue
Block a user