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)