[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
+3
View File
@@ -169,6 +169,9 @@ android {
buildFeatures {
resValues = true
// P11 B1: ExternalClientHelper's default --server is BuildConfig.APPLICATION_ID - the package of
// the very APK an app_process loaded it from (flavour and id suffix included).
buildConfig = true
}
flavorDimensions += "profile"
@@ -49,6 +49,18 @@
android:exported="${serverComponentsExported}"
android:foregroundServiceType="dataSync"
android:process=":mglsrv" />
<!-- P11 B1 (CONTRACT-P11 B1): the connection broker for a same-device GL client in another
security context (Termux, adb shell, another app), reached by ExternalClientHelper - an
app_process under the client's uid - in an explicit broadcast. It checks the IPC token
against the running server's, connects twice to the server's own unix endpoint (which
SELinux lets only this app do) and hands the client ends back over Binder. EXPORTED IN
BOTH FLAVOURS: that is the point of it, and the token is the guard (a server started
without a token of at least 16 bytes hands out nothing). In the server's own process,
where the running service's endpoint and token are; it never starts the server. -->
<receiver
android:name=".ExternalClientBroker"
android:exported="true"
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
@@ -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,26 +133,56 @@ 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 {
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,
intent == null ? null : intent.getStringExtra("env"),
intent == null ? null : intent.getStringExtra("token"),
/*backend=*/null,
new File(getFilesDir(), "mgl.log").getAbsolutePath());
ServerEnvironment.applyServerRole(env, envExtra, token, /*backend=*/null,
new File(getFilesDir(), primary ? "mgl.log" : "mgl.broker.log").getAbsolutePath());
env.put("LD_LIBRARY_PATH", getApplicationInfo().nativeLibraryDir);
supervisor = builder.start();
final Process child = supervisor;
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, "supervisor exited " + child.waitFor());
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.
@@ -129,19 +190,18 @@ public final class MobileGLServerService extends Service {
} catch (Exception error) {
Log.e(TAG, "supervisor output failed", error);
} finally {
stopSelf(startId);
if (primary) stopSelf(startId);
}
}, "mgl-supervisor-log").start();
} catch (Exception error) {
Log.e(TAG, "cannot start TCP supervisor", error);
stopSelf(startId);
}
return START_NOT_STICKY;
}, 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;