diff --git a/android-plugin/app/src/trace/AndroidManifest.xml b/android-plugin/app/src/trace/AndroidManifest.xml
index bfb628a2..d2cb4f38 100644
--- a/android-plugin/app/src/trace/AndroidManifest.xml
+++ b/android-plugin/app/src/trace/AndroidManifest.xml
@@ -13,6 +13,21 @@
android:exported="true"
android:foregroundServiceType="dataSync"
android:process=":mglsrv" />
+
+
diff --git a/android-plugin/app/src/trace/java/top/mobilegl/plugin/MobileGLDisplayActivity.java b/android-plugin/app/src/trace/java/top/mobilegl/plugin/MobileGLDisplayActivity.java
new file mode 100644
index 00000000..214f9d8c
--- /dev/null
+++ b/android-plugin/app/src/trace/java/top/mobilegl/plugin/MobileGLDisplayActivity.java
@@ -0,0 +1,333 @@
+package top.mobilegl.plugin;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.graphics.Color;
+import android.graphics.Rect;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Process;
+import android.os.SystemClock;
+import android.system.ErrnoException;
+import android.system.Os;
+import android.util.Log;
+import android.view.Gravity;
+import android.view.Surface;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.WindowManager;
+import android.widget.FrameLayout;
+import android.widget.TextView;
+
+import java.io.File;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * P12 (on-screen server window), D7: the ON-SCREEN display server.
+ *
+ *
A headless client (MOBILEGL_IPC_SURFACE=server) asks the server to create its window surface
+ * on the SERVER's window (WindowKind::ServerOwned) and renders as if it were drawing straight on
+ * this phone's GPU and screen. That window is this Activity's SurfaceView, and because an
+ * ANativeWindow survives neither exec nor fork, the TCP server runs IN THIS PROCESS
+ * (android:process=":mglwin") on a thread - libMobileGL's mobilegl_server_serve_inprocess, the
+ * exec'd supervisor's `--serve` with threads instead of session children. Sessions run one at a
+ * time; each one's window surface goes onto the SurfaceView.
+ *
+ *
ONE SERVER AT A TIME (D8). Starting this Activity stops {@link MobileGLServerService} (the
+ * offscreen supervisor, whose session children die with it); starting that service kills this
+ * process. Whichever was started last owns the port.
+ *
+ *
Extras (the service's names): {@code listen} (tcp://host:port, default loopback 40613),
+ * {@code token}, {@code env} (the service's {@code KEY=VALUE;KEY} grammar, {@link ServerEnvironment}),
+ * and optionally {@code backend} (MOBILEGL_BACKEND_TYPE: the pin for this process; without it the
+ * first session's backend is pinned). The environment is applied to this process with
+ * Os.setenv / Os.unsetenv BEFORE libMobileGL is loaded - it reads its knobs at load and first use.
+ *
+ *
THE SURFACE. surfaceCreated / surfaceChanged hand the window to the native display (which takes
+ * its own reference); surfaceDestroyed BLOCKS, bounded (~3 s), until a session rendering into it has
+ * released its backend surface on the server's apply thread - the GLSurfaceView contract: nothing
+ * renders into the window after this callback returns. That session is then latched by name
+ * (ServerWindowLost) and its client reads a clean device loss; the server keeps listening and the
+ * next session renders once the window is back. A size the client asks for arrives as a geometry
+ * request: SurfaceHolder.setFixedSize(w, h) with the view aspect-fitted (letterboxed) into the
+ * screen, or setSizeFromLayout() for 0/0.
+ *
+ *
ONE SERVER PER PROCESS LAUNCH. Finishing the Activity stops the server and ends the process, so
+ * the next launch starts with a fresh environment, a fresh backend pin and no EGL state.
+ * hardwareAccelerated="false" keeps HWUI off EGL in this process (Espryt's teardown terminates the
+ * process-default EGL display).
+ */
+public final class MobileGLDisplayActivity extends Activity {
+ private static final String TAG = "MobileGLDisplay";
+ static final String DEFAULT_LISTEN = "tcp://127.0.0.1:40613";
+
+ // ---- process-wide: one in-process display server per :mglwin process ------------------------
+ private static final Object serverLock = new Object();
+ private static Thread serverThread; // guarded by serverLock
+ private static String serverConfiguration; // guarded by serverLock
+ private static boolean serverReturned; // guarded by serverLock: nativeServe came back
+ private static volatile boolean displayInstalled;
+ private static final Handler mainHandler = new Handler(Looper.getMainLooper());
+ // Main thread only.
+ private static MobileGLDisplayActivity current;
+ private static int requestedWidth; // the last geometry request; 0/0 = the layout's size
+ private static int requestedHeight;
+
+ private FrameLayout container;
+ private SurfaceView surfaceView;
+ private TextView statusView;
+
+ // ---- native (MobileGL/MG_Remote/Server/DisplayServerJni.cpp) ---------------------------------
+ private static native void nativeInstallDisplay();
+ private static native int nativeServe(String listen);
+ private static native void nativeStop();
+ private static native boolean nativeAttachWindow(Surface surface, int width, int height);
+ private static native String nativeDetachWindow();
+ private static native void nativeUninstallDisplay();
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
+ | WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
+ | WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
+ | WindowManager.LayoutParams.FLAG_FULLSCREEN);
+ container = new FrameLayout(this);
+ container.setBackgroundColor(Color.BLACK);
+ surfaceView = new SurfaceView(this);
+ container.addView(surfaceView, new FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, Gravity.CENTER));
+ statusView = new TextView(this);
+ statusView.setTextColor(Color.WHITE);
+ statusView.setPadding(24, 24, 24, 24);
+ statusView.setVisibility(View.GONE);
+ container.addView(statusView, new FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.TOP));
+ setContentView(container);
+ hideSystemBars();
+ // Aspect-fit follows the screen (rotation keeps this Activity: configChanges).
+ container.addOnLayoutChangeListener((view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) ->
+ mainHandler.post(this::applyLayout));
+ current = this;
+
+ // D8: ONE SERVER AT A TIME. The offscreen supervisor goes first; the in-process listen
+ // retries for ~5 s while its listener dies.
+ if (stopService(new Intent(this, MobileGLServerService.class))) {
+ Log.i(TAG, "stopped MobileGLServerService (the offscreen supervisor): one server at a time (P12 D8)");
+ }
+ if (!startServer(getIntent())) return;
+
+ SurfaceHolder holder = surfaceView.getHolder();
+ // A recreated Activity keeps the size the live session asked for.
+ if (requestedWidth > 0 && requestedHeight > 0) holder.setFixedSize(requestedWidth, requestedHeight);
+ holder.addCallback(surfaceCallback);
+ }
+
+ @Override
+ protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ setIntent(intent);
+ startServer(intent); // the server is up: this only says whether the configuration differs
+ }
+
+ @Override
+ public void onWindowFocusChanged(boolean hasFocus) {
+ super.onWindowFocusChanged(hasFocus);
+ if (hasFocus) hideSystemBars();
+ }
+
+ @Override
+ protected void onDestroy() {
+ if (current == this) current = null;
+ if (isFinishing()) {
+ boolean serving;
+ synchronized (serverLock) {
+ serving = serverThread != null && !serverReturned;
+ }
+ if (serving) {
+ // Non-blocking: the live session ends within a control slice, serve() returns and
+ // ends this process (onServerReturned).
+ Log.i(TAG, "the display Activity is finishing: stopping the in-process display server");
+ nativeStop();
+ } else {
+ endProcess();
+ }
+ }
+ super.onDestroy();
+ }
+
+ // ---- the server ----------------------------------------------------------------------------
+
+ private static String extra(Intent intent, String name, String fallback) {
+ String value = intent == null ? null : intent.getStringExtra(name);
+ return value == null ? fallback : value;
+ }
+
+ /** Starts this process's display server once. False (and the Activity finishes) on failure. */
+ private boolean startServer(Intent intent) {
+ final String listen = extra(intent, "listen", DEFAULT_LISTEN);
+ String token = extra(intent, "token", "");
+ String env = extra(intent, "env", "");
+ String backend = extra(intent, "backend", "");
+ String configuration = listen + "\n" + token + "\n" + env + "\n" + backend;
+ synchronized (serverLock) {
+ if (serverThread != null) {
+ if (!configuration.equals(serverConfiguration)) {
+ Log.w(TAG, "the in-process display server already runs in this process with another "
+ + "configuration and keeps it; stop the package (am force-stop) to reconfigure");
+ }
+ return true;
+ }
+ // THE ENVIRONMENT BEFORE THE LIBRARY: libMobileGL reads its knobs when it loads and at
+ // first use, and the log role is cached at its first line.
+ Map before = new HashMap<>(System.getenv());
+ Map after = new HashMap<>(before);
+ ServerEnvironment.applyServerRole(after, env, token, backend,
+ new File(getFilesDir(), "mglwin.log").getAbsolutePath());
+ try {
+ for (Map.Entry edit : ServerEnvironment.changes(before, after).entrySet()) {
+ if (edit.getValue() == null) Os.unsetenv(edit.getKey());
+ else Os.setenv(edit.getKey(), edit.getValue(), true);
+ }
+ } catch (ErrnoException error) {
+ fail("could not set the server environment: " + error);
+ return false;
+ }
+ try {
+ System.loadLibrary("MobileGL");
+ nativeInstallDisplay();
+ } catch (Throwable error) {
+ // UnsatisfiedLinkError: an APK whose libMobileGL is not a split build has no server.
+ fail("libMobileGL has no in-process display server: " + error);
+ return false;
+ }
+ displayInstalled = true;
+ serverConfiguration = configuration;
+ Log.i(TAG, "starting the in-process display server on " + listen + " (pid=" + Process.myPid()
+ + (backend.isEmpty() ? ", backend pinned by the first session" : ", backend " + backend) + ")");
+ serverThread = new Thread(() -> serve(listen), "mgl-display-server");
+ serverThread.start();
+ return true;
+ }
+ }
+
+ private static void serve(String listen) {
+ int result = nativeServe(listen);
+ Log.i(TAG, "in-process display server on " + listen + " returned " + result
+ + (result == 0 ? " (stopped)" : " (failed: see the MobileGL lines before this one)"));
+ displayInstalled = false;
+ nativeUninstallDisplay();
+ synchronized (serverLock) {
+ serverReturned = true;
+ }
+ mainHandler.post(() -> onServerReturned(result));
+ }
+
+ private static void onServerReturned(int result) {
+ MobileGLDisplayActivity activity = current;
+ if (activity != null && !activity.isFinishing()) {
+ // A display without a server shows nothing anyone can reach: say so and go.
+ activity.showStatus("MobileGL display server exited " + result);
+ activity.finish(); // onDestroy ends the process
+ return;
+ }
+ endProcess();
+ }
+
+ private static void endProcess() {
+ Log.i(TAG, "the display server's process ends (one server per :mglwin launch)");
+ Process.killProcess(Process.myPid());
+ }
+
+ private void fail(String message) {
+ Log.e(TAG, message);
+ showStatus(message);
+ finish();
+ }
+
+ private void showStatus(String message) {
+ statusView.setText(message);
+ statusView.setVisibility(View.VISIBLE);
+ }
+
+ private void hideSystemBars() {
+ getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_FULLSCREEN
+ | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
+ | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
+ | View.SYSTEM_UI_FLAG_LAYOUT_STABLE
+ | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
+ | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN);
+ }
+
+ // ---- the window ----------------------------------------------------------------------------
+
+ private final SurfaceHolder.Callback surfaceCallback = new SurfaceHolder.Callback() {
+ @Override
+ public void surfaceCreated(SurfaceHolder holder) {
+ Rect frame = holder.getSurfaceFrame();
+ attach(holder, frame.width(), frame.height(), "surfaceCreated");
+ }
+
+ @Override
+ public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
+ attach(holder, width, height, "surfaceChanged");
+ }
+
+ @Override
+ public void surfaceDestroyed(SurfaceHolder holder) {
+ if (!displayInstalled) return;
+ long started = SystemClock.uptimeMillis();
+ // BLOCKS (bounded) until the session rendering into this window has let it go.
+ String detached = nativeDetachWindow();
+ Log.i(TAG, "surfaceDestroyed: server window detached (" + detached + ") in "
+ + (SystemClock.uptimeMillis() - started) + " ms");
+ }
+ };
+
+ private void attach(SurfaceHolder holder, int width, int height, String why) {
+ if (!displayInstalled) return;
+ Surface surface = holder.getSurface();
+ if (surface == null || !surface.isValid()) return;
+ boolean attached = nativeAttachWindow(surface, width, height);
+ Log.i(TAG, why + ": server window " + width + "x" + height + (attached ? " attached" : " NOT attached"));
+ }
+
+ // Called from native (DisplayServerJni.cpp) on the server's APPLY thread while a ServerOwned
+ // window surface is created: post to the UI thread and return - never block there.
+ @SuppressWarnings("unused")
+ private static void onGeometryRequest(int width, int height) {
+ mainHandler.post(() -> {
+ requestedWidth = Math.max(0, width);
+ requestedHeight = Math.max(0, height);
+ Log.i(TAG, "geometry request " + requestedWidth + "x" + requestedHeight
+ + (requestedWidth > 0 && requestedHeight > 0 ? " (setFixedSize, aspect-fit)" : " (setSizeFromLayout)"));
+ MobileGLDisplayActivity activity = current;
+ if (activity != null) activity.applyGeometry();
+ });
+ }
+
+ private void applyGeometry() {
+ SurfaceHolder holder = surfaceView.getHolder();
+ if (requestedWidth > 0 && requestedHeight > 0) holder.setFixedSize(requestedWidth, requestedHeight);
+ else holder.setSizeFromLayout();
+ applyLayout();
+ }
+
+ private void applyLayout() {
+ int[] fit = ServerEnvironment.aspectFit(container.getWidth(), container.getHeight(),
+ requestedWidth, requestedHeight);
+ FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) surfaceView.getLayoutParams();
+ int width = fit[0] > 0 ? fit[0] : ViewGroup.LayoutParams.MATCH_PARENT;
+ int height = fit[1] > 0 ? fit[1] : ViewGroup.LayoutParams.MATCH_PARENT;
+ if (params.width != width || params.height != height || params.gravity != Gravity.CENTER) {
+ params.width = width;
+ params.height = height;
+ params.gravity = Gravity.CENTER;
+ surfaceView.setLayoutParams(params);
+ }
+ }
+}
diff --git a/android-plugin/app/src/trace/java/top/mobilegl/plugin/MobileGLServerService.java b/android-plugin/app/src/trace/java/top/mobilegl/plugin/MobileGLServerService.java
index 7b0ddeb9..b063afa4 100644
--- a/android-plugin/app/src/trace/java/top/mobilegl/plugin/MobileGLServerService.java
+++ b/android-plugin/app/src/trace/java/top/mobilegl/plugin/MobileGLServerService.java
@@ -1,23 +1,29 @@
package top.mobilegl.plugin;
+import android.app.ActivityManager;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.Service;
+import android.content.ComponentName;
import android.content.Intent;
import android.os.IBinder;
import android.os.PowerManager;
+import android.os.SystemClock;
import android.util.Log;
import java.io.BufferedReader;
import java.io.File;
import java.io.InputStreamReader;
+import java.util.List;
import java.util.Map;
/** Trace-only entry point for a TCP supervisor; EGL belongs to its session children. */
public final class MobileGLServerService extends Service {
private static final String TAG = "MobileGLServer";
private static final String CHANNEL = "mobilegl-server";
+ // P12 (D8): the on-screen display server's process. Only one server is active on the device.
+ private static final long DISPLAY_SERVER_EXIT_WAIT_MS = 5000;
private volatile Process supervisor;
private PowerManager.WakeLock wakeLock;
@@ -38,14 +44,53 @@ public final class MobileGLServerService extends Service {
wakeLock.acquire();
}
- // Keep the replay runner's KEY=VALUE;KEY grammar, including unset and empty values.
- static void applyEnvironment(Map env, String overrides) {
- if (overrides == null) return;
- for (String entry : overrides.split(";", -1)) {
- int separator = entry.indexOf('=');
- if (entry.isEmpty() || separator == 0) continue;
- if (separator < 0) env.remove(entry);
- else env.put(entry.substring(0, separator), entry.substring(separator + 1));
+ // P12 (D8): ONLY ONE SERVER IS ACTIVE ON THE DEVICE. The on-screen display server runs in the
+ // display Activity's process (:mglwin) and listens on the same port; before the offscreen
+ // supervisor is exec'd, that Activity's task is removed (so it is not restarted from recents)
+ // and its process is killed - same uid, so Process.killProcess may - and waited out, bounded,
+ // so its listener is gone before the supervisor binds.
+ private void stopDisplayServer() {
+ ActivityManager activities = getSystemService(ActivityManager.class);
+ ComponentName display = new ComponentName(this, MobileGLDisplayActivity.class);
+ try {
+ for (ActivityManager.AppTask task : activities.getAppTasks()) {
+ ActivityManager.RecentTaskInfo info = task.getTaskInfo();
+ if (info != null && display.equals(info.baseIntent.getComponent())) {
+ Log.i(TAG, "removing the on-screen display server's task (P12 D8: one server at a time)");
+ task.finishAndRemoveTask();
+ }
+ }
+ } catch (RuntimeException error) {
+ Log.w(TAG, "could not inspect the display server's task", error);
+ }
+ String processName = getPackageName() + ":mglwin";
+ long deadline = SystemClock.uptimeMillis() + DISPLAY_SERVER_EXIT_WAIT_MS;
+ int killed = -1;
+ for (;;) {
+ int pid = -1;
+ List processes = activities.getRunningAppProcesses();
+ if (processes != null) {
+ for (ActivityManager.RunningAppProcessInfo process : processes) {
+ if (processName.equals(process.processName)) pid = process.pid;
+ }
+ }
+ // AMS forgets a dead process a moment after it is gone; /proc is the ground truth.
+ if (pid < 0 || !new File("/proc/" + pid).exists()) {
+ if (killed > 0) Log.i(TAG, "on-screen display server " + processName + " pid=" + killed + " is gone");
+ return;
+ }
+ if (pid != killed) {
+ Log.i(TAG, "stopping the on-screen display server " + processName + " pid=" + pid
+ + " (P12 D8: one server at a time)");
+ android.os.Process.killProcess(pid);
+ killed = pid;
+ }
+ if (SystemClock.uptimeMillis() >= deadline) {
+ Log.e(TAG, "on-screen display server " + processName + " pid=" + pid + " still exists after "
+ + DISPLAY_SERVER_EXIT_WAIT_MS + " ms; starting the supervisor anyway");
+ return;
+ }
+ SystemClock.sleep(50);
}
}
@@ -54,6 +99,7 @@ public final class MobileGLServerService extends Service {
Log.w(TAG, "supervisor already running; stop the service before reconfiguring");
return START_NOT_STICKY;
}
+ stopDisplayServer();
String endpoint = intent == null ? null : intent.getStringExtra("listen");
if (endpoint == null) endpoint = "tcp://127.0.0.1:40613";
try {
@@ -61,17 +107,13 @@ public final class MobileGLServerService extends Service {
ProcessBuilder builder = new ProcessBuilder(executable.getAbsolutePath(), endpoint, "--serve");
builder.directory(getFilesDir()).redirectErrorStream(true);
Map env = builder.environment();
- applyEnvironment(env, intent == null ? null : intent.getStringExtra("env"));
- env.put("MOBILEGL_IPC_ROLE", "server");
- env.put("MOBILEGL_IPC_DIAL", "no");
- env.put("MOBILEGL_IPC_LOG_FORWARD", "1");
- for (String name : new String[]{"MOBILEGL_TRANSPORT", "MOBILEGL_IPC_CONTROL", "MOBILEGL_IPC_ENDPOINT",
- "MOBILEGL_IPC_SERVER_PATH", "MOBILEGL_IPC_RING_MB", "MOBILEGL_IPC_STAGE_MB"}) {
- env.remove(name);
- }
- env.put("MOBILEGL_IPC_TOKEN", intent == null || intent.getStringExtra("token") == null
- ? "" : intent.getStringExtra("token"));
- env.putIfAbsent("MOBILEGL_LOG_FILE_PATH", new File(getFilesDir(), "mgl.log").getAbsolutePath());
+ // The replay runner's KEY=VALUE;KEY grammar (unset and empty values included) and the
+ // server role's forced keys, shared with MobileGLDisplayActivity (P12 D7).
+ ServerEnvironment.applyServerRole(env,
+ intent == null ? null : intent.getStringExtra("env"),
+ intent == null ? null : intent.getStringExtra("token"),
+ /*backend=*/null,
+ new File(getFilesDir(), "mgl.log").getAbsolutePath());
env.put("LD_LIBRARY_PATH", getApplicationInfo().nativeLibraryDir);
supervisor = builder.start();
final Process child = supervisor;
diff --git a/android-plugin/app/src/trace/java/top/mobilegl/plugin/ServerEnvironment.java b/android-plugin/app/src/trace/java/top/mobilegl/plugin/ServerEnvironment.java
new file mode 100644
index 00000000..2c204fe1
--- /dev/null
+++ b/android-plugin/app/src/trace/java/top/mobilegl/plugin/ServerEnvironment.java
@@ -0,0 +1,102 @@
+package top.mobilegl.plugin;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * The server role's environment, shared by the two servers the trace APK can run.
+ *
+ * {@link MobileGLServerService} exec's the offscreen supervisor with it (a ProcessBuilder
+ * environment); {@link MobileGLDisplayActivity} applies it to its OWN process with Os.setenv /
+ * Os.unsetenv before libMobileGL is loaded, because the on-screen display server runs in that
+ * process (P12 D7). One grammar and one set of forced keys for both, so the two servers cannot
+ * drift apart on what an {@code env} extra means.
+ *
+ *
No android.* import: tools/trace_replay/test_android_lifecycle.py compiles and runs this class
+ * with plain javac (ServerEnvironmentTest), which is the only place it runs outside a device.
+ */
+public final class ServerEnvironment {
+ private ServerEnvironment() {}
+
+ public static final String BACKEND = "MOBILEGL_BACKEND_TYPE";
+ public static final String TOKEN = "MOBILEGL_IPC_TOKEN";
+ public static final String LOG_FILE_PATH = "MOBILEGL_LOG_FILE_PATH";
+
+ /**
+ * What the server must never inherit: a transport (it would dial instead of serving), the
+ * client's endpoint knobs, and the spawn arm's sizing (mobilegl_server_main's scrub list, exit
+ * 65, plus the two endpoint names the listen extra replaces).
+ */
+ static final String[] SCRUBBED = {
+ "MOBILEGL_TRANSPORT", "MOBILEGL_IPC_CONTROL", "MOBILEGL_IPC_ENDPOINT",
+ "MOBILEGL_IPC_SERVER_PATH", "MOBILEGL_IPC_RING_MB", "MOBILEGL_IPC_STAGE_MB",
+ };
+
+ /**
+ * Applies the replay runner's {@code KEY=VALUE;KEY} grammar to {@code env}: {@code K=V} sets
+ * (an empty V is a set, not an unset), a bare {@code K} unsets, empty entries and entries with
+ * an empty key are skipped. The last entry for a key wins.
+ */
+ public static void apply(Map env, String overrides) {
+ if (overrides == null) return;
+ for (String entry : overrides.split(";", -1)) {
+ int separator = entry.indexOf('=');
+ if (entry.isEmpty() || separator == 0) continue;
+ if (separator < 0) env.remove(entry);
+ else env.put(entry.substring(0, separator), entry.substring(separator + 1));
+ }
+ }
+
+ /**
+ * Turns {@code env} into the server role's environment, in place: the {@code env} extra first,
+ * then an explicit backend pin (it wins over the extra's), then the keys every server forces -
+ * ROLE=server, DIAL=no, LOG_FORWARD=1, the scrub list removed, the token (empty when none) -
+ * and a log file path when none was given.
+ */
+ public static void applyServerRole(Map env, String overrides, String token, String backend,
+ String defaultLogPath) {
+ apply(env, overrides);
+ if (backend != null && !backend.isEmpty()) env.put(BACKEND, backend);
+ env.put("MOBILEGL_IPC_ROLE", "server");
+ env.put("MOBILEGL_IPC_DIAL", "no");
+ env.put("MOBILEGL_IPC_LOG_FORWARD", "1");
+ for (String name : SCRUBBED) env.remove(name);
+ env.put(TOKEN, token == null ? "" : token);
+ if (defaultLogPath != null) env.putIfAbsent(LOG_FILE_PATH, defaultLogPath);
+ }
+
+ /**
+ * The edits that turn {@code before} into {@code after}, in a stable order: a key whose value
+ * is new or different maps to that value, a key {@code after} dropped maps to null (unset).
+ * The display Activity applies exactly these with Os.setenv / Os.unsetenv.
+ */
+ public static Map changes(Map before, Map after) {
+ Map edits = new LinkedHashMap<>();
+ for (Map.Entry entry : new java.util.TreeMap<>(after).entrySet()) {
+ if (!entry.getValue().equals(before.get(entry.getKey()))) edits.put(entry.getKey(), entry.getValue());
+ }
+ for (String name : new java.util.TreeSet<>(before.keySet())) {
+ if (!after.containsKey(name)) edits.put(name, null);
+ }
+ return edits;
+ }
+
+ /**
+ * Aspect-fit (letterbox / pillarbox): the largest {@code bufferWidth:bufferHeight} rectangle
+ * inside a {@code containerWidth x containerHeight} view, as {width, height}. {-1, -1} (fill the
+ * container, MATCH_PARENT) when either size is unknown or no fixed buffer size is set.
+ */
+ public static int[] aspectFit(int containerWidth, int containerHeight, int bufferWidth, int bufferHeight) {
+ if (containerWidth <= 0 || containerHeight <= 0 || bufferWidth <= 0 || bufferHeight <= 0) {
+ return new int[]{-1, -1};
+ }
+ long wide = (long) containerWidth * bufferHeight;
+ long tall = (long) containerHeight * bufferWidth;
+ if (wide <= tall) {
+ // The container is narrower than the buffer: full width, bars above and below.
+ return new int[]{containerWidth, (int) Math.max(1, wide / bufferWidth)};
+ }
+ // The container is wider: full height, bars left and right.
+ return new int[]{(int) Math.max(1, tall / bufferHeight), containerHeight};
+ }
+}
diff --git a/tools/trace_replay/ServerEnvironmentTest.java b/tools/trace_replay/ServerEnvironmentTest.java
new file mode 100644
index 00000000..e70308fd
--- /dev/null
+++ b/tools/trace_replay/ServerEnvironmentTest.java
@@ -0,0 +1,103 @@
+package top.mobilegl.plugin;
+
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Host-side checks for ServerEnvironment: the server role's environment and the display's
+ * aspect-fit, shared by MobileGLServerService (the offscreen supervisor's exec environment) and
+ * MobileGLDisplayActivity (the on-screen display server's own process, P12 D7).
+ *
+ * On a device a defect here reads as "the knob had no effect" or "the server refused to start"
+ * (a scrubbed key left in: exit 65), so it is exercised here, by tools/trace_replay/
+ * test_android_lifecycle.py with plain javac. Assert-free for the same reason as
+ * SpawnServerPathTest: -ea is off by default, and a check compiled away checks nothing.
+ */
+public final class ServerEnvironmentTest {
+ private static int failures = 0;
+
+ private static void expect(String what, Object actual, Object expected) {
+ if (expected == null ? actual == null : expected.equals(actual)) return;
+ ++failures;
+ System.err.println(what + "\n got: [" + actual + "]\n expected: [" + expected + "]");
+ }
+
+ private static Map map(String... keyValues) {
+ Map result = new LinkedHashMap<>();
+ for (int i = 0; i < keyValues.length; i += 2) result.put(keyValues[i], keyValues[i + 1]);
+ return result;
+ }
+
+ public static void main(String[] args) {
+ // ---- the K=V;K grammar (the service's, unchanged) -------------------------------------------
+ Map env = new HashMap<>(map("B", "old", "E", "keep"));
+ ServerEnvironment.apply(env, "A=1;B;C=;=x;;D=4;A=2");
+ expect("K=V sets, a bare K unsets, K= sets empty, =x and empty entries are skipped, the last wins",
+ env, new HashMap<>(map("A", "2", "C", "", "D", "4", "E", "keep")));
+ Map untouched = new HashMap<>(map("A", "1"));
+ ServerEnvironment.apply(untouched, null);
+ expect("no env extra changes nothing", untouched, new HashMap<>(map("A", "1")));
+
+ // ---- the server role ----------------------------------------------------------------------
+ Map launcher = new HashMap<>(map(
+ "MOBILEGL_TRANSPORT", "spawn",
+ "MOBILEGL_IPC_CONTROL", "tcp://10.0.0.2:40613",
+ "MOBILEGL_IPC_ENDPOINT", "tcp://x",
+ "MOBILEGL_IPC_DIAL", "yes",
+ "PATH", "/system/bin"));
+ ServerEnvironment.applyServerRole(launcher,
+ "MOBILEGL_IPC_ROLE=client;MOBILEGL_BACKEND_TYPE=DirectGLES;MOBILEGL_IPC_RING_MB=8;"
+ + "MOBILEGL_IPC_SERVER_PATH=/x;MOBILEGL_IPC_STAGE_MB=4;FOO=bar",
+ "a-token-of-sixteen-bytes", "DirectVulkan", "/data/files/mglwin.log");
+ expect("the server role: forced keys win over the env extra, the scrub list is gone, the backend "
+ + "extra pins over the env's, the token and the default log path are set",
+ launcher, new HashMap<>(map(
+ "PATH", "/system/bin",
+ "FOO", "bar",
+ "MOBILEGL_BACKEND_TYPE", "DirectVulkan",
+ "MOBILEGL_IPC_ROLE", "server",
+ "MOBILEGL_IPC_DIAL", "no",
+ "MOBILEGL_IPC_LOG_FORWARD", "1",
+ "MOBILEGL_IPC_TOKEN", "a-token-of-sixteen-bytes",
+ "MOBILEGL_LOG_FILE_PATH", "/data/files/mglwin.log")));
+ Map service = new HashMap<>();
+ ServerEnvironment.applyServerRole(service, "MOBILEGL_BACKEND_TYPE=DirectGLES;MOBILEGL_LOG_FILE_PATH=/sdcard/x.log",
+ null, null, "/data/files/mgl.log");
+ expect("the service's shape: no token is an empty token, no backend extra keeps the env's, an env "
+ + "log path is kept",
+ service, new HashMap<>(map(
+ "MOBILEGL_BACKEND_TYPE", "DirectGLES",
+ "MOBILEGL_IPC_ROLE", "server",
+ "MOBILEGL_IPC_DIAL", "no",
+ "MOBILEGL_IPC_LOG_FORWARD", "1",
+ "MOBILEGL_IPC_TOKEN", "",
+ "MOBILEGL_LOG_FILE_PATH", "/sdcard/x.log")));
+
+ // ---- the Os.setenv / Os.unsetenv edits the display Activity applies ---------------------------
+ Map edits = ServerEnvironment.changes(
+ map("A", "1", "B", "2", "C", "3", "MOBILEGL_TRANSPORT", "spawn"),
+ map("A", "1", "B", "5", "D", "4", "E", ""));
+ expect("changed and new keys are set (sorted), dropped keys are unset (null), equal keys are left",
+ new java.util.ArrayList<>(edits.entrySet()).toString(),
+ "[B=5, D=4, E=, C=null, MOBILEGL_TRANSPORT=null]");
+
+ // ---- aspect-fit ----------------------------------------------------------------------------------
+ expect("a portrait screen letterboxes a 640x480 buffer: full width",
+ java.util.Arrays.toString(ServerEnvironment.aspectFit(1080, 2400, 640, 480)), "[1080, 810]");
+ expect("a landscape screen pillarboxes it: full height",
+ java.util.Arrays.toString(ServerEnvironment.aspectFit(2400, 1080, 640, 480)), "[1440, 1080]");
+ expect("the same aspect fills the view",
+ java.util.Arrays.toString(ServerEnvironment.aspectFit(1280, 960, 640, 480)), "[1280, 960]");
+ expect("no fixed size (0/0: the layout's own) fills the view",
+ java.util.Arrays.toString(ServerEnvironment.aspectFit(1080, 2400, 0, 0)), "[-1, -1]");
+ expect("an unmeasured view fills the view",
+ java.util.Arrays.toString(ServerEnvironment.aspectFit(0, 0, 640, 480)), "[-1, -1]");
+
+ if (failures != 0) {
+ System.err.println("ServerEnvironmentTest: " + failures + " failure(s)");
+ System.exit(1);
+ }
+ System.out.println("ServerEnvironmentTest: all checks passed");
+ }
+}
diff --git a/tools/trace_replay/test_android_lifecycle.py b/tools/trace_replay/test_android_lifecycle.py
index 542dd0ec..32c448b8 100644
--- a/tools/trace_replay/test_android_lifecycle.py
+++ b/tools/trace_replay/test_android_lifecycle.py
@@ -25,22 +25,41 @@ def main():
raise RuntimeError("TraceReplayActivity must handle CONFIG_ASSETS_PATHS")
if changes & 0xF80 != 0xF80:
raise RuntimeError("TraceReplayActivity must retain the fixed-size render surface on display changes")
+ # P12 (on-screen server window), D7: the display Activity. Each attribute below is load-bearing
+ # and none of them is visible to a build: its own process (the in-process server's environment,
+ # backend pin and EGL teardown stay out of every other process of the package, and the service
+ # finds it by this name to enforce one server at a time), software window rendering (HWUI must
+ # not share the EGL display Espryt terminates), and the same configChanges as the replay
+ # Activity (a rotation or an overlay must not destroy the window a session renders into).
+ display = next((a for a in manifest.findall("application/activity")
+ if a.get(android + "name") == ".MobileGLDisplayActivity"), None)
+ if display is None:
+ raise RuntimeError("the trace manifest has no MobileGLDisplayActivity (P12 D7)")
+ for attribute, expected in (("process", ":mglwin"), ("hardwareAccelerated", "false"),
+ ("exported", "true"),
+ ("configChanges", activity.get(android + "configChanges"))):
+ if display.get(android + attribute) != expected:
+ raise RuntimeError(f"MobileGLDisplayActivity android:{attribute} must be {expected!r}, "
+ f"not {display.get(android + attribute)!r}")
# Every trace-flavour Java class that has NO android.* import belongs here, because javac
# plus `java` is the only way any of them is exercised outside an APK on a device. P7 added
# SpawnServerPath: a hand-rolled K=V;K=V parse whose failure mode on device is "the knob had
- # no effect", which is why a defect in it survived from P6.
+ # no effect", which is why a defect in it survived from P6. P12 added ServerEnvironment: the
+ # server role's environment both servers build (the service's exec environment, the display
+ # Activity's own process) and the display's aspect-fit.
java_units = [
- ("TraceReplaySession", "TraceReplaySessionTest"),
- ("SpawnServerPath", "SpawnServerPathTest"),
+ ("top.mobilegl.plugin.trace", "TraceReplaySession", "TraceReplaySessionTest"),
+ ("top.mobilegl.plugin.trace", "SpawnServerPath", "SpawnServerPathTest"),
+ ("top.mobilegl.plugin", "ServerEnvironment", "ServerEnvironmentTest"),
]
with tempfile.TemporaryDirectory(prefix="mobilegl-replay-lifecycle-") as output:
sources = []
- for unit, test in java_units:
- sources.append(str(trace / f"java/top/mobilegl/plugin/trace/{unit}.java"))
+ for package, unit, test in java_units:
+ sources.append(str(trace / "java" / package.replace(".", "/") / f"{unit}.java"))
sources.append(str(root / f"tools/trace_replay/{test}.java"))
subprocess.run(["javac", "--release", "11", "-d", output, *sources], check=True)
- for _, test in java_units:
- subprocess.run(["java", "-cp", output, f"top.mobilegl.plugin.trace.{test}"], check=True)
+ for package, _, test in java_units:
+ subprocess.run(["java", "-cp", output, f"{package}.{test}"], check=True)
print("Android replay manifest and lifecycle checks passed")