From 1f72597dd845d77d5606ca6a2570b61525ea8662 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 27 Sep 2026 02:54:17 -0400 Subject: [PATCH] [Feat] (Android): a launcher screen starts and stops the offscreen TCP server ServerControlActivity (trace flavor) collects the listen endpoint, auth token and extra env, then drives MobileGLServerService with the same extras adb used. Non-loopback listens are held to the 16-byte token the server enforces, and a connect probe confirms the supervisor bound its socket. The screen is the APK's launcher icon, which the trace flavor lacked since PostActivity is removed there. --- .../app/src/trace/AndroidManifest.xml | 11 + .../plugin/ServerControlActivity.java | 289 ++++++++++++++++++ 2 files changed, 300 insertions(+) create mode 100644 android-plugin/app/src/trace/java/top/mobilegl/plugin/ServerControlActivity.java 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); + } +}