mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-11-07 01:53:24 +09:00
[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:
@@ -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=<token> CLASSPATH=<server APK> app_process / top.mobilegl.plugin.ExternalClientHelper \
|
||||
* [--server <package>] [--timeout-ms <n>] [--] <program> [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;
|
||||
|
||||
Reference in New Issue
Block a user