Four tools and a document writer, plus the storage half of the phone: until now the picker's answer (a content:// URI) reached the engine as a path, and it said "cannot read" -- the app had no way to hand a phone's file over. - md_to_html (new, src/tools/web.c + cst_web_md_document()): markdown in, one self-contained HTML document out -- doctype, utf-8, viewport, escaped title, a small stylesheet; no script, no external request. It is written into the workspace and delivered as a file card (for_user, so it never enters a request), the way deliver_file does. The default name comes from the source file's stem or, for pasted markdown, from the title with what a path cannot carry turned into '-'. It has a preview, so an approval prompt says "render x -> y.html" before anything is written. - download (src/tools/web.c): an http(s) URL into a file in the workspace, streamed with a 128 MB ceiling; a redirect chain, the same CA handling and the same refusals as web_fetch, but the bytes stay on disk (a half file is removed on any failure, including HTTP >= 400). The name comes from the URL when the caller names only a directory. - read_pdf (src/tools/fs_tools.c): the text layer and page count of a PDF, which read_file refuses because the bytes are binary -- and a PDF is exactly what arrives from a phone. Same extractor an attachment uses, its own 4 MiB input ceiling, 256 KiB of text by default with a note when cut. - session_search (new, src/tools/session_tools.c): every stored session -- this one and all the ones before it -- searched for a substring, reporting the session's id, message count and title, then the matching lines with their number and role. Case-insensitive by default, capped at 20 matches. - device_info (same file, perm_name "device"): version, system/arch/CPU count, workspace, sandbox, model and permissions, config/temp/program directories, pid -- so the model stops guessing where it runs. - Tools 3 and 4 come with tests/test_tools_ext.c (also in both build files): the document is a whole page, a script tag and a javascript: link do not survive, a hostile title stays text and cannot write outside the workspace, the preview names the file, read_pdf/download refuse what they must (no network in a test), session_search finds a fixture session and says so when nothing matches, device_info names the workspace. README's tool table grew five rows. - Android storage: the picker (SAF) answers with a content:// URI, and the engine opens files by path, so the app now copies what it points at into its own directory first -- app/android/src/xyz/whcself/cstagent/ AttachFile.java, called through a new app::materializeAttachment() (ToolProvider.h: one API for every platform; a desktop strips file:// and refuses content:// with a reason). The copy needs no permission at all: the grant travels with the URI. - "All files access" (MANAGE_EXTERNAL_STORAGE) is the other half: it is what lets the engine read a path in shared storage that a task names rather than picks. Android has no runtime dialog for it, so main.cpp opens the one Settings screen once (AttachFile.java's requestAllFilesAccess, with the all-apps screen as a fallback) and never insists: without it everything works, one picked file at a time. On the NX789J the screen came up, the app-op reads back "allow", and a relaunch no longer asks. - Composer.qml hands a content:// URI through untouched (it is not a path, and decoding it would corrupt it); AgentCore::attachFile() turns whatever the picker gave into a path first. Desktop: build clean, make check 36/36 (the new test included, also under ASan), --uitest all green, --selftest and cst-app-selftest OK. The APK builds and installs (86 MB, release-signed) with the new Java class in its dex. Version 0.9.6.
830 lines
32 KiB
C++
830 lines
32 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 <QStandardPaths>
|
|
#include <QString>
|
|
#include <QStringList>
|
|
#include <QThread>
|
|
|
|
#include <QtCore/private/qandroidextras_p.h>
|
|
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <utility>
|
|
|
|
#include "core/AgentCore.h"
|
|
#include "core/ToolProvider.h"
|
|
|
|
namespace app {
|
|
namespace {
|
|
|
|
/* --- small helpers ------------------------------------------------------- */
|
|
|
|
const char *argOf(const ctl_tool_call_t *call, const char *key) {
|
|
for (int i = 0; i < call->nargs; i++)
|
|
if (strcmp(call->args[i].key, key) == 0)
|
|
return call->args[i].value;
|
|
return nullptr;
|
|
}
|
|
|
|
void setOut(cst_tool_result_t *r, const QByteArray &text, int rc) {
|
|
r->out = strdup(text.constData());
|
|
r->out_len = r->out ? strlen(r->out) : 0;
|
|
r->exit_code = rc;
|
|
}
|
|
|
|
/* Anything that touches a Qt GUI object (the clipboard) or an Activity has to
|
|
* run on the GUI thread; the turn's worker thread waits for it. */
|
|
template <typename Fn>
|
|
void onGuiThread(Fn &&fn) {
|
|
if (!qApp || QThread::currentThread() == qApp->thread()) {
|
|
fn();
|
|
return;
|
|
}
|
|
QMetaObject::invokeMethod(qApp, std::forward<Fn>(fn),
|
|
Qt::BlockingQueuedConnection);
|
|
}
|
|
|
|
jint sdkInt() {
|
|
static const jint sdk = QJniObject::getStaticField<jint>(
|
|
"android/os/Build$VERSION", "SDK_INT");
|
|
return sdk;
|
|
}
|
|
|
|
QJniObject appContext() {
|
|
return QNativeInterface::QAndroidApplication::context();
|
|
}
|
|
|
|
/* --- notification_send --------------------------------------------------- */
|
|
|
|
/* Android 13 (API 33) put notifications behind a runtime permission; before
|
|
* that the permission does not exist and they always work. main.cpp asks for
|
|
* it once at startup, so the probe is normally green on a device where the
|
|
* user said yes. */
|
|
int notifyProbe(char *why, size_t why_sz) {
|
|
if (sdkInt() < 33)
|
|
return 0;
|
|
const QFuture<QtAndroidPrivate::PermissionResult> f =
|
|
QtAndroidPrivate::checkPermission(
|
|
QStringLiteral("android.permission.POST_NOTIFICATIONS"));
|
|
if (f.result() == QtAndroidPrivate::Authorized)
|
|
return 0;
|
|
snprintf(why, why_sz,
|
|
"POST_NOTIFICATIONS is not granted (Android 13+ asks the user the "
|
|
"first time the app runs)");
|
|
return -1;
|
|
}
|
|
|
|
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
|