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); + } +}