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")