feat: support launching privd via Shizuku

This commit is contained in:
Benjamin Wiegand
2026-03-04 15:09:27 -08:00
parent ef63082d73
commit a2ad208cc0
18 changed files with 772 additions and 145 deletions
+6
View File
@@ -18,6 +18,10 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildFeatures {
aidl = true
}
buildTypes {
release {
isMinifyEnabled = false
@@ -41,6 +45,8 @@ dependencies {
implementation(libs.bouncycastle)
implementation(libs.localbroadcastmanager)
implementation(libs.preference)
implementation(libs.shizuku.api)
implementation(libs.shizuku.provider)
testImplementation(libs.junit)
androidTestImplementation(libs.ext.junit)
androidTestImplementation(libs.espresso.core)
+10 -1
View File
@@ -109,13 +109,22 @@
<receiver
android:name=".PrivdConnectionReceiver"
android:exported="true"
tools:ignore="ExportedReceiver"
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL"
>
<intent-filter>
<action android:name="io.benwiegand.projection.geargrinder.BIND_PRIVD" />
</intent-filter>
</receiver>
<provider
android:name="rikka.shizuku.ShizukuProvider"
android:authorities="${applicationId}.shizuku"
android:multiprocess="false"
android:enabled="true"
android:exported="true"
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL"
/>
</application>
</manifest>
@@ -0,0 +1,8 @@
package io.benwiegand.projection.geargrinder;
interface IShizukuUserService {
void execPrivd(String scriptPath, in Map<String, String> env) = 1;
void killPrivd() = 2;
void destroy() = 16777114;
}
@@ -1,8 +1,10 @@
package io.benwiegand.projection.geargrinder;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.PendingIntent;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.hardware.usb.UsbAccessory;
import android.hardware.usb.UsbManager;
import android.media.projection.MediaProjectionConfig;
@@ -17,10 +19,13 @@ import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import rikka.shizuku.Shizuku;
public class ConnectionRequestActivity extends AppCompatActivity {
private static final String TAG = ConnectionRequestActivity.class.getSimpleName();
public static final String INTENT_ACTION_REQUEST_MEDIA_PROJECTION = "io.benwiegand.projection.geargrinder.REQUEST_MEDIA_PROJECTION";
public static final String INTENT_ACTION_REQUEST_SHIZUKU = "io.benwiegand.projection.geargrinder.REQUEST_SHIZUKU";
private static final String INTENT_ACTION_USB_PERMISSION_RESULT = "io.benwiegand.projection.geargrinder.USB_PERMISSION";
@@ -29,9 +34,18 @@ public class ConnectionRequestActivity extends AppCompatActivity {
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(new View(this));
Shizuku.addRequestPermissionResultListener(this::onShizukuPermissionResult);
onIntent(getIntent());
}
@Override
protected void onDestroy() {
super.onDestroy();
Shizuku.removeRequestPermissionResultListener(this::onShizukuPermissionResult);
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
@@ -86,6 +100,45 @@ public class ConnectionRequestActivity extends AppCompatActivity {
requestMediaProjection();
return; // not done yet
}
case INTENT_ACTION_REQUEST_SHIZUKU -> {
if (Shizuku.isPreV11()) {
Log.e(TAG, "shizuku app too old");
new AlertDialog.Builder(this)
.setTitle(R.string.shizuku_permission_request)
.setMessage(R.string.shizuku_too_old)
.setPositiveButton(R.string.close_button, (d, i) -> finish())
.setCancelable(false)
.show();
return;
}
if (Shizuku.getBinder() == null) {
Log.e(TAG, "shizuku binder is null. is shizuku running?");
new AlertDialog.Builder(this)
.setTitle(R.string.shizuku_permission_request)
.setMessage(R.string.shizuku_not_running)
.setPositiveButton(R.string.close_button, (d, i) -> finish())
.setCancelable(false)
.show();
return;
}
if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) {
Log.i(TAG, "shizuku already granted: uid = " + Shizuku.getUid());
finish();
} else if (Shizuku.shouldShowRequestPermissionRationale()) {
new AlertDialog.Builder(this)
.setTitle(R.string.shizuku_permission_request)
.setMessage(R.string.shizuku_permission_rationale)
.setPositiveButton(R.string.grant_permission_button, (d, i) ->
Shizuku.requestPermission(69))
.setNegativeButton(R.string.not_now_button, (d, i) -> finish())
.setCancelable(false)
.show();
} else {
Shizuku.requestPermission(69);
}
}
}
finish();
@@ -107,6 +160,19 @@ public class ConnectionRequestActivity extends AppCompatActivity {
}
);
private void onShizukuPermissionResult(int requestCode, int result) {
if (result != PackageManager.PERMISSION_GRANTED) {
Log.e(TAG, "shizuku permission denied");
finish();
return;
}
Log.i(TAG, "shizuku granted: uid = " + Shizuku.getUid());
finish();
}
private void connectToUsbHeadunit() {
Log.i(TAG, "starting connection service");
startService(new Intent(this, ConnectionService.class)
@@ -15,7 +15,8 @@ import android.util.Log;
import java.io.IOException;
import java.util.Arrays;
import io.benwiegand.projection.geargrinder.privileged.RootPrivdLauncher;
import io.benwiegand.projection.geargrinder.settings.SettingsManager;
import io.benwiegand.projection.geargrinder.privileged.PrivdLauncher;
public class PrivdConnectionReceiver extends BroadcastReceiver {
private static final String TAG = PrivdConnectionReceiver.class.getSimpleName();
@@ -31,8 +32,12 @@ public class PrivdConnectionReceiver extends BroadcastReceiver {
if (suppliedToken == null) return;
if (suppliedToken.length != TOKEN_LENGTH) return;
SettingsManager settings = new SettingsManager(context);
PrivdLauncher launcher = PrivdLauncher.createForPrivilegeMode(settings.getPrivilegeMode(), context);
if (launcher == null) return;
try {
byte[] token = RootPrivdLauncher.readToken(context);
byte[] token = launcher.readToken();
if (token == null) return;
if (!Arrays.equals(token, suppliedToken)) return;
} catch (IOException e) {
@@ -22,7 +22,10 @@ import java.util.concurrent.TimeoutException;
import java.util.function.Consumer;
import io.benwiegand.projection.geargrinder.callback.IPCConnectionListener;
import io.benwiegand.projection.geargrinder.privileged.RootPrivdLauncher;
import io.benwiegand.projection.geargrinder.settings.PrivilegeMode;
import io.benwiegand.projection.geargrinder.settings.SettingsManager;
import io.benwiegand.projection.geargrinder.privileged.PrivdLauncher;
import io.benwiegand.projection.geargrinder.privileged.ShizukuPrivdLauncher;
import io.benwiegand.projection.libprivd.IPrivd;
public class PrivdService extends Service {
@@ -35,7 +38,7 @@ public class PrivdService extends Service {
private final List<IPCConnectionListener> ipcConnectionListeners = new LinkedList<>();
private RootPrivdLauncher privdLauncher;
private PrivdLauncher privdLauncher = null;
private IPrivd privd = null;
private boolean launchInProgress = false;
@@ -43,7 +46,6 @@ public class PrivdService extends Service {
public void onCreate() {
super.onCreate();
Log.d(TAG, "onCreate");
privdLauncher = new RootPrivdLauncher(this);
tryLaunchPrivd();
}
@@ -54,7 +56,8 @@ public class PrivdService extends Service {
Log.d(TAG, "onDestroy");
synchronized (lock) {
privd = null;
privdLauncher.destroy();
if (privdLauncher != null)
privdLauncher.destroy();
}
}
@@ -115,7 +118,38 @@ public class PrivdService extends Service {
if (launchInProgress) return;
try {
privdLauncher.launchRoot();
if (privdLauncher == null) {
SettingsManager settings = new SettingsManager(this);
PrivilegeMode privilegeMode = settings.getPrivilegeMode();
Log.d(TAG, "getting launcher for privilege mode: " + privilegeMode);
privdLauncher = PrivdLauncher.createForPrivilegeMode(privilegeMode, this);
if (privdLauncher == null)
throw new IllegalStateException("privd not needed for current privilege mode");
}
if (privdLauncher instanceof ShizukuPrivdLauncher shizukuPrivdLauncher) shizukuPrivdLauncher
.checkShizukuPermission()
.filter(r -> r)
.ifPresentOrElse(
r -> Log.i(TAG, "shizuku connected and permission granted"),
() -> {
Log.e(TAG, "shizuku not connected or permission denied");
startActivity(new Intent(this, ConnectionRequestActivity.class)
.setAction(ConnectionRequestActivity.INTENT_ACTION_REQUEST_SHIZUKU)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
}
);
privdLauncher.setErrorListener(t -> {
synchronized (lock) {
if (isPrivdConnected() || !launchInProgress) return;
launchInProgress = false;
onPrivdLaunchFailure(t);
}
});
privdLauncher.launch();
handler.postDelayed(() -> {
synchronized (lock) {
if (isPrivdConnected() || !launchInProgress) return;
@@ -0,0 +1,179 @@
package io.benwiegand.projection.geargrinder.privileged;
import static io.benwiegand.projection.libprivd.ipc.IPCConstants.ENV_TOKEN_PATH;
import static io.benwiegand.projection.libprivd.ipc.IPCConstants.TOKEN_LENGTH;
import android.content.Context;
import android.util.Log;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Random;
import java.util.function.Consumer;
import io.benwiegand.projection.geargrinder.settings.PrivilegeMode;
import io.benwiegand.projection.geargrinder.util.ShellUtil;
public abstract class PrivdLauncher {
private static final String TAG = PrivdLauncher.class.getSimpleName();
protected static final String PRIVD_FILE_NAME = "privd.jar";
protected static final String LAUNCH_SCRIPT_FILE_NAME = "launch-privd.sh";
public static final String TOKEN_FILE_NAME = "privd-token.bin";
protected final Context context;
protected final File daemonFile;
protected final File launchScriptFile;
protected final File tokenFile;
private boolean init = false;
protected byte[] token = null;
protected Consumer<Throwable> errorListener = null;
public PrivdLauncher(Context context, File daemonFile, File launchScriptFile, File tokenFile) {
this.context = context;
this.daemonFile = daemonFile;
this.launchScriptFile = launchScriptFile;
this.tokenFile = tokenFile;
}
public abstract void destroy();
public abstract void launch() throws Throwable;
public void setErrorListener(Consumer<Throwable> errorListener) {
this.errorListener = errorListener;
}
protected void onError(Throwable throwable) {
if (errorListener == null) return;
errorListener.accept(throwable);
}
protected void init() throws IOException {
if (init) return;
Log.i(TAG, "init privd launcher");
copyDaemon();
generateToken();
generateScript();
init = true;
}
protected void copyDaemon() throws IOException {
if (daemonFile.isFile()) {
Log.d(TAG, "deleting " + daemonFile);
if (!daemonFile.delete()) throw new IOException("failed to delete existing copy of the daemon");
}
Log.v(TAG, "copying " + PRIVD_FILE_NAME + " to " + daemonFile);
try (InputStream is = context.getAssets().open(PRIVD_FILE_NAME);
FileOutputStream os = new FileOutputStream(daemonFile)) {
int len;
byte[] buffer = new byte[4096];
while ((len = is.read(buffer)) >= 0)
os.write(buffer, 0, len);
}
}
protected void generateToken() {
try {
byte[] newToken = new byte[TOKEN_LENGTH];
Log.d(TAG, "generating random " + newToken.length + " byte token");
Random random = SecureRandom.getInstanceStrong();
random.nextBytes(newToken);
Log.v(TAG, "saving token to " + tokenFile);
try (FileOutputStream os = new FileOutputStream(tokenFile)) {
os.write(newToken);
}
token = newToken;
} catch (Throwable t) {
Log.e(TAG, "failed to generate token", t);
throw new RuntimeException(t);
}
}
private void generateScript() throws IOException {
String script = """
#!/bin/sh
set -e
# generated vars
""";
script += "INIT_JAR_PATH=" + ShellUtil.wrapSingleQuote(daemonFile.getAbsolutePath()) + "\n";
script += "export " + ENV_TOKEN_PATH + "=" + ShellUtil.wrapSingleQuote(tokenFile.getAbsolutePath()) + "\n";
script += """
# end generated vars
PRIVD_NAME=Geargrinder-privd
EXEC_JAR_PATH="/data/local/tmp/geargrinder-privd.jar"
if ! [[ -f "$INIT_JAR_PATH" ]]; then
echo "file not found: $INIT_JAR_PATH"
exit 1
fi
echo "preparing"
rm -vf "$EXEC_JAR_PATH"
cp -v "$INIT_JAR_PATH" "$EXEC_JAR_PATH"
chown -v "`id -u`:`id -g`" "$EXEC_JAR_PATH"
chmod -v 0400 "$EXEC_JAR_PATH"
export CLASSPATH="$EXEC_JAR_PATH"
set +e
echo "launching $PRIVD_NAME as $USER (`id -u`)"
app_process /system/bin --nice-name=$PRIVD_NAME io.benwiegand.projection.geargrinder.privd.Main
exit_code=$?
echo "app_process exited with code $exit_code"
exit $exit_code
""";
Log.v(TAG, "saving launch script to " + launchScriptFile);
try (FileOutputStream os = new FileOutputStream(launchScriptFile)) {
os.write(script.getBytes(StandardCharsets.UTF_8));
}
}
public byte[] readToken() throws IOException {
if (!tokenFile.isFile()) return null;
try (FileInputStream is = new FileInputStream(tokenFile)) {
byte[] token = new byte[TOKEN_LENGTH];
int offset = 0;
int len;
while (offset < TOKEN_LENGTH) {
len = is.read(token, offset, TOKEN_LENGTH - offset);
if (len < 0) {
Log.e(TAG, "stored token too short (" + len + " / " + TOKEN_LENGTH + " bytes): " + tokenFile);
return null;
}
offset += len;
}
return token;
}
}
public static PrivdLauncher createForPrivilegeMode(PrivilegeMode mode, Context context) {
return switch (mode) {
case NO_ROOT -> null;
case SHIZUKU -> new ShizukuPrivdLauncher(context);
case ROOT -> new RootPrivdLauncher(context);
};
}
}
@@ -1,80 +1,53 @@
package io.benwiegand.projection.geargrinder.privileged;
import static io.benwiegand.projection.libprivd.ipc.IPCConstants.ENV_TOKEN;
import static io.benwiegand.projection.libprivd.ipc.IPCConstants.ENV_TOKEN_PATH;
import static io.benwiegand.projection.libprivd.ipc.IPCConstants.TOKEN_LENGTH;
import android.content.Context;
import android.util.Base64;
import android.util.Log;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Random;
import io.benwiegand.projection.geargrinder.util.ShellUtil;
public class RootPrivdLauncher {
public class RootPrivdLauncher extends PrivdLauncher {
private static final String TAG = RootPrivdLauncher.class.getSimpleName();
private static final String PRIVD_FILE_NAME = "privd.jar";
private static final String LAUNCH_SCRIPT_FILE_NAME = "launch-privd.sh";
public static final String TOKEN_FILE_NAME = "privd-token.bin";
private final Context context;
private final File daemonFile;
private final File launchScriptFile;
private final File tokenFile;
private boolean init = false;
private Process rootProcess = null;
private byte[] token = null;
public RootPrivdLauncher(Context context) {
this.context = context;
daemonFile = context.getCodeCacheDir().toPath().resolve(PRIVD_FILE_NAME).toFile();
launchScriptFile = context.getCodeCacheDir().toPath().resolve(LAUNCH_SCRIPT_FILE_NAME).toFile();
tokenFile = context.getFilesDir().toPath().resolve(TOKEN_FILE_NAME).toFile();
// TODO: for launching non-root (less secure)
// daemonFile = context.getExternalFilesDir(null).toPath().resolve(PRIVD_FILE_NAME).toFile();
// launchScriptFile = context.getExternalFilesDir(null).toPath().resolve(LAUNCH_SCRIPT_FILE_NAME).toFile();
// tokenFile = context.getExternalFilesDir(null).toPath().resolve(TOKEN_FILE_NAME).toFile();
super(
context,
context.getCodeCacheDir().toPath().resolve(PRIVD_FILE_NAME).toFile(),
context.getCodeCacheDir().toPath().resolve(LAUNCH_SCRIPT_FILE_NAME).toFile(),
context.getFilesDir().toPath().resolve(TOKEN_FILE_NAME).toFile()
);
}
public void destroy() {
killRootProcess();
killProcess();
}
private void init() throws IOException {
Log.i(TAG, "init privd launcher");
copyDaemon();
generateToken();
generateScript();
init = true;
}
public void launchRoot() throws IOException {
if (!init) init();
public void launch() throws IOException {
init();
Log.i(TAG, "launching privd as root");
killRootProcess();
executeDaemonRoot();
killProcess();
executeDaemon();
}
private void killRootProcess() {
private void killProcess() {
if (rootProcess == null) return;
Log.d(TAG, "killing privd root process");
rootProcess.destroyForcibly();
rootProcess = null;
}
private void executeDaemonRoot() throws IOException {
private void executeDaemon() throws IOException {
assert rootProcess == null;
String command = "sh " + ShellUtil.wrapSingleQuote(launchScriptFile.getAbsolutePath());
@@ -85,108 +58,31 @@ public class RootPrivdLauncher {
processBuilder.environment().put(ENV_TOKEN, Base64.encodeToString(token, Base64.NO_WRAP));
rootProcess = processBuilder.start();
}
private void copyDaemon() throws IOException {
if (daemonFile.isFile()) {
Log.d(TAG, "deleting " + daemonFile);
if (!daemonFile.delete()) throw new IOException("failed to delete existing copy of the daemon");
}
new Thread(() -> {
try (InputStream is = rootProcess.getInputStream();
InputStream es = rootProcess.getErrorStream()) {
Log.v(TAG, "copying " + PRIVD_FILE_NAME + " to " + daemonFile);
try (InputStream is = context.getAssets().open(PRIVD_FILE_NAME);
FileOutputStream os = new FileOutputStream(daemonFile)) {
int len;
byte[] buffer = new byte[4096];
while ((len = is.read(buffer)) >= 0)
os.write(buffer, 0, len);
}
}
Reader outReader = new InputStreamReader(is, StandardCharsets.UTF_8);
Reader errReader = new InputStreamReader(es, StandardCharsets.UTF_8);
private void generateToken() {
try {
byte[] newToken = new byte[TOKEN_LENGTH];
char[] buffer = new char[2048];
int len;
Log.d(TAG, "generating random " + newToken.length + " byte token");
Random random = SecureRandom.getInstanceStrong();
random.nextBytes(newToken);
while (rootProcess.isAlive()) {
while ((len = outReader.read(buffer)) > 0)
Log.v(TAG, "STDOUT: " + new String(buffer, 0, len));
Log.v(TAG, "saving token to " + tokenFile);
try (FileOutputStream os = new FileOutputStream(tokenFile)) {
os.write(newToken);
}
while ((len = errReader.read(buffer)) > 0)
Log.e(TAG, "STDERR: " + new String(buffer, 0, len));
token = newToken;
} catch (Throwable t) {
Log.e(TAG, "failed to generate token", t);
throw new RuntimeException(t);
}
}
private void generateScript() throws IOException {
String script = """
#!/bin/sh
set -e
# generated vars
""";
script += "INIT_JAR_PATH=" + ShellUtil.wrapSingleQuote(daemonFile.getAbsolutePath()) + "\n";
script += "export " + ENV_TOKEN_PATH + "=" + ShellUtil.wrapSingleQuote(tokenFile.getAbsolutePath()) + "\n";
script += """
# end generated vars
PRIVD_NAME=Geargrinder-privd
EXEC_JAR_PATH="/data/local/tmp/geargrinder-privd.jar"
if ! [[ -f "$INIT_JAR_PATH" ]]; then
echo "file not found: $INIT_JAR_PATH"
exit 1
fi
cp -v "$INIT_JAR_PATH" "$EXEC_JAR_PATH"
chown -v "`id -u`:`id -g`" "$EXEC_JAR_PATH"
chmod -v 0400 "$EXEC_JAR_PATH"
export CLASSPATH="$EXEC_JAR_PATH"
set +e
echo "launching $PRIVD_NAME as $USER (`id -u`)"
app_process /system/bin --nice-name=$PRIVD_NAME io.benwiegand.projection.geargrinder.privd.Main
exit_code=$?
echo "app_process exited with code $exit_code"
exit $exit_code
""";
Log.v(TAG, "saving launch script to " + launchScriptFile);
try (FileOutputStream os = new FileOutputStream(launchScriptFile)) {
os.write(script.getBytes(StandardCharsets.UTF_8));
}
}
public static byte[] readToken(Context context) throws IOException {
File tokenFile = context.getFilesDir().toPath().resolve(TOKEN_FILE_NAME).toFile();
if (!tokenFile.isFile()) return null;
try (FileInputStream is = new FileInputStream(tokenFile)) {
byte[] token = new byte[TOKEN_LENGTH];
int offset = 0;
int len;
while (offset < TOKEN_LENGTH) {
len = is.read(token, offset, TOKEN_LENGTH - offset);
if (len < 0) {
Log.e(TAG, "stored token too short (" + len + " / " + TOKEN_LENGTH + " bytes): " + tokenFile);
return null;
}
offset += len;
Log.e(TAG, "TERMINATED: " + rootProcess.exitValue());
} catch (IOException e) {
Log.w(TAG, "root process loop threw", e);
}
return token;
}
}).start();
}
}
@@ -0,0 +1,136 @@
package io.benwiegand.projection.geargrinder.privileged;
import static io.benwiegand.projection.libprivd.ipc.IPCConstants.ENV_TOKEN;
import android.content.Context;
import android.os.RemoteException;
import android.util.Base64;
import android.util.Log;
import java.io.File;
import java.io.IOException;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeoutException;
import io.benwiegand.projection.geargrinder.IShizukuUserService;
import io.benwiegand.projection.geargrinder.service.GeargrinderServiceConnector;
import moe.shizuku.server.IShizukuService;
import rikka.shizuku.Shizuku;
public class ShizukuPrivdLauncher extends PrivdLauncher implements GeargrinderServiceConnector.ConnectionListener {
private static final String TAG = ShizukuPrivdLauncher.class.getSimpleName();
private static final long SHIZUKU_CONNECTION_TIMEOUT = 10000;
private final Object lock = new Object();
private final GeargrinderServiceConnector connector;
private static File requireExternalFilesDir(Context context) {
File file = context.getExternalFilesDir(null);
if (file == null) throw new UnsupportedOperationException("an external files directory is required");
return file;
}
public ShizukuPrivdLauncher(Context context) {
super(
context,
requireExternalFilesDir(context).toPath().resolve(PRIVD_FILE_NAME).toFile(),
requireExternalFilesDir(context).toPath().resolve(LAUNCH_SCRIPT_FILE_NAME).toFile(),
requireExternalFilesDir(context).toPath().resolve(TOKEN_FILE_NAME).toFile()
);
connector = new GeargrinderServiceConnector(TAG, context, this);
Shizuku.addBinderReceivedListenerSticky(this::onBinderReceived);
}
@Override
public void destroy() {
Shizuku.removeBinderReceivedListener(this::onBinderReceived);
connector.getShizukuUserService().ifPresent(this::killProcess);
connector.destroy();
}
private void onBinderReceived() {
Log.i(TAG, "Shizuku binder received");
checkShizukuPermission()
.filter(p -> p)
.ifPresent(permission -> connector.bindShizukuUserService());
}
@Override
public void onShizukuUserServiceConnected(IShizukuUserService service) {
Log.i(TAG, "Shizuku user service connected");
synchronized (lock) {
lock.notifyAll();
}
}
private IShizukuUserService waitForShizukuUserService() throws InterruptedException, TimeoutException {
synchronized (lock) {
Optional<IShizukuUserService> serviceOptional = connector.getShizukuUserService();
if (serviceOptional.isPresent()) return serviceOptional.get();
Log.i(TAG, "waiting for Shizuku user service");
lock.wait(SHIZUKU_CONNECTION_TIMEOUT);
serviceOptional = connector.getShizukuUserService();
return serviceOptional
.orElseThrow(() -> new TimeoutException("Shizuku user service not connected before timeout"));
}
}
@Override
public void launch() throws IOException, RemoteException, InterruptedException, TimeoutException {
init();
Log.i(TAG, "launching privd as shizuku");
new Thread(() -> {
try {
IShizukuUserService userService = waitForShizukuUserService();
Log.i(TAG, "shizuku connected");
Log.d(TAG, "shizuku version: " + Shizuku.getVersion());
Log.d(TAG, "shizuku UID: " + Shizuku.getUid());
Log.d(TAG, "SELinux context: " + Shizuku.getSELinuxContext());
executeDaemon(userService);
} catch (Throwable t) {
Log.e(TAG, "shizuku privd launch failed", t);
onError(t);
}
}).start();
}
private void executeDaemon(IShizukuUserService userService) throws RemoteException {
Log.v(TAG, "requesting privd execution");
userService.execPrivd(launchScriptFile.getAbsolutePath(), Map.of(
ENV_TOKEN, Base64.encodeToString(token, Base64.NO_WRAP)
));
}
private void killProcess(IShizukuUserService userService) {
Log.d(TAG, "requesting to kill privd shizuku process");
try {
userService.killPrivd();
} catch (RemoteException e) {
Log.w(TAG, "failed to kill process", e);
}
}
private Optional<IShizukuService> getShizukuService() {
return Optional.ofNullable(IShizukuService.Stub.asInterface(Shizuku.getBinder()));
}
public Optional<Boolean> checkShizukuPermission() {
return getShizukuService()
.flatMap(shizuku -> {
try {
return Optional.of(shizuku.checkSelfPermission());
} catch (RemoteException e) {
Log.e(TAG, "remote exception while checking shizuku permission", e);
return Optional.empty();
}
});
}
}
@@ -0,0 +1,78 @@
package io.benwiegand.projection.geargrinder.privileged;
import android.os.RemoteException;
import android.util.Log;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import io.benwiegand.projection.geargrinder.IShizukuUserService;
public class ShizukuUserService extends IShizukuUserService.Stub {
private static final String TAG = ShizukuUserService.class.getSimpleName();
private Process privdProcess = null;
@Override
public void execPrivd(String scriptPath, Map<String, String> env) throws RemoteException {
if (privdProcess != null) killPrivd();
try {
Log.v(TAG, "executing privd launcher script at: " + scriptPath);
ProcessBuilder builder = new ProcessBuilder("sh", scriptPath);
builder.environment().putAll(env);
privdProcess = builder.start();
new Thread(() -> {
try (InputStream is = privdProcess.getInputStream();
InputStream es = privdProcess.getErrorStream()) {
Reader outReader = new InputStreamReader(is, StandardCharsets.UTF_8);
Reader errReader = new InputStreamReader(es, StandardCharsets.UTF_8);
char[] buffer = new char[2048];
int len;
while (privdProcess.isAlive()) {
while ((len = outReader.read(buffer)) > 0)
Log.v(TAG, "STDOUT: " + new String(buffer, 0, len));
while ((len = errReader.read(buffer)) > 0)
Log.e(TAG, "STDERR: " + new String(buffer, 0, len));
}
Log.e(TAG, "TERMINATED: " + privdProcess.exitValue());
} catch (IOException e) {
Log.w(TAG, "shizuku process loop threw", e);
}
}).start();
} catch (IOException e) {
Log.e(TAG, "failed to start privd");
throw new RuntimeException(e);
}
}
@Override
public void killPrivd() throws RemoteException {
if (privdProcess == null) return;
if (!privdProcess.isAlive()) {
privdProcess = null;
return;
}
Log.i(TAG, "killing existing privd process");
privdProcess.destroyForcibly();
privdProcess = null;
}
@Override
public void destroy() throws RemoteException {
killPrivd();
}
}
@@ -12,10 +12,13 @@ import java.util.Optional;
import io.benwiegand.projection.geargrinder.AccessibilityInputService;
import io.benwiegand.projection.geargrinder.ConnectionService;
import io.benwiegand.projection.geargrinder.IShizukuUserService;
import io.benwiegand.projection.geargrinder.PackageService;
import io.benwiegand.projection.geargrinder.PrivdService;
import io.benwiegand.projection.geargrinder.ProjectionActivity;
import io.benwiegand.projection.geargrinder.makeshiftbind.MakeshiftServiceConnection;
import io.benwiegand.projection.geargrinder.privileged.ShizukuUserService;
import rikka.shizuku.Shizuku;
public class GeargrinderServiceConnector extends MakeshiftServiceConnection {
private static final String PACKAGE_NAME = "io.benwiegand.projection.geargrinder";
@@ -25,18 +28,28 @@ public class GeargrinderServiceConnector extends MakeshiftServiceConnection {
private static final ComponentName PRIVD_SERVICE_COMPONENT = new ComponentName(PACKAGE_NAME, PrivdService.class.getName());
private static final ComponentName PACKAGE_SERVICE_COMPONENT = new ComponentName(PACKAGE_NAME, PackageService.class.getName());
private static final ComponentName CONNECTION_SERVICE_COMPONENT = new ComponentName(PACKAGE_NAME, ConnectionService.class.getName());
private static final ComponentName SHIZUKU_USER_SERVICE_COMPONENT = new ComponentName(PACKAGE_NAME, ShizukuUserService.class.getName());
private static final Shizuku.UserServiceArgs SHIZUKU_ARGS = new Shizuku.UserServiceArgs(SHIZUKU_USER_SERVICE_COMPONENT)
.tag("geargrinder-shizuku-service")
.processNameSuffix("shizuku-service")
.daemon(false);
private final Map<ComponentName, IBinder> binderMap = new HashMap<>();
private final String tag;
private final Context context;
private final ConnectionListener listener;
private boolean shizukuBound = false;
private boolean contextBound = false;
public interface ConnectionListener {
default void onAccessibilityServiceConnected(AccessibilityInputService.ServiceBinder binder) {}
default void onProjectionActivityConnected(ProjectionActivity.ActivityBinder binder) {}
default void onPrivdServiceConnected(PrivdService.ServiceBinder binder) {}
default void onPackageServiceConnected(PackageService.ServiceBinder binder) {}
default void onConnectionServiceConnected(ConnectionService.ServiceBinder binder) {}
default void onShizukuUserServiceConnected(IShizukuUserService service) {}
}
public GeargrinderServiceConnector(String tag, Context context, ConnectionListener listener) {
@@ -48,7 +61,9 @@ public class GeargrinderServiceConnector extends MakeshiftServiceConnection {
@Override
public void destroy() {
super.destroy();
context.unbindService(this);
if (contextBound) context.unbindService(this);
if (shizukuBound && Shizuku.getBinder() != null)
Shizuku.unbindUserService(SHIZUKU_ARGS, this, true);
}
private void makeshiftBind(ComponentName component) {
@@ -57,6 +72,7 @@ public class GeargrinderServiceConnector extends MakeshiftServiceConnection {
private void realBind(ComponentName component, int flags) {
context.bindService(new Intent().setComponent(component), this, flags);
contextBound = true;
}
private Optional<IBinder> getBinder(ComponentName componentName) {
@@ -109,6 +125,16 @@ public class GeargrinderServiceConnector extends MakeshiftServiceConnection {
.map(b -> (ConnectionService.ServiceBinder) b);
}
public void bindShizukuUserService() {
Shizuku.bindUserService(SHIZUKU_ARGS, this);
shizukuBound = true;
}
public Optional<IShizukuUserService> getShizukuUserService() {
return getBinder(SHIZUKU_USER_SERVICE_COMPONENT)
.map(IShizukuUserService.Stub::asInterface);
}
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
@@ -124,6 +150,8 @@ public class GeargrinderServiceConnector extends MakeshiftServiceConnection {
listener.onPackageServiceConnected((PackageService.ServiceBinder) service);
} else if (name.equals(CONNECTION_SERVICE_COMPONENT)) {
listener.onConnectionServiceConnected((ConnectionService.ServiceBinder) service);
} else if (name.equals(SHIZUKU_USER_SERVICE_COMPONENT)) {
listener.onShizukuUserServiceConnected(IShizukuUserService.Stub.asInterface(service));
} else {
Log.wtf(tag, "unhandled component: " + name);
assert false;
@@ -0,0 +1,28 @@
package io.benwiegand.projection.geargrinder.settings;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Pair;
import java.util.List;
import io.benwiegand.projection.geargrinder.R;
public enum OperationalMode {
AUDIO_ONLY,
SCREEN_MIRRORING,
GEARGRINDER_PROJECTION;
public static OperationalMode read(Context context, SharedPreferences prefs) {
return SettingsManager.enumForPref(
context, prefs,
R.string.key_operational_mode,
R.string.operational_mode_screen_mirroring,
List.of(
Pair.create(R.string.operational_mode_audio_only, AUDIO_ONLY),
Pair.create(R.string.operational_mode_screen_mirroring, SCREEN_MIRRORING),
Pair.create(R.string.operational_mode_geargrinder_projection, GEARGRINDER_PROJECTION)
)
);
}
}
@@ -0,0 +1,28 @@
package io.benwiegand.projection.geargrinder.settings;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Pair;
import java.util.List;
import io.benwiegand.projection.geargrinder.R;
public enum PrivilegeMode {
NO_ROOT,
SHIZUKU,
ROOT;
public static PrivilegeMode read(Context context, SharedPreferences prefs) {
return SettingsManager.enumForPref(
context, prefs,
R.string.key_privilege_mode,
R.string.privilege_mode_no_root,
List.of(
Pair.create(R.string.privilege_mode_no_root, NO_ROOT),
Pair.create(R.string.privilege_mode_shizuku, SHIZUKU),
Pair.create(R.string.privilege_mode_root, ROOT)
)
);
}
}
@@ -0,0 +1,50 @@
package io.benwiegand.projection.geargrinder.settings;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import android.util.Pair;
import java.util.List;
public class SettingsManager {
private static final String TAG = SettingsManager.class.getSimpleName();
private static final String PREFERENCE_NAME = "io.benwiegand.projection.geargrinder_preferences";
private final Context context;
private final SharedPreferences prefs;
public SettingsManager(Context context) {
this.context = context;
prefs = context.getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE);
}
public OperationalMode getOperationalMode() {
return OperationalMode.read(context, prefs);
}
public PrivilegeMode getPrivilegeMode() {
return PrivilegeMode.read(context, prefs);
}
public static <T> T enumForPref(Context context, SharedPreferences prefs, int key, int defaultValue, List<Pair<Integer, T>> mapping) {
String value = prefs.getString(
context.getString(key),
context.getString(defaultValue));
T defaultMapping = null;
for (Pair<Integer, T> entry : mapping) {
if (entry.first == defaultValue) defaultMapping = entry.second;
if (!context.getString(entry.first).equals(value)) continue;
return entry.second;
}
if (defaultMapping == null)
Log.wtf(TAG, "default value not present in mappings");
Log.wtf(TAG, "unhandled value for pref " + context.getString(key) + ": " + value);
assert false;
return defaultMapping;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="privilege_mode_entries">
<!-- <item>no root</item>-->
<item>Shizuku</item>
<item>root</item>
</string-array>
<string-array name="operational_mode_entries">
<item>audio only</item>
<item>screen mirroring</item>
<item>Geargrinder UI</item>
</string-array>
<string-array translatable="false" name="privilege_mode_values">
<!-- <item>@string/privilege_mode_no_root</item>-->
<item>@string/privilege_mode_shizuku</item>
<item>@string/privilege_mode_root</item>
</string-array>
<string-array translatable="false" name="operational_mode_values">
<item>@string/operational_mode_audio_only</item>
<item>@string/operational_mode_screen_mirroring</item>
<item>@string/operational_mode_geargrinder_projection</item>
</string-array>
<string translatable="false" name="operational_mode_audio_only">audio_only</string>
<string translatable="false" name="operational_mode_screen_mirroring">screen_mirroring</string>
<string translatable="false" name="operational_mode_geargrinder_projection">geargrinder_projection</string>
<string translatable="false" name="privilege_mode_no_root">no_root</string>
<string translatable="false" name="privilege_mode_shizuku">shizuku</string>
<string translatable="false" name="privilege_mode_root">root</string>
<string translatable="false" name="key_privilege_mode">privilege_mode</string>
<string translatable="false" name="key_operational_mode">operational_mode</string>
</resources>
+6
View File
@@ -37,5 +37,11 @@
<string name="settings_title">Settings</string>
<string name="debug_title">Debug</string>
<string name="connect_your_car_over_usb">Connect your car over USB to start Geargrinder.</string>
<string name="privilege_mode_pref_name">Privilege mode</string>
<string name="operational_mode_pref_name">Operational mode</string>
<string name="shizuku_permission_request">Shizuku permission</string>
<string name="shizuku_permission_rationale">Shizuku is used to access system APIs for a better user experience. Things like creating trusted virtual displays, injecting input events into said displays, etc.</string>
<string name="shizuku_not_running">Unable to connect to Shizuku, is Shizuku running?</string>
<string name="shizuku_too_old">Please update Shizuku.</string>
</resources>
@@ -2,6 +2,35 @@
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:android="http://schemas.android.com/apk/res/android">
<PreferenceCategory
app:title="operations"
app:iconSpaceReserved="false"
>
<!-- TODO: set to no_root by default when that's implemented -->
<ListPreference
app:key="@string/key_privilege_mode"
app:iconSpaceReserved="false"
app:title="@string/privilege_mode_pref_name"
app:entries="@array/privilege_mode_entries"
app:entryValues="@array/privilege_mode_values"
app:defaultValue="@string/privilege_mode_root"
app:useSimpleSummaryProvider="true"
/>
<!-- TODO: implement -->
<ListPreference
app:key="@string/key_operational_mode"
app:iconSpaceReserved="false"
app:title="@string/operational_mode_pref_name"
app:entries="@array/operational_mode_entries"
app:entryValues="@array/operational_mode_values"
app:defaultValue="@string/operational_mode_screen_mirroring"
app:useSimpleSummaryProvider="true"
app:isPreferenceVisible="false"
/>
</PreferenceCategory>
</PreferenceScreen>
+3
View File
@@ -8,6 +8,7 @@ material = "1.13.0"
bouncycastle = "1.45"
localbroadcastmanager = "1.1.0"
preference = "1.2.1"
shizuku = "13.1.5"
[libraries]
junit = { group = "junit", name = "junit", version.ref = "junit" }
@@ -18,6 +19,8 @@ material = { group = "com.google.android.material", name = "material", version.r
bouncycastle = { group = "org.bouncycastle", name = "bcprov-jdk16", version.ref = "bouncycastle" }
localbroadcastmanager = { group = "androidx.localbroadcastmanager", name = "localbroadcastmanager", version.ref = "localbroadcastmanager" }
preference = { group = "androidx.preference", name = "preference", version.ref = "preference" }
shizuku-api = { group = "dev.rikka.shizuku", name = "api", version.ref = "shizuku" }
shizuku-provider = { group = "dev.rikka.shizuku", name = "provider", version.ref = "shizuku" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }