Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,10 @@ _Avoid_: Account, login, linked phone
A short plain-text code, also drawn as a QR code, that adds a Remote Device. Either a trusted client shows a single-use code the new device reads, or the new device shows a code a trusted client reads and confirms.
_Avoid_: Password, token, invite link

**SSH Computer**:
A Linux computer reached over SSH where an App Shell installs, starts, and keeps the App Server, and holds its token like a same-machine App Shell. It is not a Remote Device: no Pairing Code is involved.
_Avoid_: Remote host, server, SSH remote

**Desktop Update**:
A newer official OpenAIDE Desktop App release offered inside the installed Desktop App. The user may review its Release Notes, initiate download and staging, and restart OpenAIDE to apply it.
_Avoid_: Silently replacing a running release, treating a downloaded update as already applied
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,14 @@
* The ongoing notification that keeps OpenAIDE working while it is off the screen.
*
* For the workspace on this phone it keeps the phone awake while a Task works and
* reads Task state from the local App Server. For a paired computer it holds no wake
* lock and does not stop when idle: it keeps a {@link RemoteAttention} watch, so Task
* notifications still arrive while the WebView is paused. The selected workspace
* decides the mode on every start, so a restart by the system resumes the same one.
* reads Task state from the local App Server. For a paired computer it does not stop
* when idle: it keeps a {@link RemoteAttention} watch, so Task notifications still
* arrive while the WebView is paused, and it also watches this phone's App Server if
* that is already running, so the phone's own Tasks keep it awake and notify too
* ({@link BackgroundSources}). The selected workspace decides the mode on every start,
* so a restart by the system resumes the same one.
*/
public final class BackgroundService extends Service {
private static final String TASK_TAG = "task:";
private static final int TASK_NOTIFICATION = 3;
private WorkProtection protection;
private final Handler handler = new Handler(Looper.getMainLooper());
Expand All @@ -40,14 +41,20 @@ public final class BackgroundService extends Service {
private boolean polling;
private final Runnable pollTask = this::poll;
private long settlingUntil;
private final AttentionNotifications attention = new AttentionNotifications();
/** Each source has its own baseline: the first list read from it stays silent. */
private final AttentionNotifications localAttention = new AttentionNotifications();
private final AttentionNotifications pairedAttention = new AttentionNotifications();
private long hiddenSince;
private String notificationText = "";
private final AndroidDiagnostics diagnostics = new AndroidDiagnostics();
/** Why the service is ending; a stop the service did not ask for is the system's. */
private String stopReason = "system";
/** The paired computer's watch, or null while the service serves the workspace on this phone. */
private RemoteAttention remote;
/** Whether this phone's own App Server is watched while the paired computer is in use. */
private boolean watchingLocal;
/** Failed reads in a row of the phone's App Server while paired; only the transitions are logged. */
private int localFailures;
private boolean modeChosen;
/** An outdated computer is reported once per process, not on every return to the app. */
private static boolean outdatedReported;
Expand Down Expand Up @@ -102,13 +109,20 @@ private boolean chooseMode() {
remote = new RemoteAttention(this, new RemoteAttention.Events() {
@Override public void state(RemoteAttention.State state) { handler.post(() -> remoteState(state)); }
@Override public void attention(List<AttentionNotifications.Attention> current) {
handler.post(() -> { if (!destroyed && remote != null) presentAttention(current); });
handler.post(() -> { if (!destroyed && remote != null) presentAttention(BackgroundSources.Source.PAIRED, current); });
}
});
remote.start();
// The first read tells whether this phone's App Server runs; the watch never starts it.
watchingLocal = true;
localFailures = 0;
diagnostics.record("local_watch", "started", 0, 0, Map.of("source", BackgroundSources.Source.THIS_DEVICE.wire));
} else if (remote != null) {
remote.stop();
remote = null;
endLocalWatch("mode_changed");
// The phone's reads start over, so a long paired stretch is not taken for a lost server.
policy.observe(0, SystemClock.elapsedRealtime());
}
diagnostics.record("background_work", paired ? "paired" : "local", 0, 0, Map.of("switched", switched));
return true;
Expand All @@ -133,72 +147,109 @@ private void remoteState(RemoteAttention.State state) {
stop("remote_" + state.name().toLowerCase(Locale.ROOT));
}

/** Reads this phone's App Server: the workspace in use, or alongside the paired computer while it is running. */
private void poll() {
if (polling || destroyed || remote != null) return;
boolean paired = remote != null;
if (polling || destroyed || (paired && !watchingLocal)) return;
handler.removeCallbacks(pollTask);
polling = true;
worker.execute(() -> {
ServerStatus status = null;
try { status = LocalServer.status(this); }
boolean running = true;
// The phone's App Server is started only while it is the workspace in use.
try { status = paired ? LocalServer.statusIfRunning() : LocalServer.status(this); running = status != null; }
catch (Exception ignored) { }
ServerStatus result = status;
boolean serverRunning = running;
handler.post(() -> {
polling = false;
// A switch to the paired computer while this read ran ends the local schedule.
if (destroyed || remote != null) return;
if (destroyed) return;
// A switch of the workspace while this read ran starts the schedule of the new mode.
if ((remote != null) != paired) { poll(); return; }
long now = SystemClock.elapsedRealtime();
if (result != null) {
policy.observe(result.active, now);
presentAttention(result.attention);
updateNotification(result.active > 0 ? "Working · safe to lock the screen"
presentAttention(BackgroundSources.Source.THIS_DEVICE, result.attention);
// While paired, the ongoing notification tells about the paired computer.
if (!paired) updateNotification(result.active > 0 ? "Working · safe to lock the screen"
: result.waiting > 0 ? "Waiting for your response · battery saving" : "Ready · battery saving");
} else updateNotification("Connection interrupted · checking again");
} else if (!paired) updateNotification("Connection interrupted · checking again");
if (paired) recordLocalRead(result != null, serverRunning);
BackgroundSources.Next next = BackgroundSources.afterLocalRead(paired, serverRunning, result, policy.expired(now), visible, now, settlingUntil);
if (next == BackgroundSources.Next.END_WATCH) {
endLocalWatch("not_running");
policy.observe(0, now);
}
updateLock();
if (policy.expired(now)) {
getSystemService(NotificationManager.class).notify(2, notification("Connection lost. Open OpenAIDE to recover.", "completion"));
stop("connection_lost");
} else if (!visible && result != null && result.active == 0 && now >= settlingUntil) stop("idle");
else handler.postDelayed(pollTask, result == null ? 10_000 : result.active > 0 || !visible ? 5_000 : 15_000);
switch (next) {
case STOP_LOST:
getSystemService(NotificationManager.class).notify(2, notification("Connection lost. Open OpenAIDE to recover.", "completion"));
stop("connection_lost");
break;
case STOP_IDLE: stop("idle"); break;
case END_WATCH: break;
default: handler.postDelayed(pollTask, BackgroundSources.delay(paired, result, visible));
}
});
});
}

/** Shows each new Task Attention Event that occurred out of sight and withdraws superseded ones. */
private void presentAttention(List<AttentionNotifications.Attention> current) {
/** Logs the phone's reads while paired by transition only: a healthy schedule stays quiet. */
private void recordLocalRead(boolean read, boolean running) {
if (!running) return;
if (!read && ++localFailures == 1) diagnostics.record("local_watch", "retrying", localFailures, 0, Map.of("source", BackgroundSources.Source.THIS_DEVICE.wire));
if (read && localFailures > 0) {
diagnostics.record("local_watch", "recovered", localFailures, 0, Map.of("source", BackgroundSources.Source.THIS_DEVICE.wire));
localFailures = 0;
}
}

private void endLocalWatch(String reason) {
if (!watchingLocal) return;
watchingLocal = false;
diagnostics.record("local_watch", "ended", localFailures, 0, Map.of("source", BackgroundSources.Source.THIS_DEVICE.wire, "reason", reason));
}

/** Shows each new Task Attention Event of [source] that occurred out of sight and withdraws its superseded ones. */
private void presentAttention(BackgroundSources.Source source, List<AttentionNotifications.Attention> current) {
NotificationManager manager = getSystemService(NotificationManager.class);
Map<String, String> shown = new HashMap<>();
for (StatusBarNotification active : manager.getActiveNotifications()) {
String tag = active.getTag();
String eventId = active.getNotification().extras.getString("event_id");
if (tag != null && tag.startsWith(TASK_TAG) && eventId != null) shown.put(tag.substring(TASK_TAG.length()), eventId);
if (tag != null && tag.startsWith(source.tag) && eventId != null) shown.put(tag.substring(source.tag.length()), eventId);
}
List<String> stale = AttentionNotifications.stale(shown, current);
for (String taskId : stale) manager.cancel(TASK_TAG + taskId, TASK_NOTIFICATION);
AttentionNotifications.Decision decision = attention.observe(current, visible, hiddenSince);
for (String taskId : stale) manager.cancel(source.tag + taskId, TASK_NOTIFICATION);
// The workspace not in use is never on the screen, so none of its events occurred in sight.
boolean inUse = BackgroundSources.inUse(source, remote != null);
AttentionNotifications attention = source == BackgroundSources.Source.PAIRED ? pairedAttention : localAttention;
AttentionNotifications.Decision decision = attention.observe(current, inUse && visible, inUse ? hiddenSince : 0);
// Android drops a post the user blocked without telling the app, so the block is recorded instead.
NotificationChannel channel = manager.getNotificationChannel("completion");
String blocked = !manager.areNotificationsEnabled() ? "app"
: channel != null && channel.getImportance() == NotificationManager.IMPORTANCE_NONE ? "channel" : null;
for (AttentionNotifications.Attention event : decision.show) {
manager.notify(TASK_TAG + event.taskId, TASK_NOTIFICATION, taskNotification(event));
manager.notify(source.tag + event.taskId, TASK_NOTIFICATION, taskNotification(source, event));
diagnostics.record("task_attention_notification", blocked == null ? "shown" : "blocked", 1, 0,
blocked == null ? Map.of("reason", event.reason, "event_id", event.eventId)
: Map.of("reason", event.reason, "event_id", event.eventId, "blocked_by", blocked));
blocked == null ? Map.of("reason", event.reason, "event_id", event.eventId, "source", source.wire)
: Map.of("reason", event.reason, "event_id", event.eventId, "blocked_by", blocked, "source", source.wire));
}
if (decision.baseline > 0) diagnostics.record("task_attention_notification", "suppressed", decision.baseline, 0, Map.of("cause", "baseline"));
if (decision.inSight > 0) diagnostics.record("task_attention_notification", "suppressed", decision.inSight, 0, Map.of("cause", "in_sight"));
if (!stale.isEmpty()) diagnostics.record("task_attention_notification", "withdrawn", stale.size(), 0);
if (decision.baseline > 0) diagnostics.record("task_attention_notification", "suppressed", decision.baseline, 0, Map.of("cause", "baseline", "source", source.wire));
if (decision.inSight > 0) diagnostics.record("task_attention_notification", "suppressed", decision.inSight, 0, Map.of("cause", "in_sight", "source", source.wire));
if (!stale.isEmpty()) diagnostics.record("task_attention_notification", "withdrawn", stale.size(), 0, Map.of("source", source.wire));
}

private void stop(String reason) {
stopReason = reason;
stopSelf();
}

private Notification taskNotification(AttentionNotifications.Attention event) {
// A distinct request code per Task keeps each notification's target Task.
PendingIntent open = PendingIntent.getActivity(this, event.taskId.hashCode(),
new Intent(this, MainActivity.class).putExtra(MainActivity.EXTRA_TASK_ID, event.taskId),
private Notification taskNotification(BackgroundSources.Source source, AttentionNotifications.Attention event) {
// The source tells the tap whether the phone must become the workspace in use first.
PendingIntent open = PendingIntent.getActivity(this, source.requestCode(event.taskId),
new Intent(this, MainActivity.class).putExtra(MainActivity.EXTRA_TASK_ID, event.taskId)
.putExtra(BackgroundSources.EXTRA_TASK_SOURCE, source.wire),
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
android.os.Bundle extras = new android.os.Bundle();
extras.putString("event_id", event.eventId);
Expand All @@ -208,8 +259,8 @@ private Notification taskNotification(AttentionNotifications.Attention event) {
}

private void updateLock() {
// A paired computer does the work; the phone may sleep while it waits for an answer.
protection.apply(remote == null && policy.protect(SystemClock.elapsedRealtime(), settlingUntil));
// A paired computer does its own work; only this phone's working Tasks keep it awake.
protection.apply(BackgroundSources.protect(remote != null, watchingLocal, policy, SystemClock.elapsedRealtime(), settlingUntil));
}

private void updateNotification(String text) {
Expand All @@ -236,6 +287,7 @@ private Notification notification(String text, String channel) {
handler.removeCallbacksAndMessages(null);
worker.shutdownNow();
if (remote != null) remote.stop();
endLocalWatch(stopReason);
if (protection != null) protection.close();
stopForeground(STOP_FOREGROUND_REMOVE);
// Task notifications end with the service, so its stop explains a later missing one.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package io.openaide.android;

/**
* The rules for the two places the background service watches Tasks (ADR-0070).
*
* The workspace in use is always watched: this phone's App Server, or the paired
* computer through {@link RemoteAttention}. While the paired computer is in use the
* service also watches this phone's App Server, but only while it is already running,
* so the phone's own Tasks keep it awake and still notify. Each source keeps its own
* notifications, so one source's list never withdraws the other's, and a tap on a
* phone Task while the paired computer is in use moves to the phone before it opens
* the Task. Kept free of Android so the rules can be tested.
*/
final class BackgroundSources {
/** Which source a Task notification came from, carried in its tap intent. */
static final String EXTRA_TASK_SOURCE = "task_source";

enum Source {
THIS_DEVICE("this_device", "task:"),
PAIRED("paired", "paired-task:");

/** The value carried in intents and diagnostics. */
final String wire;
/** The notification tag prefix; each source withdraws only its own notifications. */
final String tag;

Source(String wire, String tag) {
this.wire = wire;
this.tag = tag;
}

/** A distinct request code per source and Task keeps each notification's own target. */
int requestCode(String taskId) {
return this == THIS_DEVICE ? taskId.hashCode() : (wire + ":" + taskId).hashCode();
}
}

/** What the service does after one read of this phone's App Server. */
enum Next { RESCHEDULE, STOP_IDLE, STOP_LOST, END_WATCH }

/** What a tap on a Task notification does. */
enum Tap { OPEN, SWITCH_THEN_OPEN }

private BackgroundSources() { }

/**
* The step after a read of this phone's App Server. With the phone in use the
* service ends with its work, or when the server stays unreachable. With the
* paired computer in use that computer's watch keeps the service, so the phone's
* watch only ends once its server is gone, and a failed read is retried.
*/
static Next afterLocalRead(boolean paired, boolean running, ServerStatus result, boolean expired, boolean visible, long now, long settlingUntil) {
if (paired) return running ? Next.RESCHEDULE : Next.END_WATCH;
if (expired) return Next.STOP_LOST;
if (!visible && result != null && result.active == 0 && now >= settlingUntil) return Next.STOP_IDLE;
return Next.RESCHEDULE;
}

/** When to read this phone's App Server again; idle phone Tasks are read seldom while the paired computer is in use. */
static long delay(boolean paired, ServerStatus result, boolean visible) {
if (result == null) return 10_000;
if (result.active > 0) return 5_000;
if (paired) return 30_000;
return visible ? 15_000 : 5_000;
}

/** Whether the phone stays awake: only for its own working Tasks; a paired computer does its work itself. */
static boolean protect(boolean paired, boolean watchingLocal, PowerPolicy policy, long now, long settlingUntil) {
if (!paired) return policy.protect(now, settlingUntil);
// Leaving the screen gives no settling time here: the read that follows decides.
return watchingLocal && policy.protect(now, 0);
}

/** Only the workspace in use is on the screen; the other source's events are never in sight. */
static boolean inUse(Source source, boolean paired) {
return (source == Source.PAIRED) == paired;
}

/** A phone Task tapped while the paired computer is in use opens once the phone is in use. */
static Tap tap(String source, boolean pairedInUse) {
return pairedInUse && Source.THIS_DEVICE.wire.equals(source) ? Tap.SWITCH_THEN_OPEN : Tap.OPEN;
}
}
Loading
Loading