[Feat] (Plugin, P11 B1): same-device external clients - the exported token-checked broker that connects twice to the server's own endpoint and answers every request, the app_process helper that execs the program on the pair, a private broker listener beside a TCP-only supervisor, and the copyable launch command on the server screen

This commit is contained in:
2026-09-29 14:47:31 -04:00
parent 23a9470a28
commit bd05e061fc
6 changed files with 733 additions and 30 deletions
@@ -0,0 +1,226 @@
package top.mobilegl.plugin;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.net.LocalSocket;
import android.net.LocalSocketAddress;
import android.os.Bundle;
import android.os.IBinder;
import android.os.Parcel;
import android.os.ParcelFileDescriptor;
import android.util.Log;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* P11 B1 (CONTRACT-P11 B1): the connection broker for a same-device client in ANOTHER security
* context - a native GL program started from Termux, adb shell or another app.
*
* <p>SELinux refuses such a client the {@code connect()} to this app's unix endpoint
* ({@code connectto}, B0-CROSS-APP.md), but this app may connect to its own. So the client's
* {@link ExternalClientHelper} (an {@code app_process} under the client's uid) sends this exported
* receiver an explicit broadcast carrying a callback {@link IBinder} and the IPC token; this
* receiver checks the token against the running server's own ({@code MOBILEGL_IPC_TOKEN}), connects
* TWICE to the server's unix endpoint - or, when the server listens on TCP only, to the private
* abstract listener {@link MobileGLServerService} runs beside it for this - and hands both client
* ends back as {@link ParcelFileDescriptor}s. It writes nothing on them: the client presents its own
* PairBind pair ({@code MOBILEGL_IPC_CONTROL=fd:<control>,<aux>}).
*
* <p>EVERY request is answered on the callback - the two descriptors, or a refusal with its name -
* so a helper never has to time out to learn it was refused. The refusals: {@code bad-request},
* {@code server-not-running}, {@code server-no-token}, {@code token-missing}, {@code token-wrong},
* {@code broker-busy}, {@code connect-failed}.
*
* <p>Exported in BOTH flavours: an exported component has no category wall in front of it, so the
* token IS the guard. A server started without a token of at least {@value #MIN_TOKEN_BYTES} bytes
* hands out nothing. Runs in the server's own process ({@code :mglsrv}), where the running
* service's state is; a broadcast that finds no service there ({@code server-not-running}) cannot
* start one - Android 12+ forbids starting a foreground service from a background receiver.
*/
public final class ExternalClientBroker extends BroadcastReceiver {
private static final String TAG = "MobileGLBroker";
public static final String ACTION = "top.mobilegl.plugin.EXTERNAL_CLIENT_CONNECT";
public static final String EXTRA_REQUEST = "mobilegl_broker";
public static final String KEY_CALLBACK = "callback";
public static final String KEY_TOKEN = "token";
public static final String KEY_VERSION = "version";
public static final int PROTOCOL_VERSION = 1;
/** The broker's answer: int version, then the control and aux descriptors. */
public static final int REPLY_FDS = IBinder.FIRST_CALL_TRANSACTION;
/** The broker's refusal: String code, String message. */
public static final int REPLY_REFUSED = IBinder.FIRST_CALL_TRANSACTION + 1;
public static final String REFUSE_BAD_REQUEST = "bad-request";
public static final String REFUSE_NOT_RUNNING = "server-not-running";
public static final String REFUSE_SERVER_NO_TOKEN = "server-no-token";
public static final String REFUSE_TOKEN_MISSING = "token-missing";
public static final String REFUSE_TOKEN_WRONG = "token-wrong";
public static final String REFUSE_BUSY = "broker-busy";
public static final String REFUSE_CONNECT_FAILED = "connect-failed";
/** The same floor as a non-loopback TCP listen (MG_Remote/Transport/AuthToken.h). */
public static final int MIN_TOKEN_BYTES = 16;
/** How long the two connects may take before the listener is called busy. */
private static final long CONNECT_BUDGET_MS = 3000;
// One brokering at a time: a second request while one is connecting is refused by name.
private static final AtomicBoolean inFlight = new AtomicBoolean(false);
@Override
public void onReceive(Context context, Intent intent) {
Bundle request = intent == null ? null : intent.getBundleExtra(EXTRA_REQUEST);
IBinder callback = request == null ? null : request.getBinder(KEY_CALLBACK);
if (callback == null) {
// Nobody to answer: the one refusal that can only be logged.
Log.e(TAG, "refused by name: bad-request - no " + EXTRA_REQUEST + " bundle with a callback binder");
return;
}
int version = request.getInt(KEY_VERSION, 0);
if (version != PROTOCOL_VERSION) {
refuse(callback, REFUSE_BAD_REQUEST, "broker protocol version " + version + " is not "
+ PROTOCOL_VERSION + "; use the helper from this server's own APK (the launch command on "
+ "its server screen)");
return;
}
MobileGLServerService.BrokerTarget target = MobileGLServerService.brokerTarget();
if (target == null) {
refuse(callback, REFUSE_NOT_RUNNING, "the MobileGL render server is not running. Start it on the "
+ "MobileGL app's server screen first (offscreen mode); the broker cannot start it - "
+ "Android 12+ forbids starting a foreground service from a background receiver");
return;
}
if (target.token == null || target.token.getBytes(StandardCharsets.UTF_8).length < MIN_TOKEN_BYTES) {
refuse(callback, REFUSE_SERVER_NO_TOKEN, "the render server was started without an IPC token of at "
+ "least " + MIN_TOKEN_BYTES + " bytes, so it takes no same-device external clients. Set the "
+ "Auth token on its server screen and restart it");
return;
}
String presented = request.getString(KEY_TOKEN);
if (presented == null || presented.isEmpty()) {
refuse(callback, REFUSE_TOKEN_MISSING, "no IPC token presented; set MOBILEGL_IPC_TOKEN (or "
+ "MOBILEGL_IPC_TOKEN_FILE) in the helper's environment");
return;
}
if (!constantTimeEquals(target.token, presented)) {
refuse(callback, REFUSE_TOKEN_WRONG, "the presented IPC token is not this server's; handing out no "
+ "descriptors");
return;
}
if (!inFlight.compareAndSet(false, true)) {
refuse(callback, REFUSE_BUSY, "another client's connection is being brokered right now; retry");
return;
}
// The connects run off the main thread and onReceive returns at once, so the next request
// is delivered (and refused busy) rather than queued behind this one. The process is the
// server's foreground-service process, so it outlives the broadcast.
new Thread(() -> {
try {
broker(callback, target);
} finally {
inFlight.set(false);
}
}, "mgl-broker").start();
}
private static void broker(IBinder callback, MobileGLServerService.BrokerTarget target) {
final LocalSocket[] sockets = new LocalSocket[2];
final Exception[] failure = new Exception[1];
Thread connector = new Thread(() -> {
try {
sockets[0] = connect(target.endpoint);
sockets[1] = connect(target.endpoint);
} catch (Exception error) {
failure[0] = error;
}
}, "mgl-broker-connect");
connector.start();
try {
connector.join(CONNECT_BUDGET_MS);
} catch (InterruptedException ignored) {
}
if (connector.isAlive()) {
// A full listen backlog (or a server that stopped accepting) blocks a unix connect.
closeQuietly(sockets[0]);
closeQuietly(sockets[1]);
refuse(callback, REFUSE_BUSY, "the server's listener " + target.endpoint + " did not accept two "
+ "connections within " + CONNECT_BUDGET_MS + " ms; retry");
return;
}
if (failure[0] != null || sockets[0] == null || sockets[1] == null) {
closeQuietly(sockets[0]);
closeQuietly(sockets[1]);
refuse(callback, REFUSE_CONNECT_FAILED, "could not connect to the server's endpoint " + target.endpoint
+ ": " + failure[0]);
return;
}
ParcelFileDescriptor control = null;
ParcelFileDescriptor aux = null;
Parcel data = Parcel.obtain();
try {
control = ParcelFileDescriptor.dup(sockets[0].getFileDescriptor());
aux = ParcelFileDescriptor.dup(sockets[1].getFileDescriptor());
data.writeInt(PROTOCOL_VERSION);
control.writeToParcel(data, 0);
aux.writeToParcel(data, 0);
boolean delivered = callback.transact(REPLY_FDS, data, null, IBinder.FLAG_ONEWAY);
Log.i(TAG, "brokered a connection pair to " + target.endpoint + " for a client (delivered="
+ delivered + ")");
} catch (Exception error) {
Log.e(TAG, "could not hand the connection pair to the client", error);
} finally {
data.recycle();
// Our copies go; the client's (received over Binder) are its own descriptors.
closeQuietly(control);
closeQuietly(aux);
closeQuietly(sockets[0]);
closeQuietly(sockets[1]);
}
}
private static LocalSocket connect(String endpoint) throws Exception {
boolean abstractName = endpoint.startsWith("@");
LocalSocket socket = new LocalSocket();
try {
socket.connect(new LocalSocketAddress(abstractName ? endpoint.substring(1) : endpoint,
abstractName ? LocalSocketAddress.Namespace.ABSTRACT : LocalSocketAddress.Namespace.FILESYSTEM));
} catch (Exception error) {
closeQuietly(socket);
throw error;
}
return socket;
}
private static void refuse(IBinder callback, String code, String message) {
Log.w(TAG, "refused by name: " + code + " - " + message);
Parcel data = Parcel.obtain();
try {
data.writeString(code);
data.writeString(message);
callback.transact(REPLY_REFUSED, data, null, IBinder.FLAG_ONEWAY);
} catch (Exception error) {
Log.w(TAG, "the client's callback is gone; the refusal was only logged", error);
} finally {
data.recycle();
}
}
static boolean constantTimeEquals(String expected, String presented) {
byte[] x = expected.getBytes(StandardCharsets.UTF_8);
byte[] y = presented.getBytes(StandardCharsets.UTF_8);
int difference = x.length ^ y.length;
for (int i = 0; i < x.length; i++) difference |= x[i] ^ (i < y.length ? y[i] : 0);
return difference == 0;
}
private static void closeQuietly(AutoCloseable closeable) {
if (closeable == null) return;
try {
closeable.close();
} catch (Exception ignored) {
}
}
}
@@ -0,0 +1,305 @@
package top.mobilegl.plugin;
import android.content.ComponentName;
import android.content.Intent;
import android.os.Binder;
import android.os.Bundle;
import android.os.IBinder;
import android.os.Looper;
import android.os.Parcel;
import android.os.ParcelFileDescriptor;
import android.system.Os;
import android.system.OsConstants;
import android.util.Log;
import java.io.File;
import java.io.FileDescriptor;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* P11 B1 (CONTRACT-P11 B1): how a native GL program with no Java {@code Context} - started from
* Termux, adb shell or another app - reaches the render server over shared memory on the same
* device. Run with {@code app_process} under the program's own uid, from the server's APK:
*
* <pre>
* MOBILEGL_IPC_TOKEN=&lt;token&gt; CLASSPATH=&lt;server APK&gt; app_process / top.mobilegl.plugin.ExternalClientHelper \
* [--server &lt;package&gt;] [--timeout-ms &lt;n&gt;] [--] &lt;program&gt; [args...]
* </pre>
*
* <p>The token is read from {@code MOBILEGL_IPC_TOKEN}, or from the file {@code MOBILEGL_IPC_TOKEN_FILE}
* names - NEVER from argv, which every process on the device can read. {@code --server} defaults to
* the package of the APK this class was loaded from.
*
* <p>The helper builds a Binder, sends it and the token to the server app's exported
* {@link ExternalClientBroker} in an explicit broadcast through the hidden
* {@code IActivityManager.broadcastIntentWithFeature} (the {@code am broadcast} path: a raw
* {@code app_process} is not zygote-forked, so the hidden-API blocklist is not applied to it - a ROM
* that enforces it there breaks this route; CONTRACT-P11 B1), and waits for the broker's answer.
* Two connected sockets: it clears their close-on-exec flag and {@code execve}s the program with
* {@code MOBILEGL_TRANSPORT=spawn}, {@code MOBILEGL_IPC_CONTROL=fd:<control>,<aux>},
* {@code MOBILEGL_IPC_DATA=shm} and {@code MOBILEGL_IPC_TOKEN} (the session's Hello carries it too).
* A refusal: it prints the broker's name for it and exits with that refusal's code. No answer at
* all within the timeout: it says so and exits {@value #EXIT_NO_ANSWER}.
*/
public final class ExternalClientHelper {
private static final String TAG = "MobileGLHelper";
private static final String PREFIX = "MobileGL helper: ";
public static final int EXIT_USAGE = 2;
public static final int EXIT_BAD_REQUEST = 64;
public static final int EXIT_TOKEN_MISSING = 65;
public static final int EXIT_TOKEN_WRONG = 66;
public static final int EXIT_SERVER_NO_TOKEN = 67;
public static final int EXIT_SERVER_NOT_RUNNING = 68;
public static final int EXIT_BUSY = 69;
public static final int EXIT_CONNECT_FAILED = 70;
public static final int EXIT_NO_ANSWER = 71;
public static final int EXIT_BROADCAST_FAILED = 72;
public static final int EXIT_EXEC_FAILED = 73;
private static final long DEFAULT_TIMEOUT_MS = 10000;
private static final Object answered = new Object();
private static boolean done = false;
public static void main(String[] args) {
Looper.prepareMainLooper();
String serverPackage = BuildConfig.APPLICATION_ID;
long timeoutMs = DEFAULT_TIMEOUT_MS;
List<String> program = new ArrayList<>();
for (int i = 0; i < args.length; i++) {
String arg = args[i];
if (!program.isEmpty()) { program.add(arg); continue; }
if ("--".equals(arg)) {
for (int j = i + 1; j < args.length; j++) program.add(args[j]);
break;
} else if ("--server".equals(arg) && i + 1 < args.length) {
serverPackage = args[++i];
} else if ("--timeout-ms".equals(arg) && i + 1 < args.length) {
try {
timeoutMs = Long.parseLong(args[++i]);
} catch (NumberFormatException error) {
usage("--timeout-ms takes a number of milliseconds");
}
} else if ("--token".equals(arg) || arg.startsWith("--token=")) {
usage("the token is never taken from argv (every process can read it); set MOBILEGL_IPC_TOKEN "
+ "or MOBILEGL_IPC_TOKEN_FILE in the environment");
} else if (arg.startsWith("--")) {
usage("unknown option " + arg);
} else {
program.add(arg);
}
}
if (program.isEmpty()) usage("no program to run");
String token = readToken();
if (token.isEmpty()) {
say("no IPC token in MOBILEGL_IPC_TOKEN or MOBILEGL_IPC_TOKEN_FILE; asking the broker anyway, it "
+ "will refuse by name");
}
final String programPath = resolveProgram(program.get(0));
final List<String> argv = program;
final String presented = token;
final String component = serverPackage + "/" + ExternalClientBroker.class.getName();
// The broker's answer arrives on a binder thread; the execve happens right there, which
// replaces the whole process, so nothing has to hand the descriptors to the main thread.
Binder callback = new Binder() {
@Override
protected boolean onTransact(int code, Parcel data, Parcel reply, int flags) {
if (code == ExternalClientBroker.REPLY_REFUSED) {
String refusal = data.readString();
String message = data.readString();
say("refused by the server app (" + component + "): " + refusal + " - " + message);
finish(exitCodeFor(refusal));
return true;
}
if (code != ExternalClientBroker.REPLY_FDS) return false;
try {
int version = data.readInt();
ParcelFileDescriptor control = ParcelFileDescriptor.CREATOR.createFromParcel(data);
ParcelFileDescriptor aux = ParcelFileDescriptor.CREATOR.createFromParcel(data);
int controlFd = inheritable(control);
int auxFd = inheritable(aux);
Log.i(TAG, "broker v" + version + " handed over fd:" + controlFd + "," + auxFd + "; exec "
+ programPath);
exec(programPath, argv, controlFd, auxFd, presented);
} catch (Throwable error) {
say("could not exec " + programPath + ": " + error);
finish(EXIT_EXEC_FAILED);
}
return true;
}
};
try {
sendRequest(serverPackage, presented, callback);
} catch (Throwable error) {
say("could not send the request to " + component + " through the hidden IActivityManager: " + error
+ ". This route needs a raw app_process (not zygote-forked) on a ROM that does not apply the "
+ "hidden-API blocklist to it (CONTRACT-P11 B1)");
System.exit(EXIT_BROADCAST_FAILED);
}
final long budget = timeoutMs;
Thread watchdog = new Thread(() -> {
synchronized (answered) {
long end = System.currentTimeMillis() + budget;
while (!done) {
long left = end - System.currentTimeMillis();
if (left <= 0) break;
try {
answered.wait(left);
} catch (InterruptedException ignored) {
}
}
if (done) return;
}
say("no answer from " + component + " within " + budget + " ms. Is that MobileGL server app "
+ "installed? If it was not running, the system may have refused to start it for a "
+ "broadcast (autostart control): open its server screen and start the server first");
System.exit(EXIT_NO_ANSWER);
}, "mgl-helper-watchdog");
watchdog.setDaemon(true);
watchdog.start();
Looper.loop();
}
private static void finish(int code) {
synchronized (answered) {
done = true;
answered.notifyAll();
}
System.exit(code);
}
private static int exitCodeFor(String refusal) {
if (refusal == null) return EXIT_BAD_REQUEST;
switch (refusal) {
case ExternalClientBroker.REFUSE_TOKEN_MISSING: return EXIT_TOKEN_MISSING;
case ExternalClientBroker.REFUSE_TOKEN_WRONG: return EXIT_TOKEN_WRONG;
case ExternalClientBroker.REFUSE_SERVER_NO_TOKEN: return EXIT_SERVER_NO_TOKEN;
case ExternalClientBroker.REFUSE_NOT_RUNNING: return EXIT_SERVER_NOT_RUNNING;
case ExternalClientBroker.REFUSE_BUSY: return EXIT_BUSY;
case ExternalClientBroker.REFUSE_CONNECT_FAILED: return EXIT_CONNECT_FAILED;
default: return EXIT_BAD_REQUEST;
}
}
private static void usage(String problem) {
System.err.println(PREFIX + problem);
System.err.println("usage: MOBILEGL_IPC_TOKEN=<token> CLASSPATH=<server APK> app_process / "
+ ExternalClientHelper.class.getName()
+ " [--server <package>] [--timeout-ms <n>] [--] <program> [args...]");
System.exit(EXIT_USAGE);
}
private static void say(String line) {
Log.i(TAG, line);
System.err.println(PREFIX + line);
}
/** MOBILEGL_IPC_TOKEN, else the trimmed content of the file MOBILEGL_IPC_TOKEN_FILE names, else "". */
private static String readToken() {
String token = System.getenv("MOBILEGL_IPC_TOKEN");
if (token != null && !token.isEmpty()) return token;
String file = System.getenv("MOBILEGL_IPC_TOKEN_FILE");
if (file == null || file.isEmpty()) return "";
try {
return new String(Files.readAllBytes(new File(file).toPath()), StandardCharsets.UTF_8).trim();
} catch (Exception error) {
say("cannot read MOBILEGL_IPC_TOKEN_FILE=" + file + ": " + error);
return "";
}
}
/** A bare name is looked up on PATH, as a shell would; anything with a '/' is used as given. */
private static String resolveProgram(String name) {
if (name.indexOf('/') >= 0) return name;
String path = System.getenv("PATH");
if (path != null) {
for (String directory : path.split(":")) {
if (directory.isEmpty()) continue;
File candidate = new File(directory, name);
if (candidate.isFile() && candidate.canExecute()) return candidate.getAbsolutePath();
}
}
return name;
}
/** Clears FD_CLOEXEC so the descriptor survives execve, and detaches it from its holder. */
private static int inheritable(ParcelFileDescriptor pfd) throws Exception {
FileDescriptor fd = pfd.getFileDescriptor();
int flags = Os.fcntlInt(fd, OsConstants.F_GETFD, 0);
Os.fcntlInt(fd, OsConstants.F_SETFD, flags & ~OsConstants.FD_CLOEXEC);
return pfd.detachFd();
}
private static void exec(String path, List<String> argv, int control, int aux, String token) throws Exception {
Map<String, String> env = new LinkedHashMap<>();
String[] inherited = Os.environ();
if (inherited != null) {
for (String entry : inherited) {
int separator = entry.indexOf('=');
if (separator > 0) env.put(entry.substring(0, separator), entry.substring(separator + 1));
}
}
// CLASSPATH named the server APK for this helper; the program is not a Java class path user.
env.remove("CLASSPATH");
env.put("MOBILEGL_TRANSPORT", "spawn");
env.put("MOBILEGL_IPC_CONTROL", "fd:" + control + "," + aux);
env.put("MOBILEGL_IPC_DATA", "shm");
env.put("MOBILEGL_IPC_TOKEN", token);
List<String> envp = new ArrayList<>();
for (Map.Entry<String, String> entry : env.entrySet()) envp.add(entry.getKey() + "=" + entry.getValue());
Os.execve(path, argv.toArray(new String[0]), envp.toArray(new String[0]));
throw new IllegalStateException("execve returned");
}
/**
* The explicit broadcast, sent as {@code am broadcast} sends one: {@code ActivityManager.getService()}
* and its {@code broadcastIntentWithFeature} (or {@code broadcastIntent} on older frameworks) with a
* null caller. The parameter list differs across framework versions, so it is filled by type: the
* Intent, false for booleans, null for objects, -1 for ints except the last, which is the user id.
*/
private static void sendRequest(String serverPackage, String token, IBinder callback) throws Exception {
Intent intent = new Intent(ExternalClientBroker.ACTION);
intent.setComponent(new ComponentName(serverPackage, ExternalClientBroker.class.getName()));
// A stopped app gets the broadcast too, so a server that is not running is refused by name
// (server-not-running) instead of the request vanishing.
intent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND | Intent.FLAG_INCLUDE_STOPPED_PACKAGES);
Bundle request = new Bundle();
request.putBinder(ExternalClientBroker.KEY_CALLBACK, callback);
request.putString(ExternalClientBroker.KEY_TOKEN, token);
request.putInt(ExternalClientBroker.KEY_VERSION, ExternalClientBroker.PROTOCOL_VERSION);
intent.putExtra(ExternalClientBroker.EXTRA_REQUEST, request);
Object activityManager = Class.forName("android.app.ActivityManager").getMethod("getService").invoke(null);
Method withFeature = null;
Method plain = null;
for (Method method : activityManager.getClass().getMethods()) {
if (method.getName().equals("broadcastIntentWithFeature")) withFeature = method;
else if (method.getName().equals("broadcastIntent")) plain = method;
}
Method broadcast = withFeature != null ? withFeature : plain;
if (broadcast == null) throw new NoSuchMethodException("IActivityManager has no broadcastIntent*");
Class<?>[] types = broadcast.getParameterTypes();
Object[] values = new Object[types.length];
int lastInt = -1;
for (int i = 0; i < types.length; i++) if (types[i] == int.class) lastInt = i;
int userId = Os.getuid() / 100000;
for (int i = 0; i < types.length; i++) {
if (types[i] == Intent.class) values[i] = intent;
else if (types[i] == boolean.class) values[i] = Boolean.FALSE;
else if (types[i] == int.class) values[i] = i == lastInt ? userId : -1;
else values[i] = null;
}
broadcast.invoke(activityManager, values);
Log.i(TAG, "request sent to " + serverPackage + " via " + broadcast.getName() + "(" + types.length + " args)");
}
}
@@ -25,8 +25,39 @@ public final class MobileGLServerService extends Service {
// 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;
// P11 B1: the private abstract listener beside a TCP-only supervisor (see BrokerTarget).
private volatile Process brokerSupervisor;
private PowerManager.WakeLock wakeLock;
/**
* P11 B1 (CONTRACT-P11 B1): where {@link ExternalClientBroker} connects for a same-device client in
* another security context, and the token it checks. A unix endpoint (@name or a path) is used as
* it is; a TCP-only server gets a second supervisor on a private abstract name beside it - same
* binary, same environment and token - because the broker's descriptors must be a unix pair to
* carry the shared segments. Only this app's processes can connect to either (SELinux).
*/
static final class BrokerTarget {
final String endpoint;
final String token;
final Process process;
BrokerTarget(String endpoint, String token, Process process) {
this.endpoint = endpoint;
this.token = token;
this.process = process;
}
}
// Set when a supervisor the broker can reach is running, cleared when it stops. The broker runs
// in this same process (:mglsrv), so a broadcast that finds null here finds no server.
private static volatile BrokerTarget brokerTarget;
/** The running server's broker target, or null when none is running. */
static BrokerTarget brokerTarget() {
BrokerTarget target = brokerTarget;
return target != null && target.process != null && target.process.isAlive() ? target : null;
}
@Override public IBinder onBind(Intent intent) { return null; }
@Override public void onCreate() {
@@ -102,46 +133,75 @@ public final class MobileGLServerService extends Service {
stopDisplayServer();
String endpoint = intent == null ? null : intent.getStringExtra("listen");
if (endpoint == null) endpoint = "tcp://127.0.0.1:40613";
final String envExtra = intent == null ? null : intent.getStringExtra("env");
final String token = intent == null ? null : intent.getStringExtra("token");
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();
supervisor = startSupervisor(endpoint, envExtra, token, "mgl-supervisor-log", startId, true);
// P11 B1: the broker's target. A unix endpoint is reachable by this app as it is (a
// relative path resolves against filesDir, the supervisor's working directory); a TCP
// one gets a private abstract listener of its own (BrokerTarget).
if (endpoint.startsWith("tcp://")) {
String privateName = "@" + getPackageName() + ".broker." + Long.toHexString(
new java.security.SecureRandom().nextLong() & 0xffffffffL);
brokerSupervisor = startSupervisor(privateName, envExtra, token, "mgl-broker-supervisor-log",
startId, false);
brokerTarget = new BrokerTarget(privateName, token, brokerSupervisor);
Log.i(TAG, "same-device external clients: broker listener " + privateName
+ " beside the TCP-only supervisor on " + endpoint);
} else {
String unix = endpoint.startsWith("@") || endpoint.startsWith("/") ? endpoint
: new File(getFilesDir(), endpoint).getAbsolutePath();
brokerTarget = new BrokerTarget(unix, token, supervisor);
Log.i(TAG, "same-device external clients: the broker connects to " + unix);
}
} catch (Exception error) {
Log.e(TAG, "cannot start TCP supervisor", error);
brokerTarget = null;
stopSelf(startId);
}
return START_NOT_STICKY;
}
/**
* Execs one {@code libMobileGLServer.so <endpoint> --serve} with the server role's environment and
* logs its output. When {@code primary}, its exit stops the service.
*/
private Process startSupervisor(String endpoint, String envExtra, String token, String logThreadName,
int startId, boolean primary) throws java.io.IOException {
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, envExtra, token, /*backend=*/null,
new File(getFilesDir(), primary ? "mgl.log" : "mgl.broker.log").getAbsolutePath());
env.put("LD_LIBRARY_PATH", getApplicationInfo().nativeLibraryDir);
final Process child = builder.start();
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, (primary ? "supervisor" : "broker 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 {
if (primary) stopSelf(startId);
}
}, logThreadName).start();
return child;
}
@Override public void onDestroy() {
brokerTarget = null;
Process child = supervisor;
if (child != null) child.destroy();
Process broker = brokerSupervisor;
if (broker != null) broker.destroy();
if (wakeLock != null && wakeLock.isHeld()) wakeLock.release();
stopForeground(STOP_FOREGROUND_REMOVE);
super.onDestroy();
@@ -84,7 +84,9 @@ public final class ServerControlActivity extends Activity {
+ "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 "
+ "least " + MIN_TOKEN_BYTES + " bytes; unix endpoints need none, but GL programs "
+ "from other apps (Termux, adb shell) reach the server through its broker, which "
+ "needs one (see the launch command below). 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);
@@ -136,6 +138,8 @@ public final class ServerControlActivity extends Activity {
statusParams.topMargin = dp(12);
contentLayout.addView(statusView, statusParams);
addExternalClientSection(contentLayout);
if (isServerRunning()) {
setStatus("A server is running (started outside this screen).", COLOR_WARN);
} else {
@@ -370,6 +374,99 @@ public final class ServerControlActivity extends Activity {
return false;
}
// ---- P11 B1 (CONTRACT-P11 B1): the launch command for same-device external clients ----------
//
// A GL program started from another app (Termux, adb shell) cannot connect to this app's unix
// endpoint (SELinux), so it runs ExternalClientHelper under app_process with THIS APK as its class
// path; the helper asks ExternalClientBroker for a connected pair and execs the program on it. The
// program's side needs three facts it cannot look up itself - package visibility filters `pm path`
// for an app, and a native program has no Context - so this screen prints them: the APK path, the
// helper class, the broker component, and how the token travels (the environment, never argv).
private TextView launchCommandView;
private void addExternalClientSection(LinearLayout parent) {
TextView title = makeText("Same-device GL programs (Termux, adb shell, other apps)", 14, COLOR_TEXT, true);
LinearLayout.LayoutParams titleParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
titleParams.topMargin = dp(20);
parent.addView(title, titleParams);
launchCommandView = makeText("", 12, COLOR_INFO, false);
launchCommandView.setTextIsSelectable(true);
LinearLayout.LayoutParams commandParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
commandParams.topMargin = dp(8);
parent.addView(launchCommandView, commandParams);
LinearLayout row = new LinearLayout(this);
row.setOrientation(LinearLayout.HORIZONTAL);
LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
rowParams.topMargin = dp(8);
parent.addView(row, rowParams);
Button generate = new Button(this);
generate.setText("Generate token");
generate.setAllCaps(false);
generate.setTextSize(TypedValue.COMPLEX_UNIT_SP, 13);
generate.setOnClickListener(v -> {
byte[] bytes = new byte[16];
new java.security.SecureRandom().nextBytes(bytes);
StringBuilder hex = new StringBuilder();
for (byte b : bytes) hex.append(String.format(Locale.ROOT, "%02x", b & 0xff));
tokenField.setText(hex.toString());
});
row.addView(generate);
Button copy = new Button(this);
copy.setText("Copy launch command");
copy.setAllCaps(false);
copy.setTextSize(TypedValue.COMPLEX_UNIT_SP, 13);
LinearLayout.LayoutParams copyParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
copyParams.leftMargin = dp(8);
row.addView(copy, copyParams);
copy.setOnClickListener(v -> {
android.content.ClipboardManager clipboard = getSystemService(android.content.ClipboardManager.class);
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("MobileGL launch command",
launchCommand(tokenField.getText().toString())));
setStatus("Launch command copied (it carries the token: paste it only where you run the program).",
COLOR_INFO);
});
tokenField.addTextChangedListener(new android.text.TextWatcher() {
@Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@Override public void onTextChanged(CharSequence s, int start, int before, int count) {}
@Override public void afterTextChanged(android.text.Editable s) { refreshLaunchCommand(); }
});
refreshLaunchCommand();
}
/** The one line a user pastes before their program: token in the environment, the APK as class path. */
private String launchCommand(String token) {
return "MOBILEGL_IPC_TOKEN='" + token + "' CLASSPATH=" + getApplicationInfo().sourceDir
+ " app_process / " + ExternalClientHelper.class.getName() + " -- <program> [args...]";
}
private void refreshLaunchCommand() {
if (launchCommandView == null) return;
String token = tokenField.getText().toString();
boolean usable = token.getBytes().length >= ExternalClientBroker.MIN_TOKEN_BYTES;
StringBuilder text = new StringBuilder();
if (!usable) {
text.append("Needs an Auth token of at least ").append(ExternalClientBroker.MIN_TOKEN_BYTES)
.append(" bytes (Generate token), then start the server offscreen.\n\n");
}
text.append(launchCommand(usable ? token : "<token>")).append("\n\n")
.append("Broker: ").append(getPackageName()).append('/').append(ExternalClientBroker.class.getName())
.append("\nThe token travels in the environment (MOBILEGL_IPC_TOKEN, or MOBILEGL_IPC_TOKEN_FILE=<file>)")
.append(", never on the command line. The program runs over shared memory; the server keeps")
.append(" running as a foreground service. Battery: set both this app and the client's app to")
.append(" \"No restrictions\".");
launchCommandView.setText(text.toString());
}
/** {host, port} for tcp://host:port, or null. */
private static String[] parseTcpEndpoint(String endpoint) {
if (endpoint == null || !endpoint.startsWith("tcp://")) return null;