v0.9.6: documents, downloads, PDFs and the phone's own files

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.
This commit is contained in:
WHC
2026-10-11 18:33:41 +08:00
parent 08008a5a4c
commit 580d533656
20 changed files with 1408 additions and 10 deletions
+14 -5
View File
@@ -18,12 +18,15 @@
Permissions: INTERNET for the providers the user configures,
POST_NOTIFICATIONS for the app's notification_send tool (Android 13+
asks at runtime; the tool stays hidden until it is granted), and the
asks at runtime; the tool stays hidden until it is granted), the
foreground-service permissions with WAKE_LOCK for the service that keeps
a turn going while the phone is in a pocket (CstService.java). No
storage permission: the file picker is the system one (SAF) and its
input is copied into the app's directory. No cleartext traffic: every
provider URL is https. -->
a turn going while the phone is in a pocket (CstService.java), and
MANAGE_EXTERNAL_STORAGE ("all files access", Android 11+) so the engine
can read a path in shared storage that a task names rather than picks:
Android only offers the Settings screen for it, so main.cpp opens that
once and everything still works without it: what the system picker (SAF)
hands over is copied into the app's own directory (AttachFile.java). No
cleartext traffic: every provider URL is https. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="xyz.whcself.cstagent"
android:installLocation="auto"
@@ -38,6 +41,12 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- "All files access": without it the engine can only read the app's own
directory, so a task that names a file in shared storage cannot be
answered. Android 11+ has no runtime dialog for it, only the Settings
screen (AttachFile.java opens it once). Files the user picks work
either way: their content:// grant is copied into the app first. -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<supports-screens
android:anyDensity="true"
@@ -0,0 +1,189 @@
package xyz.whcself.cstagent;
import android.content.ActivityNotFoundException;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.provider.OpenableColumns;
import android.provider.Settings;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
/* What the user picks in the system file chooser comes back as a content://
* URI, and the engine opens files by path -- so the bytes are copied into the
* app's own directory first. That copy needs no permission at all (the grant
* travels with the URI), which is what makes picking work on a phone that
* grants nothing else.
*
* The "all files access" below is a different thing: it is what lets the
* engine read /sdcard by path afterwards (a model that opens a file the user
* names rather than picks). Android 11+ has no runtime dialog for it -- only
* the Settings screen -- so it is a request, not a demand: everything works
* without it, one file at a time.
*
* Qt swallows a Java exception (it comes back as a null object), so every
* method catches its own and answers with a reason instead.
*/
public final class AttachFile {
private AttachFile() {}
private static final long MAX_BYTES = 256L * 1024 * 1024;
public static boolean isContent(String s) {
return s != null && s.startsWith("content:");
}
/* content://<uri> -> the path of a copy inside destDir; "" when the string
* is already a path; "error: ..." when the copy failed. */
public static String materialize(Context ctx, String pathOrUri, String destDir) {
if (pathOrUri == null || pathOrUri.isEmpty())
return "error: no file was picked";
if (!isContent(pathOrUri))
return "";
try {
if (ctx == null)
return "error: no Android context";
final Uri uri = Uri.parse(pathOrUri);
String name = displayName(ctx, uri);
if (name == null || name.trim().isEmpty())
name = uri.getLastPathSegment();
name = sanitize(name);
File dir = new File(destDir);
if (!dir.isDirectory() && !dir.mkdirs())
return "error: cannot use " + destDir;
File out = unique(dir, name);
final ContentResolver cr = ctx.getContentResolver();
final InputStream in = cr.openInputStream(uri);
if (in == null)
return "error: the picker gave no data for that file";
long total = 0;
try {
final OutputStream os = new FileOutputStream(out);
try {
final byte[] buf = new byte[64 * 1024];
int n;
while ((n = in.read(buf)) > 0) {
total += n;
if (total > MAX_BYTES) {
os.close();
in.close();
out.delete();
return "error: that file is larger than 256 MB";
}
os.write(buf, 0, n);
}
} finally {
os.close();
}
} finally {
in.close();
}
if (total == 0) {
out.delete();
return "error: that file is empty";
}
return out.getAbsolutePath();
} catch (Throwable t) {
return "error: cannot read the picked file: " + t;
}
}
/* Android 11+ keeps "all files access" in a special app-op; older systems
* have no such gate. */
public static boolean hasAllFilesAccess(Context ctx) {
try {
if (Build.VERSION.SDK_INT < 30)
return true;
return Environment.isExternalStorageManager();
} catch (Throwable t) {
return false;
}
}
/* The Settings screen Android requires for it. "" when it was opened. */
public static String requestAllFilesAccess(Context ctx) {
try {
if (Build.VERSION.SDK_INT < 30 || ctx == null)
return "";
if (Environment.isExternalStorageManager())
return "";
final Intent intent = new Intent(
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
Uri.parse("package:" + ctx.getPackageName()));
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
try {
ctx.startActivity(intent);
} catch (ActivityNotFoundException notFound) {
final Intent all = new Intent(
Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION);
all.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
ctx.startActivity(all);
}
return "";
} catch (Throwable t) {
return "cannot open the storage settings: " + t;
}
}
private static String displayName(Context ctx, Uri uri) {
Cursor c = null;
try {
c = ctx.getContentResolver().query(uri,
new String[] { OpenableColumns.DISPLAY_NAME }, null, null,
null);
if (c != null && c.moveToFirst()) {
final int i = c.getColumnIndex(OpenableColumns.DISPLAY_NAME);
if (i >= 0)
return c.getString(i);
}
} catch (Throwable ignored) {
/* fall back to the last path segment */
} finally {
if (c != null)
c.close();
}
return null;
}
/* A plain file name: no separators, no traversal, no control bytes. */
private static String sanitize(String name) {
if (name == null || name.isEmpty())
return "picked.bin";
final StringBuilder b = new StringBuilder();
for (int i = 0; i < name.length() && b.length() < 120; i++) {
final char ch = name.charAt(i);
if (ch == '/' || ch == '\\' || ch < 0x20)
b.append('_');
else
b.append(ch);
}
String s = b.toString();
while (s.startsWith("."))
s = s.substring(1);
if (s.isEmpty())
return "picked.bin";
return s;
}
private static File unique(File dir, String name) {
File f = new File(dir, name);
if (!f.exists())
return f;
final int dot = name.lastIndexOf('.');
final String stem = dot > 0 ? name.substring(0, dot) : name;
final String ext = dot > 0 ? name.substring(dot) : "";
for (int i = 2; i < 1000; i++) {
f = new File(dir, stem + "-" + i + ext);
if (!f.exists())
return f;
}
return f;
}
}