diff --git a/android-plugin/app/src/trace/AndroidManifest.xml b/android-plugin/app/src/trace/AndroidManifest.xml
index d2cb4f38..6719a6cf 100644
--- a/android-plugin/app/src/trace/AndroidManifest.xml
+++ b/android-plugin/app/src/trace/AndroidManifest.xml
@@ -8,6 +8,17 @@
+
+
+
+
+
+
+
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 (isServiceRunning()) {
+ setStatus("Service is running (started outside this screen).", COLOR_WARN);
+ } else {
+ setStatus("Stopped.", COLOR_INFO);
+ }
+ }
+
+ @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[] hostPort = parseTcpEndpoint(endpoint);
+ if (hostPort == null) {
+ setStatus("Invalid 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;
+ }
+ if (isServiceRunning()) {
+ setStatus("Service already running; stop it before reconfiguring.", COLOR_WARN);
+ return;
+ }
+
+ Intent intent = new Intent(this, MobileGLServerService.class);
+ intent.putExtra("listen", endpoint);
+ intent.putExtra("token", token);
+ if (!env.isEmpty()) intent.putExtra("env", env);
+ try {
+ startForegroundService(intent);
+ } catch (RuntimeException error) {
+ Log.e(TAG, "cannot start MobileGLServerService", error);
+ setStatus("startForegroundService failed: " + error, COLOR_FAIL);
+ return;
+ }
+ setStatus("Starting " + endpoint + " ...", COLOR_INFO);
+ probeUntilListening(hostPort[0], hostPort[1]);
+ }
+
+ private void stopServer() {
+ Thread probe = probeThread;
+ if (probe != null) probe.interrupt();
+ boolean stopped = stopService(new Intent(this, MobileGLServerService.class));
+ setStatus(stopped ? "Stopped." : "Stop requested, but the service was not running.",
+ stopped ? COLOR_INFO : COLOR_WARN);
+ }
+
+ /**
+ * Confirms the supervisor bound its socket: connect to the endpoint (loopback when it
+ * listens on a wildcard address) until one attempt succeeds. 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 void probeUntilListening(String host, String portText) {
+ final String probeHost = isWildcard(host) ? "127.0.0.1" : host;
+ final int port;
+ try {
+ port = Integer.parseInt(portText);
+ } catch (NumberFormatException error) {
+ setStatus("Invalid port in endpoint.", COLOR_FAIL);
+ return;
+ }
+ Thread probe = new Thread(() -> {
+ for (int attempt = 1; attempt <= PROBE_ATTEMPTS; ++attempt) {
+ if (Thread.currentThread().isInterrupted()) return;
+ try (Socket socket = new Socket()) {
+ socket.connect(new InetSocketAddress(probeHost, port), PROBE_TIMEOUT_MS);
+ runOnUiThread(() -> setStatus(
+ "Listening on " + probeHost + ":" + port, COLOR_PASS));
+ return;
+ } catch (Exception refused) {
+ try {
+ Thread.sleep(PROBE_INTERVAL_MS);
+ } catch (InterruptedException stopped) {
+ return;
+ }
+ }
+ }
+ runOnUiThread(() -> {
+ if (isServiceRunning()) {
+ setStatus("Service running but " + probeHost + ":" + port
+ + " did not accept a probe; see logcat tag MobileGLServer.", COLOR_WARN);
+ } else {
+ setStatus("Service exited; see logcat tag MobileGLServer.", COLOR_FAIL);
+ }
+ });
+ }, "mgl-server-probe");
+ probeThread = probe;
+ probe.start();
+ }
+
+ private boolean isServiceRunning() {
+ ActivityManager activities = getSystemService(ActivityManager.class);
+ List services =
+ activities.getRunningServices(Integer.MAX_VALUE);
+ if (services == null) return false;
+ for (ActivityManager.RunningServiceInfo service : services) {
+ if (MobileGLServerService.class.getName().equals(service.service.getClassName())) {
+ 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);
+ }
+}