[Feat] (Android): the render server screen and binary ship in the plugin APK too, not just trace

This commit is contained in:
2026-09-28 22:10:56 -04:00
parent 2fa58940e4
commit 1987bdff5c
16 changed files with 128 additions and 74 deletions
@@ -2,42 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<application>
<!-- Render server control screen: start/stop the offscreen TCP supervisor
(MobileGLServerService) or the on-screen display server. Not the launcher:
PostActivity is the entry icon and the bottom navigation bar switches between
the two screens; exported so adb can drive it by explicit component with
listen/token/env/onscreen/start/stop extras. -->
<activity
android:name=".ServerControlActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/Theme.MobileGLPlugin" />
<service
android:name=".MobileGLServerService"
android:exported="true"
android:foregroundServiceType="dataSync"
android:process=":mglsrv" />
<!-- P12 (on-screen server window): the ON-SCREEN display server. Its own process (:mglwin)
runs the TCP server on a thread and renders a headless client's window surface
(MOBILEGL_IPC_SURFACE=server) onto its SurfaceView. One server at a time: starting it
stops MobileGLServerService, starting the service kills :mglwin. Software window
rendering keeps HWUI off EGL here (Espryt's teardown terminates the default display);
configChanges keeps the Surface through rotation and overlay changes. -->
<activity
android:name=".MobileGLDisplayActivity"
android:configChanges="@integer/trace_replay_config_changes"
android:exported="true"
android:hardwareAccelerated="false"
android:launchMode="singleTop"
android:process=":mglwin"
android:taskAffinity="${applicationId}.mglwin"
android:theme="@style/Theme.MobileGLPlugin" />
<activity
android:name=".PluginActivity"
tools:node="remove" />
@@ -1,333 +0,0 @@
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.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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<String, String> before = new HashMap<>(System.getenv());
Map<String, String> after = new HashMap<>(before);
ServerEnvironment.applyServerRole(after, env, token, backend,
new File(getFilesDir(), "mglwin.log").getAbsolutePath());
try {
for (Map.Entry<String, String> 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);
}
}
}
@@ -1,149 +0,0 @@
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;
@Override public IBinder onBind(Intent intent) { return null; }
@Override public void onCreate() {
super.onCreate();
NotificationManager notifications = getSystemService(NotificationManager.class);
notifications.createNotificationChannel(new NotificationChannel(
CHANNEL, "MobileGL TCP server", NotificationManager.IMPORTANCE_LOW));
startForeground(40613, new Notification.Builder(this, CHANNEL)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentTitle("MobileGL TCP server")
.setContentText("Serving remote rendering sessions")
.setOngoing(true).build());
wakeLock = getSystemService(PowerManager.class).newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, "MobileGL:TCPServer");
wakeLock.acquire();
}
// 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<ActivityManager.RunningAppProcessInfo> 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);
}
}
@Override public synchronized int onStartCommand(Intent intent, int flags, int startId) {
if (supervisor != null && supervisor.isAlive()) {
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 {
File executable = new File(getApplicationInfo().nativeLibraryDir, "libMobileGLServer.so");
ProcessBuilder builder = new ProcessBuilder(executable.getAbsolutePath(), endpoint, "--serve");
builder.directory(getFilesDir()).redirectErrorStream(true);
Map<String, String> env = builder.environment();
// 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;
new Thread(() -> {
try (BufferedReader output = new BufferedReader(new InputStreamReader(child.getInputStream()))) {
String line;
while ((line = output.readLine()) != null) Log.i(TAG, line);
Log.i(TAG, "supervisor exited " + child.waitFor());
} catch (java.io.InterruptedIOException stopped) {
// onDestroy's Process.destroy() closes this stream under the read: the service
// is being stopped (P12 D8: the display Activity stops it), not failing.
Log.i(TAG, "supervisor output closed: the service is stopping");
} catch (Exception error) {
Log.e(TAG, "supervisor output failed", error);
} finally {
stopSelf(startId);
}
}, "mgl-supervisor-log").start();
} catch (Exception error) {
Log.e(TAG, "cannot start TCP supervisor", error);
stopSelf(startId);
}
return START_NOT_STICKY;
}
@Override public void onDestroy() {
Process child = supervisor;
if (child != null) child.destroy();
if (wakeLock != null && wakeLock.isHeld()) wakeLock.release();
stopForeground(STOP_FOREGROUND_REMOVE);
super.onDestroy();
}
}
@@ -1,431 +0,0 @@
package top.mobilegl.plugin;
import android.app.Activity;
import android.app.ActivityManager;
import android.content.Context;
import android.content.Intent;
import android.graphics.Typeface;
import android.net.LocalSocket;
import android.net.LocalSocketAddress;
import android.os.Bundle;
import android.text.InputType;
import android.util.Log;
import android.util.TypedValue;
import android.view.inputmethod.EditorInfo;
import android.widget.Button;
import android.widget.CheckBox;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;
import java.io.File;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.util.List;
import java.util.Locale;
/**
* Control screen for the render server, in either shape: the offscreen supervisor
* ({@link MobileGLServerService}) or the on-screen display server
* ({@link MobileGLDisplayActivity}), which renders a client's window surface onto this
* screen. Collects the shared {@code listen}/{@code token}/{@code env} extras and starts
* or stops the selected one; no native code of its own. The endpoint is a tcp://host:port
* for remote clients, or an @abstract name / filesystem path for on-device ones. One
* server per device (P12 D8): the two shapes replace each other.
*/
public final class ServerControlActivity extends Activity {
private static final String TAG = "MobileGLServerCtl";
private static final int COLOR_BACKGROUND = 0xFF121212;
private static final int COLOR_TEXT = 0xFFEEEEEE;
private static final int COLOR_PASS = 0xFF4CAF50;
private static final int COLOR_WARN = 0xFFFF9800;
private static final int COLOR_FAIL = 0xFFF44336;
private static final int COLOR_INFO = 0xFF9E9E9E;
private static final String DEFAULT_ENDPOINT = "tcp://0.0.0.0:40613";
private static final int MIN_TOKEN_BYTES = 16;
private static final int PROBE_ATTEMPTS = 10;
private static final long PROBE_INTERVAL_MS = 500;
private static final int PROBE_TIMEOUT_MS = 500;
private EditText listenField;
private EditText tokenField;
private EditText envField;
private CheckBox onScreenBox;
private Button startButton;
private Button stopButton;
private TextView statusView;
private volatile Thread probeThread;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
LinearLayout contentLayout = new LinearLayout(this);
contentLayout.setOrientation(LinearLayout.VERTICAL);
int padding = dp(16);
contentLayout.setPadding(padding, padding, padding, padding);
ScrollView scrollView = new ScrollView(this);
scrollView.setBackgroundColor(COLOR_BACKGROUND);
scrollView.setFillViewport(true);
scrollView.addView(contentLayout);
setContentView(NavBar.wrap(this, scrollView, NavBar.TAB_SERVER));
contentLayout.addView(makeText("MobileGL Render Server", 20, COLOR_TEXT, true));
TextView hint = makeText(
"Starts the render server. Offscreen (default): the supervisor "
+ "(libMobileGLServer.so --serve) runs as a foreground service. On-screen "
+ "window: the server runs in a display Activity that renders the client's "
+ "frames onto this screen (clients opt in with MOBILEGL_IPC_SURFACE=server). "
+ "Endpoint forms: tcp://host:port for remote clients, @name for an "
+ "on-device abstract socket, or a filesystem path (relative to the app's "
+ "files dir) for on-device clients. Non-loopback TCP requires a token of at "
+ "least " + MIN_TOKEN_BYTES + " bytes; unix endpoints need none. Extra env "
+ "uses the KEY=VALUE;KEY grammar (e.g. MOBILEGL_BACKEND_TYPE=DirectVulkan). "
+ "One server per device: the two modes replace each other.",
12, COLOR_INFO, false);
LinearLayout.LayoutParams hintParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
hintParams.topMargin = dp(8);
contentLayout.addView(hint, hintParams);
listenField = addField(contentLayout, "Listen endpoint", DEFAULT_ENDPOINT, dp(16));
tokenField = addField(contentLayout, "Auth token (empty = loopback only)", "", dp(8));
envField = addField(contentLayout, "Extra env (optional)", "", dp(8));
onScreenBox = new CheckBox(this);
onScreenBox.setText("On-screen window (render client frames onto this screen)");
onScreenBox.setTextColor(COLOR_TEXT);
onScreenBox.setTextSize(TypedValue.COMPLEX_UNIT_SP, 13);
LinearLayout.LayoutParams boxParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
boxParams.topMargin = dp(8);
contentLayout.addView(onScreenBox, boxParams);
LinearLayout buttons = new LinearLayout(this);
buttons.setOrientation(LinearLayout.HORIZONTAL);
LinearLayout.LayoutParams buttonsParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
buttonsParams.topMargin = dp(16);
contentLayout.addView(buttons, buttonsParams);
startButton = new Button(this);
startButton.setText("Start server");
startButton.setAllCaps(false);
startButton.setTextSize(TypedValue.COMPLEX_UNIT_SP, 13);
startButton.setOnClickListener(v -> startServer());
buttons.addView(startButton);
stopButton = new Button(this);
stopButton.setText("Stop server");
stopButton.setAllCaps(false);
stopButton.setTextSize(TypedValue.COMPLEX_UNIT_SP, 13);
LinearLayout.LayoutParams stopParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
stopParams.leftMargin = dp(8);
buttons.addView(stopButton, stopParams);
stopButton.setOnClickListener(v -> stopServer());
statusView = makeText("", 13, COLOR_INFO, false);
LinearLayout.LayoutParams statusParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
statusParams.topMargin = dp(12);
contentLayout.addView(statusView, statusParams);
if (isServerRunning()) {
setStatus("A server is running (started outside this screen).", COLOR_WARN);
} else {
setStatus("Stopped.", COLOR_INFO);
}
handleIntent(getIntent());
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
handleIntent(intent);
}
/**
* Intent extras prefill the form, so adb can drive the screen without the IME:
* --es listen @name --es token T --es env "K=V;K" --ez onscreen true --ez start true
* (--ez stop true instead stops). This mirrors how every other entry point in this
* APK is already driven by extras.
*/
private void handleIntent(Intent intent) {
if (intent != null) {
String prefillListen = intent.getStringExtra("listen");
if (prefillListen != null) listenField.setText(prefillListen);
String prefillToken = intent.getStringExtra("token");
if (prefillToken != null) tokenField.setText(prefillToken);
String prefillEnv = intent.getStringExtra("env");
if (prefillEnv != null) envField.setText(prefillEnv);
if (intent.getBooleanExtra("onscreen", false)) onScreenBox.setChecked(true);
if (intent.getBooleanExtra("stop", false)) stopServer();
else if (intent.getBooleanExtra("start", false)) startServer();
}
}
@Override
protected void onDestroy() {
Thread probe = probeThread;
if (probe != null) probe.interrupt();
super.onDestroy();
}
private void startServer() {
String endpoint = listenField.getText().toString().trim();
String token = tokenField.getText().toString();
String env = envField.getText().toString().trim();
String probeDisplay;
Probe probe;
if (endpoint.startsWith("tcp://")) {
String[] hostPort = parseTcpEndpoint(endpoint);
if (hostPort == null) {
setStatus("Invalid TCP endpoint: expected tcp://host:port", COLOR_FAIL);
return;
}
if (!isLoopback(hostPort[0]) && token.getBytes().length < MIN_TOKEN_BYTES) {
setStatus("Non-loopback listen requires a token of at least "
+ MIN_TOKEN_BYTES + " bytes.", COLOR_FAIL);
return;
}
final String probeHost = isWildcard(hostPort[0]) ? "127.0.0.1" : hostPort[0];
final int port;
try {
port = Integer.parseInt(hostPort[1]);
} catch (NumberFormatException error) {
setStatus("Invalid port in endpoint.", COLOR_FAIL);
return;
}
probeDisplay = probeHost + ":" + port;
probe = () -> {
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress(probeHost, port), PROBE_TIMEOUT_MS);
}
};
} else {
String invalid = validateUnixEndpoint(endpoint);
if (invalid != null) {
setStatus(invalid, COLOR_FAIL);
return;
}
final boolean abstractName = endpoint.startsWith("@");
final String address = abstractName ? endpoint.substring(1)
: endpoint.startsWith("/") ? endpoint
: new File(getFilesDir(), endpoint).getAbsolutePath();
final LocalSocketAddress.Namespace namespace = abstractName
? LocalSocketAddress.Namespace.ABSTRACT : LocalSocketAddress.Namespace.FILESYSTEM;
probeDisplay = endpoint;
probe = () -> {
LocalSocket socket = new LocalSocket();
try {
socket.connect(new LocalSocketAddress(address, namespace));
} finally {
socket.close();
}
};
}
if (isServerRunning()) {
setStatus("A server is already running; stop it before reconfiguring.", COLOR_WARN);
return;
}
final boolean onScreen = onScreenBox.isChecked();
Intent intent = new Intent(this,
onScreen ? MobileGLDisplayActivity.class : MobileGLServerService.class);
intent.putExtra("listen", endpoint);
intent.putExtra("token", token);
if (!env.isEmpty()) intent.putExtra("env", env);
try {
// The display Activity runs the server in its own process (:mglwin) and shows the
// client's window surface; launching it brings that window to the front.
if (onScreen) startActivity(intent);
else startForegroundService(intent);
} catch (RuntimeException error) {
Log.e(TAG, "cannot start the render server", error);
setStatus("start failed: " + error, COLOR_FAIL);
return;
}
setStatus("Starting " + endpoint + (onScreen ? " (on-screen)" : "") + " ...", COLOR_INFO);
probeUntilListening(probeDisplay, probe);
}
/**
* unix endpoint check: an @abstract name or a filesystem path (relative paths resolve
* against the app's files dir, which is the service's working directory). Returns the
* problem, or null when the endpoint is usable. No token: an AF_UNIX address is reachable
* only from this device, and an abstract name only from this app.
*/
private String validateUnixEndpoint(String endpoint) {
if (endpoint.isEmpty()) {
return "Endpoint is empty.";
}
if (endpoint.contains("://")) {
return "Unknown endpoint scheme; use tcp://host:port, @name or a filesystem path.";
}
String resolved = endpoint.startsWith("@") || endpoint.startsWith("/") ? endpoint
: new File(getFilesDir(), endpoint).getAbsolutePath();
// sun_path is 108 bytes on Linux/Android, and an abstract name drops the '@' into it.
if (resolved.getBytes().length >= 108) {
return "Endpoint is too long for an AF_UNIX address.";
}
return null;
}
private void stopServer() {
Thread probe = probeThread;
if (probe != null) probe.interrupt();
boolean stopped = stopService(new Intent(this, MobileGLServerService.class));
boolean displayKilled = killDisplayServer();
setStatus(stopped || displayKilled ? "Stopped." : "Stop requested, but no server was running.",
stopped || displayKilled ? COLOR_INFO : COLOR_WARN);
}
/**
* Kills the on-screen server's process (:mglwin). Same uid, so killProcess is allowed;
* this mirrors MobileGLServerService.stopDisplayServer for the reverse direction.
*/
private boolean killDisplayServer() {
ActivityManager activities = getSystemService(ActivityManager.class);
String displayProcess = getPackageName() + ":mglwin";
List<ActivityManager.RunningAppProcessInfo> processes = activities.getRunningAppProcesses();
if (processes == null) return false;
for (ActivityManager.RunningAppProcessInfo process : processes) {
if (displayProcess.equals(process.processName)) {
android.os.Process.killProcess(process.pid);
return true;
}
}
return false;
}
/**
* Confirms the supervisor bound its socket: connect to the endpoint until one attempt
* succeeds. TCP probes go to loopback when the server listens on a wildcard address;
* unix probes use LocalSocket. The server closes each unauthenticated probe after its
* pre-auth timeout; at most {@link #PROBE_ATTEMPTS} probes are made, then the screen
* reports whatever the service state is.
*/
private interface Probe {
void connect() throws Exception;
}
private void probeUntilListening(String display, Probe probe) {
Thread thread = new Thread(() -> {
for (int attempt = 1; attempt <= PROBE_ATTEMPTS; ++attempt) {
if (Thread.currentThread().isInterrupted()) return;
try {
probe.connect();
runOnUiThread(() -> setStatus("Listening on " + display, COLOR_PASS));
return;
} catch (Exception refused) {
try {
Thread.sleep(PROBE_INTERVAL_MS);
} catch (InterruptedException stopped) {
return;
}
}
}
runOnUiThread(() -> {
if (isServerRunning()) {
setStatus("Server running but " + display + " did not accept a probe; "
+ "see logcat tags MobileGLServer/MobileGLDisplay.", COLOR_WARN);
} else {
setStatus("Server exited; see logcat tags MobileGLServer/MobileGLDisplay.",
COLOR_FAIL);
}
});
}, "mgl-server-probe");
probeThread = thread;
thread.start();
}
/** True when either server shape is up: the supervisor service or the :mglwin process. */
private boolean isServerRunning() {
ActivityManager activities = getSystemService(ActivityManager.class);
List<ActivityManager.RunningServiceInfo> services =
activities.getRunningServices(Integer.MAX_VALUE);
if (services != null) {
for (ActivityManager.RunningServiceInfo service : services) {
if (MobileGLServerService.class.getName().equals(service.service.getClassName())) {
return true;
}
}
}
String displayProcess = getPackageName() + ":mglwin";
List<ActivityManager.RunningAppProcessInfo> processes = activities.getRunningAppProcesses();
if (processes != null) {
for (ActivityManager.RunningAppProcessInfo process : processes) {
if (displayProcess.equals(process.processName)) return true;
}
}
return false;
}
/** {host, port} for tcp://host:port, or null. */
private static String[] parseTcpEndpoint(String endpoint) {
if (endpoint == null || !endpoint.startsWith("tcp://")) return null;
String hostPort = endpoint.substring("tcp://".length());
int separator = hostPort.lastIndexOf(':');
if (separator <= 0 || separator == hostPort.length() - 1) return null;
return new String[]{hostPort.substring(0, separator), hostPort.substring(separator + 1)};
}
private static boolean isLoopback(String host) {
// A wildcard bind is reachable from the network, so the server holds it to the
// non-loopback token requirement (AuthToken.h) just like a real interface address.
return host.equals("127.0.0.1") || host.equals("localhost") || host.equals("::1");
}
private static boolean isWildcard(String host) {
return host.isEmpty() || host.equals("0.0.0.0") || host.equals("::") || host.equals("*");
}
private EditText addField(LinearLayout parent, String label, String initial, int topMarginPx) {
TextView labelView = makeText(label, 12, COLOR_INFO, false);
LinearLayout.LayoutParams labelParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
labelParams.topMargin = topMarginPx;
parent.addView(labelView, labelParams);
EditText field = new EditText(this);
field.setText(initial);
field.setTextColor(COLOR_TEXT);
field.setHintTextColor(COLOR_INFO);
field.setTextSize(TypedValue.COMPLEX_UNIT_SP, 13);
field.setTypeface(Typeface.MONOSPACE);
field.setSingleLine(true);
field.setImeOptions(EditorInfo.IME_FLAG_NO_EXTRACT_UI);
field.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
parent.addView(field, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT));
return field;
}
private void setStatus(String message, int color) {
Log.i(TAG, String.format(Locale.ROOT, "status: %s", message));
statusView.setText(message);
statusView.setTextColor(color);
}
private TextView makeText(String text, int sizeSp, int color, boolean bold) {
TextView view = new TextView(this);
view.setText(text);
view.setTextColor(color);
view.setTextSize(TypedValue.COMPLEX_UNIT_SP, sizeSp);
view.setTypeface(Typeface.MONOSPACE, bold ? Typeface.BOLD : Typeface.NORMAL);
return view;
}
private int dp(int value) {
return Math.round(value * getResources().getDisplayMetrics().density);
}
}
@@ -1,102 +0,0 @@
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.
*
* <p>{@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.
*
* <p>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<String, String> 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<String, String> 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<String, String> changes(Map<String, String> before, Map<String, String> after) {
Map<String, String> edits = new LinkedHashMap<>();
for (Map.Entry<String, String> 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};
}
}
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- CONFIG_ASSETS_PATHS (0x80000000) exists in Android 34, but that SDK has no
public configChanges flag name for it. Package parsing resolves this integer
through TypedArray.getInt. Keep it unqualified: overlay installation must not
recreate the Activity and destroy the live native render Surface.
0x0f80 = orientation | screenLayout | uiMode | screenSize | smallestScreenSize,
matching PluginActivity. Trace framebuffer dimensions are request parameters,
and these configuration changes need no new view or native replay. -->
<integer name="trace_replay_config_changes">0x80000f80</integer>
</resources>