/* 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 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #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 void onGuiThread(Fn &&fn) { if (!qApp || QThread::currentThread() == qApp->thread()) { fn(); return; } QMetaObject::invokeMethod(qApp, std::forward(fn), Qt::BlockingQueuedConnection); } jint sdkInt() { static const jint sdk = QJniObject::getStaticField( "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 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()); 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(), QJniObject::fromString(QStringLiteral("CST-agent")).object(), static_cast(3) /* IMPORTANCE_DEFAULT */); manager.callMethod("createNotificationChannel", "(Landroid/app/NotificationChannel;)V", channel.object()); } /* 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( "getIdentifier", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)I", QJniObject::fromString(QString::fromLatin1(name)).object(), QJniObject::fromString(QString::fromLatin1(type)).object(), owner ? QJniObject::fromString(QString::fromLatin1(owner)).object() : pkg.object()); }; 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()); 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(), QJniObject::fromString(channelId).object(), QJniObject::fromString(title).object(), QJniObject::fromString(message).object(), icon, launch.isValid() ? launch.object() : nullptr, static_cast(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(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 .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()); intent.callObjectMethod("setType", "(Ljava/lang/String;)Landroid/content/Intent;", QJniObject::fromString(QStringLiteral("text/plain")) .object()); intent.callObjectMethod( "putExtra", "(Ljava/lang/String;Ljava/lang/CharSequence;)Landroid/content/Intent;", QJniObject::fromString(QStringLiteral("android.intent.extra.TEXT")) .object(), QJniObject::fromString(text).object()); if (!subject.isEmpty()) intent.callObjectMethod( "putExtra", "(Ljava/lang/String;Ljava/lang/CharSequence;)Landroid/content/Intent;", QJniObject::fromString(QStringLiteral("android.intent.extra.SUBJECT")) .object(), QJniObject::fromString(subject).object()); QJniObject chooser("android/content/Intent", "(Ljava/lang/String;)V", QJniObject::fromString(QStringLiteral("android.intent.action.CHOOSER")) .object()); /* 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(), QJniObject::fromString(QStringLiteral("Share with")).object()); QJniObject toStart = sheet.isValid() ? sheet : intent; toStart.callObjectMethod("addFlags", "(I)Landroid/content/Intent;", static_cast(0x10000000) /* NEW_TASK */); ctx.callMethod("startActivity", "(Landroid/content/Intent;)V", toStart.object()); 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 .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()); QJniObject uri = QJniObject::callStaticObjectMethod( "androidx/core/content/FileProvider", "getUriForFile", "(Landroid/content/Context;Ljava/lang/String;Ljava/io/File;)Landroid/net/Uri;", ctx.object(), QJniObject::fromString(authority).object(), file.object()); 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()); intent.callObjectMethod("setType", "(Ljava/lang/String;)Landroid/content/Intent;", QJniObject::fromString(mimeOf(path)).object()); intent.callObjectMethod( "putExtra", "(Ljava/lang/String;Landroid/os/Parcelable;)Landroid/content/Intent;", QJniObject::fromString(QStringLiteral("android.intent.extra.STREAM")) .object(), uri.object()); if (!text.isEmpty()) intent.callObjectMethod( "putExtra", "(Ljava/lang/String;Ljava/lang/CharSequence;)Landroid/content/Intent;", QJniObject::fromString(QStringLiteral("android.intent.extra.TEXT")) .object(), QJniObject::fromString(text).object()); intent.callObjectMethod( "addFlags", "(I)Landroid/content/Intent;", static_cast(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(), QJniObject::fromString(QStringLiteral("Share with")).object()); QJniObject toStart = sheet.isValid() ? sheet : intent; toStart.callObjectMethod("addFlags", "(I)Landroid/content/Intent;", static_cast(0x10000000)); ctx.callMethod("startActivity", "(Landroid/content/Intent;)V", toStart.object()); 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()); intent.callObjectMethod( "setDataAndType", "(Landroid/net/Uri;Ljava/lang/String;)Landroid/content/Intent;", uri.object(), QJniObject::fromString(mimeOf(path)).object()); intent.callObjectMethod("addFlags", "(I)Landroid/content/Intent;", static_cast(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(), static_cast(0x00010000) /* MATCH_DEFAULT_ONLY */) : QJniObject(); if (!found.isValid()) { err = QStringLiteral("no app on the phone opens %1").arg(name); return; } ctx.callMethod("startActivity", "(Landroid/content/Intent;)V", intent.object()); 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(), QJniObject::fromString( QStringLiteral("xyz.whcself.cstagent.CstService")) .object()); if (!comp.isValid()) return QJniObject(); QJniObject intent("android/content/Intent"); intent.callObjectMethod( "setComponent", "(Landroid/content/ComponentName;)Landroid/content/Intent;", comp.object()); if (!action.isEmpty()) intent.callObjectMethod("setAction", "(Ljava/lang/String;)Landroid/content/Intent;", QJniObject::fromString(action).object()); 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(), QJniObject::fromString(text).object()); QJniObject started = sdkInt() >= 26 ? ctx.callObjectMethod( "startForegroundService", "(Landroid/content/Intent;)Landroid/content/ComponentName;", intent.object()) : ctx.callObjectMethod( "startService", "(Landroid/content/Intent;)Landroid/content/ComponentName;", intent.object()); 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("stopService", "(Landroid/content/Intent;)Z", intent.object()); }); } /* 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(), QJniObject::fromString(pathOrUri).object(), QJniObject::fromString(dest).object()); 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( "xyz/whcself/cstagent/AttachFile", "hasAllFilesAccess", "(Landroid/content/Context;)Z", ctx.object()); }); 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()); 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