v0.9.4: a turn keeps running when the app is not on screen

A phone freezes an app it cannot see, and the front end had nothing holding
it: a turn that took minutes -- a long shell command, a provider that thinks,
a build -- stopped quietly the moment the user pressed home, and the answer
was only there if the window happened to come back. The engine's own /attach
flow made this visible: an APK turn survived, a pocket did not.

- app/android/src/xyz/whcself/cstagent/CstService.java: a foreground service
  started with an explicit component (nothing outside the app can reach it,
  no intent filter) for as long as a turn runs. Being a foreground service
  makes the process one Android neither freezes nor kills; a partial wake
  lock (30 min ceiling) keeps the CPU awake with the screen off. Its ongoing
  notification says what is running -- "CST-agent is working" plus the first
  line of the prompt -- on the same cst-agent channel the notification_send
  tool uses, and taps back into the app. A phone that refuses the foreground
  start (Android 12+ does, from the background) loses the protection, not the
  turn: the failure is caught and the service stops itself.
- Manifest: FOREGROUND_SERVICE, FOREGROUND_SERVICE_DATA_SYNC (API 34+ wants
  the typed one) and WAKE_LOCK, plus the <service> with
  foregroundServiceType="dataSync". The first build of this failed with
  "Expected '>', but got ' '" -- a manifest comment cannot contain "--".
- Front end: app::holdForeground(text) / app::releaseForeground() in
  ToolProvider.h, one call for every platform -- Android starts and stops the
  service through JNI, a desktop has nothing to hold (its window is the app).
  AgentCore::send() calls it when the turn starts (firstLine() of the prompt
  is what the notification says) and finishTurn() releases it, so the
  notification exists exactly as long as the work does. CST_APP_NO_SERVICE=1
  keeps a test run from touching the phone.
- On the NX789J, with the app sent home and the screen off: the service read
  back isForeground=true with its notification, the wake lock cst-agent:turn
  was taken at the start of the turn and dropped at its end, run_shell
  "sleep 60" finished with the screen dark, and the service was gone after.

Desktop: build clean, --uitest all green, --selftest and cst-app-selftest OK
(the hold is a no-op there, so nothing about the Qt side changed).

Version 0.9.4.
This commit is contained in:
WHC
2026-10-11 16:51:39 +08:00
parent d4f65b17cf
commit 7299f39245
9 changed files with 304 additions and 5 deletions
+20 -4
View File
@@ -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. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="xyz.whcself.cstagent"
android:installLocation="auto"
@@ -33,6 +35,9 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<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" />
<supports-screens
android:anyDensity="true"
@@ -84,5 +89,16 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/qtprovider_paths" />
</provider>
<!-- A turn can outlive the screen: while one is running the front end
starts this service (AgentCore::send -> app::holdForeground), which
makes the process a foreground one, so Android neither freezes nor
kills it in the background, and takes a partial wake lock so the
CPU keeps going with the phone in a pocket. dataSync is the type
that fits "the app is finishing some work". -->
<service
android:name="xyz.whcself.cstagent.CstService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>
@@ -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: <first line of the prompt>", 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;
}
}