diff --git a/app/README.md b/app/README.md
index c63667a..3221533 100644
--- a/app/README.md
+++ b/app/README.md
@@ -374,6 +374,10 @@ androiddeployqt copies `app/android/` with the rest of the package source and
Qt's Gradle template puts its `src/` on `java.srcDirs`, so no build-script
change is needed (`Notifier.java` is the first one; a Java helper is the only
way to catch a throwable where the platform throws instead of returning).
+`CstService.java` (the foreground service that holds a phone awake while a
+turn runs) went in the same way; it is started by `app::holdForeground()`
+when a turn begins and stopped by `app::releaseForeground()` when it ends,
+so nothing about it is visible when the app is idle.
## Status
@@ -398,6 +402,8 @@ way to catch a throwable where the platform throws instead of returning).
transcript draws a file card with Open and Share (see the Files section)
- [x] Android platform tools (JNI: notification, clipboard, share sheet) and the
file card's open/share through the app's FileProvider
+- [x] a turn keeps running with the app off screen (Android foreground service
+ + wake lock; nothing to do on a desktop)
- [ ] a real turn driven through the window (needs a provider key)
- [ ] SAF file picker, the back gesture, the icon glyphs on the phone
- [ ] Kirigami (the rest of the Breeze look: colors, controls), tray,
diff --git a/app/android/AndroidManifest.xml b/app/android/AndroidManifest.xml
index 5b7a2ed..0026e6f 100644
--- a/app/android/AndroidManifest.xml
+++ b/app/android/AndroidManifest.xml
@@ -18,10 +18,12 @@
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). 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. -->
+ asks at runtime; the tool stays hidden until it is granted), and 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. -->
+
+
+
+
+
+
diff --git a/app/android/src/xyz/whcself/cstagent/CstService.java b/app/android/src/xyz/whcself/cstagent/CstService.java
new file mode 100644
index 0000000..c52ae18
--- /dev/null
+++ b/app/android/src/xyz/whcself/cstagent/CstService.java
@@ -0,0 +1,143 @@
+package xyz.whcself.cstagent;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ServiceInfo;
+import android.os.Build;
+import android.os.IBinder;
+import android.os.PowerManager;
+
+/**
+ * The service that keeps a turn running while the app is not on screen.
+ *
+ * A phone freezes an app it cannot see (and may kill it), so a turn that takes
+ * minutes -- a build, a long web fetch, a provider that thinks -- dies in the
+ * pocket. While one is going, AgentCore::send() starts this service through
+ * app::holdForeground(); being a foreground service makes the process one
+ * Android does not touch, and the partial wake lock keeps the CPU awake with
+ * the screen off. turnFinished() stops it again (app::releaseForeground()), so
+ * the permanent notification exists exactly as long as the work does.
+ *
+ * Started with an explicit component (AndroidTools.cpp builds the Intent), no
+ * intent filter: nothing outside the app can start it.
+ */
+public class CstService extends Service {
+ /** Same channel the notification_send tool uses: one place in settings. */
+ private static final String CHANNEL_ID = "cst-agent";
+ private static final int NOTE_ID = 2;
+ private static final String ACTION_STOP = "xyz.whcself.cstagent.STOP";
+ /** A turn that runs longer than this is stuck; the lock must not outlive it. */
+ private static final long WAKE_LOCK_TIMEOUT_MS = 30 * 60 * 1000L;
+
+ private PowerManager.WakeLock wake;
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ if (intent != null && ACTION_STOP.equals(intent.getAction())) {
+ stopEverything();
+ return START_NOT_STICKY;
+ }
+ String text = intent == null ? null : intent.getStringExtra("text");
+ try {
+ startForeground(NOTE_ID, buildNote(text));
+ } catch (Throwable t) {
+ /* Android 12+ refuses a foreground start from the background: the
+ * turn then simply runs without the protection rather than not at
+ * all. Nothing to show the user here -- the turn's own result is
+ * what matters. */
+ stopSelf();
+ return START_NOT_STICKY;
+ }
+ acquireWake();
+ return START_NOT_STICKY;
+ }
+
+ @Override
+ public void onDestroy() {
+ releaseWake();
+ super.onDestroy();
+ }
+
+ private void stopEverything() {
+ releaseWake();
+ if (Build.VERSION.SDK_INT >= 24)
+ stopForeground(STOP_FOREGROUND_REMOVE);
+ else
+ stopForeground(true);
+ stopSelf();
+ }
+
+ private void acquireWake() {
+ try {
+ if (wake == null) {
+ PowerManager pm =
+ (PowerManager) getSystemService(Context.POWER_SERVICE);
+ if (pm == null)
+ return;
+ wake = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
+ "cst-agent:turn");
+ wake.setReferenceCounted(false);
+ }
+ wake.acquire(WAKE_LOCK_TIMEOUT_MS);
+ } catch (Throwable ignored) {
+ /* no wake lock: the turn still runs while the screen is on */
+ }
+ }
+
+ private void releaseWake() {
+ try {
+ if (wake != null && wake.isHeld())
+ wake.release();
+ } catch (Throwable ignored) {
+ }
+ }
+
+ /** "the agent is working: ", tapping opens it. */
+ private Notification buildNote(String text) {
+ Context ctx = getApplicationContext();
+ NotificationManager manager =
+ (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE);
+ if (manager != null && Build.VERSION.SDK_INT >= 26) {
+ NotificationChannel channel = new NotificationChannel(
+ CHANNEL_ID, "CST-agent", NotificationManager.IMPORTANCE_LOW);
+ channel.setDescription("A turn is running");
+ manager.createNotificationChannel(channel);
+ }
+ Notification.Builder builder = Build.VERSION.SDK_INT >= 26
+ ? new Notification.Builder(ctx, CHANNEL_ID)
+ : new Notification.Builder(ctx);
+ builder.setContentTitle("CST-agent is working")
+ .setSmallIcon(smallIcon())
+ .setOngoing(true)
+ .setShowWhen(false);
+ if (text != null && !text.isEmpty())
+ builder.setContentText(text);
+ Intent open = ctx.getPackageManager().getLaunchIntentForPackage(ctx.getPackageName());
+ if (open != null) {
+ open.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ builder.setContentIntent(PendingIntent.getActivity(ctx, 0, open,
+ PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT));
+ }
+ return builder.build();
+ }
+
+ /** A framework drawable first (the same order the tool uses), then the app's. */
+ private int smallIcon() {
+ int icon = getResources().getIdentifier("ic_dialog_info", "drawable", "android");
+ if (icon == 0)
+ icon = getResources().getIdentifier("stat_notify_chat", "drawable", "android");
+ if (icon == 0)
+ icon = getApplicationInfo().icon;
+ return icon;
+ }
+}
diff --git a/app/src/core/AgentCore.cpp b/app/src/core/AgentCore.cpp
index 918ef51..29aef3a 100644
--- a/app/src/core/AgentCore.cpp
+++ b/app/src/core/AgentCore.cpp
@@ -188,6 +188,18 @@ void AgentCore::close() {
/* --- turns --------------------------------------------------------------- */
+namespace {
+/* The first line of a prompt, cut short: the line the phone's "working"
+ * notification carries while the turn runs (app::holdForeground). */
+QString firstLine(const QString &s) {
+ const int nl = s.indexOf(QLatin1Char('\n'));
+ QString line = (nl < 0 ? s : s.left(nl)).trimmed();
+ if (line.size() > 120)
+ line = line.left(119) + QStringLiteral("…");
+ return line;
+}
+} // namespace
+
void AgentCore::send(const QString &prompt) {
if (!m_agent || m_running || prompt.trimmed().isEmpty())
return;
@@ -195,6 +207,12 @@ void AgentCore::send(const QString &prompt) {
* message the composer just appended, before the turn can say anything. */
const QString sending = pendingImages();
setRunning(true);
+ /* A turn can take minutes: hold the process while it runs, or a phone
+ * freezes the app the moment it leaves the screen. "" on a desktop and
+ * whenever the hold is in place. */
+ const QString held = app::holdForeground(firstLine(prompt));
+ if (!held.isEmpty())
+ emit statusChanged(held);
if (!sending.isEmpty())
emit promptImages(sending);
m_turn = new Turn(this, prompt);
@@ -210,6 +228,7 @@ void AgentCore::finishTurn(int rc, const QString &err) {
Turn *t = static_cast(sender());
post([this, rc, err] {
setRunning(false);
+ app::releaseForeground(); /* the turn is over: let the phone sleep again */
/* The prompt carried whatever was pending; the engine dropped it. */
m_attachNames.clear();
emit pendingChanged();
diff --git a/app/src/core/ToolProvider.h b/app/src/core/ToolProvider.h
index 15385c6..83a2c5f 100644
--- a/app/src/core/ToolProvider.h
+++ b/app/src/core/ToolProvider.h
@@ -67,6 +67,16 @@ QString openLocalFile(const QString &path, const QString &title, QString *status
* desktop has no share sheet, so the path goes to the clipboard. */
QString shareLocalFile(const QString &path, const QString &title,
QString *status);
+/* Hold the machine while a turn runs, so it survives the app leaving the
+ * screen: a phone starts a foreground service with a partial wake lock (the
+ * process is one Android will not freeze or kill, and the CPU keeps going
+ * with the screen off) and shows a notification naming the work; a desktop
+ * does nothing -- the window *is* the app there. Returns "" when the hold is
+ * in place (or when the platform has nothing to do), a reason otherwise; the
+ * turn runs either way. `text` is what the phone's notification says. */
+QString holdForeground(const QString &text);
+/* Drop it again, at the end of the turn (a phone: the notification goes). */
+void releaseForeground();
} // namespace app
diff --git a/app/src/platform/android/AndroidTools.cpp b/app/src/platform/android/AndroidTools.cpp
index b7e2f1b..b9f01c7 100644
--- a/app/src/platform/android/AndroidTools.cpp
+++ b/app/src/platform/android/AndroidTools.cpp
@@ -577,6 +577,91 @@ QString shareLocalFile(const QString &path, const QString &title,
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());
+ });
+}
+
int platformRegisterAndroid(AgentCore *core) {
if (!core)
return 0;
diff --git a/app/src/platform/linux/NotifyTool.cpp b/app/src/platform/linux/NotifyTool.cpp
index 768cd9c..787c775 100644
--- a/app/src/platform/linux/NotifyTool.cpp
+++ b/app/src/platform/linux/NotifyTool.cpp
@@ -127,6 +127,16 @@ QString shareLocalFile(const QString &path, const QString &title,
return QString();
}
+/* A desktop has nothing to hold: the window is the app, and a turn runs as
+ * long as it is open -- the machine does not freeze a process it can see.
+ * Kept here so the front end calls one API on every platform. */
+QString holdForeground(const QString &text) {
+ (void)text;
+ return QString();
+}
+
+void releaseForeground() {}
+
int platformRegisterLinux(AgentCore *core) {
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
{"message", "string", 1, "text of the notification"},
diff --git a/docs/android.md b/docs/android.md
index 99a2288..7acd545 100644
--- a/docs/android.md
+++ b/docs/android.md
@@ -187,6 +187,16 @@ have, so the script rewrites the shebang of the build-tools wrappers.
with Open and Share -- the copy lives next to the session, marked `for_user`
so no request ever contains it (see `app/README.md`, "Files the agent
delivers").
+* **A turn survives the app leaving the screen**: `AgentCore::send()` calls
+ `app::holdForeground()`, which starts `CstService`
+ (`app/android/src/xyz/whcself/cstagent/CstService.java`) as a foreground
+ service and takes a partial wake lock; `finishTurn()` stops it again. On the
+ NX789J: with the app sent home and the screen off, the service showed
+ `isForeground=true` with its `CST-agent is working` notification (id 2, the
+ same `cst-agent` channel), the lock `cst-agent:turn` was acquired at the
+ start of the turn and released at its end, `run_shell sleep 60` finished with
+ the screen dark, and the service was gone once the turn was over.
+ `CST_APP_NO_SERVICE=1` keeps a test run from touching the phone.
* Still to come: the SAF picker for attaching a picture from another app, the
back gesture, and the icon glyphs -- `ThemeIcon` finds no icon files on the
phone and draws empty boxes where the desktop shows Breeze icons.
diff --git a/include/cst_version.h b/include/cst_version.h
index a6c80ef..4753463 100644
--- a/include/cst_version.h
+++ b/include/cst_version.h
@@ -3,7 +3,7 @@
#define CST_VERSION_MAJOR 0
#define CST_VERSION_MINOR 9
-#define CST_VERSION_PATCH 3
+#define CST_VERSION_PATCH 4
#define CST_VERSION_STR_(x) #x
#define CST_VERSION_STR(x) CST_VERSION_STR_(x)