diff --git a/app/src/main/assets/scrcpy-server.jar b/app/src/main/assets/scrcpy-server.jar
index 104abe3..b575242 100644
Binary files a/app/src/main/assets/scrcpy-server.jar and b/app/src/main/assets/scrcpy-server.jar differ
diff --git a/server/src/main/java/org/cagnulein/android_remote/CleanUp.java b/server/src/main/java/org/cagnulein/android_remote/CleanUp.java
new file mode 100644
index 0000000..51bd711
--- /dev/null
+++ b/server/src/main/java/org/cagnulein/android_remote/CleanUp.java
@@ -0,0 +1,150 @@
+package org.cagnulein.android_remote;
+
+import android.util.Log;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * Handle the cleanup of scrcpy, even if the main process is killed.
+ *
+ * This is useful to restore some state when scrcpy is closed, even on device disconnection (which kills the scrcpy process).
+ */
+public final class CleanUp {
+
+ private static final int MSG_TYPE_MASK = 0b11;
+ private static final int MSG_TYPE_RESTORE_STAY_ON = 0;
+ private static final int MSG_TYPE_DISABLE_SHOW_TOUCHES = 1;
+ private static final int MSG_TYPE_RESTORE_NORMAL_POWER_MODE = 2;
+ private static final int MSG_TYPE_POWER_OFF_SCREEN = 3;
+
+ private static final int MSG_PARAM_SHIFT = 2;
+
+ private final OutputStream out;
+
+ public CleanUp(OutputStream out) {
+ this.out = out;
+ }
+
+ public static CleanUp configure(int displayId) throws IOException {
+ String[] cmd = {"app_process", "/", CleanUp.class.getName(), String.valueOf(displayId)};
+
+ ProcessBuilder builder = new ProcessBuilder(cmd);
+ builder.environment().put("CLASSPATH", Server.SERVER_PATH);
+ Process process = builder.start();
+ return new CleanUp(process.getOutputStream());
+ }
+
+ private boolean sendMessage(int type, int param) {
+ assert (type & ~MSG_TYPE_MASK) == 0;
+ int msg = type | param << MSG_PARAM_SHIFT;
+ try {
+ out.write(msg);
+ out.flush();
+ return true;
+ } catch (IOException e) {
+ Log.w("Cleanup", "Could not configure cleanup (type=" + type + ", param=" + param + ")", e);
+ return false;
+ }
+ }
+
+ public boolean setRestoreStayOn(int restoreValue) {
+ // Restore the value (between 0 and 7), -1 to not restore
+ //
+ assert restoreValue >= -1 && restoreValue <= 7;
+ return sendMessage(MSG_TYPE_RESTORE_STAY_ON, restoreValue & 0b1111);
+ }
+
+ public boolean setDisableShowTouches(boolean disableOnExit) {
+ return sendMessage(MSG_TYPE_DISABLE_SHOW_TOUCHES, disableOnExit ? 1 : 0);
+ }
+
+ public boolean setRestoreNormalPowerMode(boolean restoreOnExit) {
+ return sendMessage(MSG_TYPE_RESTORE_NORMAL_POWER_MODE, restoreOnExit ? 1 : 0);
+ }
+
+ public boolean setPowerOffScreen(boolean powerOffScreenOnExit) {
+ return sendMessage(MSG_TYPE_POWER_OFF_SCREEN, powerOffScreenOnExit ? 1 : 0);
+ }
+
+ public static void unlinkSelf() {
+ try {
+ new File(Server.SERVER_PATH).delete();
+ } catch (Exception e) {
+ Ln.e("Could not unlink server", e);
+ }
+ }
+
+ public static void main(String... args) {
+ unlinkSelf();
+
+ int displayId = Integer.parseInt(args[0]);
+
+ int restoreStayOn = -1;
+ boolean disableShowTouches = false;
+ boolean restoreNormalPowerMode = false;
+ boolean powerOffScreen = false;
+
+ try {
+ // Wait for the server to die
+ int msg;
+ while ((msg = System.in.read()) != -1) {
+ int type = msg & MSG_TYPE_MASK;
+ int param = msg >> MSG_PARAM_SHIFT;
+ switch (type) {
+ case MSG_TYPE_RESTORE_STAY_ON:
+ restoreStayOn = param > 7 ? -1 : param;
+ break;
+ case MSG_TYPE_DISABLE_SHOW_TOUCHES:
+ disableShowTouches = param != 0;
+ break;
+ case MSG_TYPE_RESTORE_NORMAL_POWER_MODE:
+ restoreNormalPowerMode = param != 0;
+ break;
+ case MSG_TYPE_POWER_OFF_SCREEN:
+ powerOffScreen = param != 0;
+ break;
+ default:
+ Ln.w("Unexpected msg type: " + type);
+ break;
+ }
+ }
+ } catch (IOException e) {
+ // Expected when the server is dead
+ }
+
+ Ln.i("Cleaning up");
+
+ if (disableShowTouches) {
+ Ln.i("Disabling \"show touches\"");
+ try {
+ Settings.putValue(Settings.TABLE_SYSTEM, "show_touches", "0");
+ } catch (SettingsException e) {
+ Ln.e("Could not restore \"show_touches\"", e);
+ }
+ }
+
+ if (restoreStayOn != -1) {
+ Ln.i("Restoring \"stay awake\"");
+ try {
+ Settings.putValue(Settings.TABLE_GLOBAL, "stay_on_while_plugged_in", String.valueOf(restoreStayOn));
+ } catch (SettingsException e) {
+ Ln.e("Could not restore \"stay_on_while_plugged_in\"", e);
+ }
+ }
+
+ /* VIOLA
+ if (Device.isScreenOn()) {
+ if (powerOffScreen) {
+ Ln.i("Power off screen");
+ Device.powerOffScreen(displayId);
+ } else if (restoreNormalPowerMode) {
+ Ln.i("Restoring normal power mode");
+ Device.setScreenPowerMode(Device.POWER_MODE_NORMAL);
+ }
+ }*/
+
+ System.exit(0);
+ }
+}
diff --git a/server/src/main/java/org/cagnulein/android_remote/Command.java b/server/src/main/java/org/cagnulein/android_remote/Command.java
new file mode 100644
index 0000000..531670b
--- /dev/null
+++ b/server/src/main/java/org/cagnulein/android_remote/Command.java
@@ -0,0 +1,43 @@
+package org.cagnulein.android_remote;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Scanner;
+
+public final class Command {
+ private Command() {
+ // not instantiable
+ }
+
+ public static void exec(String... cmd) throws IOException, InterruptedException {
+ Process process = Runtime.getRuntime().exec(cmd);
+ int exitCode = process.waitFor();
+ if (exitCode != 0) {
+ throw new IOException("Command " + Arrays.toString(cmd) + " returned with value " + exitCode);
+ }
+ }
+
+ public static String execReadLine(String... cmd) throws IOException, InterruptedException {
+ String result = null;
+ Process process = Runtime.getRuntime().exec(cmd);
+ Scanner scanner = new Scanner(process.getInputStream());
+ if (scanner.hasNextLine()) {
+ result = scanner.nextLine();
+ }
+ int exitCode = process.waitFor();
+ if (exitCode != 0) {
+ throw new IOException("Command " + Arrays.toString(cmd) + " returned with value " + exitCode);
+ }
+ return result;
+ }
+
+ public static String execReadOutput(String... cmd) throws IOException, InterruptedException {
+ Process process = Runtime.getRuntime().exec(cmd);
+ String output = IO.toString(process.getInputStream());
+ int exitCode = process.waitFor();
+ if (exitCode != 0) {
+ throw new IOException("Command " + Arrays.toString(cmd) + " returned with value " + exitCode);
+ }
+ return output;
+ }
+}
diff --git a/server/src/main/java/org/cagnulein/android_remote/FakeContext.java b/server/src/main/java/org/cagnulein/android_remote/FakeContext.java
new file mode 100644
index 0000000..fd35fc4
--- /dev/null
+++ b/server/src/main/java/org/cagnulein/android_remote/FakeContext.java
@@ -0,0 +1,53 @@
+package org.cagnulein.android_remote;
+
+import android.annotation.TargetApi;
+import android.content.AttributionSource;
+import android.content.Context;
+import android.content.ContextWrapper;
+import android.os.Build;
+import android.os.Process;
+
+public final class FakeContext extends ContextWrapper {
+
+ public static final String PACKAGE_NAME = "com.android.shell";
+ public static final int ROOT_UID = 0; // Like android.os.Process.ROOT_UID, but before API 29
+
+ private static final FakeContext INSTANCE = new FakeContext();
+
+ public static FakeContext get() {
+ return INSTANCE;
+ }
+
+ private FakeContext() {
+ super(Workarounds.getSystemContext());
+ }
+
+ @Override
+ public String getPackageName() {
+ return PACKAGE_NAME;
+ }
+
+ @Override
+ public String getOpPackageName() {
+ return PACKAGE_NAME;
+ }
+
+ @TargetApi(Build.VERSION_CODES.S)
+ @Override
+ public AttributionSource getAttributionSource() {
+ AttributionSource.Builder builder = new AttributionSource.Builder(Process.SHELL_UID);
+ builder.setPackageName(PACKAGE_NAME);
+ return builder.build();
+ }
+
+ // @Override to be added on SDK upgrade for Android 14
+ @SuppressWarnings("unused")
+ public int getDeviceId() {
+ return 0;
+ }
+
+ @Override
+ public Context getApplicationContext() {
+ return this;
+ }
+}
diff --git a/server/src/main/java/org/cagnulein/android_remote/IO.java b/server/src/main/java/org/cagnulein/android_remote/IO.java
new file mode 100644
index 0000000..eeddc7a
--- /dev/null
+++ b/server/src/main/java/org/cagnulein/android_remote/IO.java
@@ -0,0 +1,58 @@
+package org.cagnulein.android_remote;
+
+import android.system.ErrnoException;
+import android.system.Os;
+import android.system.OsConstants;
+
+import com.genymobile.scrcpy.BuildConfig;
+
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.util.Scanner;
+
+public final class IO {
+ private IO() {
+ // not instantiable
+ }
+
+ public static void writeFully(FileDescriptor fd, ByteBuffer from) throws IOException {
+ // ByteBuffer position is not updated as expected by Os.write() on old Android versions, so
+ // count the remaining bytes manually.
+ // See .
+ int remaining = from.remaining();
+ while (remaining > 0) {
+ try {
+ int w = Os.write(fd, from);
+ if (BuildConfig.DEBUG && w < 0) {
+ // w should not be negative, since an exception is thrown on error
+ throw new AssertionError("Os.write() returned a negative value (" + w + ")");
+ }
+ remaining -= w;
+ } catch (ErrnoException e) {
+ if (e.errno != OsConstants.EINTR) {
+ throw new IOException(e);
+ }
+ }
+ }
+ }
+
+ public static void writeFully(FileDescriptor fd, byte[] buffer, int offset, int len) throws IOException {
+ writeFully(fd, ByteBuffer.wrap(buffer, offset, len));
+ }
+
+ public static String toString(InputStream inputStream) {
+ StringBuilder builder = new StringBuilder();
+ Scanner scanner = new Scanner(inputStream);
+ while (scanner.hasNextLine()) {
+ builder.append(scanner.nextLine()).append('\n');
+ }
+ return builder.toString();
+ }
+
+ public static boolean isBrokenPipe(IOException e) {
+ Throwable cause = e.getCause();
+ return cause instanceof ErrnoException && ((ErrnoException) cause).errno == OsConstants.EPIPE;
+ }
+}
diff --git a/server/src/main/java/org/cagnulein/android_remote/Server.java b/server/src/main/java/org/cagnulein/android_remote/Server.java
index b22887f..3fa15b5 100644
--- a/server/src/main/java/org/cagnulein/android_remote/Server.java
+++ b/server/src/main/java/org/cagnulein/android_remote/Server.java
@@ -1,17 +1,49 @@
package org.cagnulein.android_remote;
+import android.os.BatteryManager;
+
+import java.io.File;
import java.io.IOException;
public final class Server {
private static String ip = null;
+ public static final String SERVER_PATH;
private Server() {
// not instantiable
}
+ static {
+ String[] classPaths = System.getProperty("java.class.path").split(File.pathSeparator);
+ // By convention, scrcpy is always executed with the absolute path of scrcpy-server.jar as the first item in the classpath
+ SERVER_PATH = classPaths[0];
+ }
+
+
private static void scrcpy(Options options) throws IOException {
final Device device = new Device(options);
+
+ /*if (options.getStayAwake())*/ {
+ int stayOn = BatteryManager.BATTERY_PLUGGED_AC | BatteryManager.BATTERY_PLUGGED_USB | BatteryManager.BATTERY_PLUGGED_WIRELESS;
+ try {
+ String oldValue = Settings.getAndPutValue(Settings.TABLE_GLOBAL, "stay_on_while_plugged_in", String.valueOf(stayOn));
+ try {
+ int restoreStayOn = Integer.parseInt(oldValue);
+ if (restoreStayOn != stayOn) {
+ // Restore only if the current value is different
+ if (!cleanUp.setRestoreStayOn(restoreStayOn)) {
+ Ln.e("Could not restore stay on on exit");
+ }
+ }
+ } catch (NumberFormatException e) {
+ // ignore
+ }
+ } catch (SettingsException e) {
+ Ln.e("Could not change \"stay_on_while_plugged_in\"", e);
+ }
+ }
+
try (DroidConnection connection = DroidConnection.open(ip)) {
ScreenEncoder screenEncoder = new ScreenEncoder(options.getBitRate());
diff --git a/server/src/main/java/org/cagnulein/android_remote/Settings.java b/server/src/main/java/org/cagnulein/android_remote/Settings.java
new file mode 100644
index 0000000..f8c45a0
--- /dev/null
+++ b/server/src/main/java/org/cagnulein/android_remote/Settings.java
@@ -0,0 +1,82 @@
+package org.cagnulein.android_remote;
+
+import org.cagnulein.android_remote.wrappers.ContentProvider;
+import org.cagnulein.android_remote.wrappers.ServiceManager;
+
+import android.os.Build;
+
+import java.io.IOException;
+
+public final class Settings {
+
+ public static final String TABLE_SYSTEM = ContentProvider.TABLE_SYSTEM;
+ public static final String TABLE_SECURE = ContentProvider.TABLE_SECURE;
+ public static final String TABLE_GLOBAL = ContentProvider.TABLE_GLOBAL;
+
+ private Settings() {
+ /* not instantiable */
+ }
+
+ private static void execSettingsPut(String table, String key, String value) throws SettingsException {
+ try {
+ Command.exec("settings", "put", table, key, value);
+ } catch (IOException | InterruptedException e) {
+ throw new SettingsException("put", table, key, value, e);
+ }
+ }
+
+ private static String execSettingsGet(String table, String key) throws SettingsException {
+ try {
+ return Command.execReadLine("settings", "get", table, key);
+ } catch (IOException | InterruptedException e) {
+ throw new SettingsException("get", table, key, null, e);
+ }
+ }
+
+ public static String getValue(String table, String key) throws SettingsException {
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
+ // on Android >= 12, it always fails:
+ try (ContentProvider provider = ServiceManager.getActivityManager().createSettingsProvider()) {
+ return provider.getValue(table, key);
+ } catch (SettingsException e) {
+ Ln.w("Could not get settings value via ContentProvider, fallback to settings process" + e);
+ }
+ }
+
+ return execSettingsGet(table, key);
+ }
+
+ public static void putValue(String table, String key, String value) throws SettingsException {
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
+ // on Android >= 12, it always fails:
+ try (ContentProvider provider = ServiceManager.getActivityManager().createSettingsProvider()) {
+ provider.putValue(table, key, value);
+ } catch (SettingsException e) {
+ Ln.w("Could not put settings value via ContentProvider, fallback to settings process"+ e);
+ }
+ }
+
+ execSettingsPut(table, key, value);
+ }
+
+ public static String getAndPutValue(String table, String key, String value) throws SettingsException {
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
+ // on Android >= 12, it always fails:
+ try (ContentProvider provider = ServiceManager.getActivityManager().createSettingsProvider()) {
+ String oldValue = provider.getValue(table, key);
+ if (!value.equals(oldValue)) {
+ provider.putValue(table, key, value);
+ }
+ return oldValue;
+ } catch (SettingsException e) {
+ Ln.w("Could not get and put settings value via ContentProvider, fallback to settings process"+ e);
+ }
+ }
+
+ String oldValue = getValue(table, key);
+ if (!value.equals(oldValue)) {
+ putValue(table, key, value);
+ }
+ return oldValue;
+ }
+}
diff --git a/server/src/main/java/org/cagnulein/android_remote/SettingsException.java b/server/src/main/java/org/cagnulein/android_remote/SettingsException.java
new file mode 100644
index 0000000..b369c2b
--- /dev/null
+++ b/server/src/main/java/org/cagnulein/android_remote/SettingsException.java
@@ -0,0 +1,11 @@
+package org.cagnulein.android_remote;
+
+public class SettingsException extends Exception {
+ private static String createMessage(String method, String table, String key, String value) {
+ return "Could not access settings: " + method + " " + table + " " + key + (value != null ? " " + value : "");
+ }
+
+ public SettingsException(String method, String table, String key, String value, Throwable cause) {
+ super(createMessage(method, table, key, value), cause);
+ }
+}
diff --git a/server/src/main/java/org/cagnulein/android_remote/Workarounds.java b/server/src/main/java/org/cagnulein/android_remote/Workarounds.java
new file mode 100644
index 0000000..18aec6b
--- /dev/null
+++ b/server/src/main/java/org/cagnulein/android_remote/Workarounds.java
@@ -0,0 +1,340 @@
+package org.cagnulein.android_remote;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.app.Application;
+import android.content.AttributionSource;
+import android.content.Context;
+import android.content.ContextWrapper;
+import android.content.pm.ApplicationInfo;
+import android.media.AudioAttributes;
+import android.media.AudioManager;
+import android.media.AudioRecord;
+import android.os.Build;
+import android.os.Looper;
+import android.os.Parcel;
+import android.util.Log;
+
+import java.lang.ref.WeakReference;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+
+@SuppressLint("PrivateApi,BlockedPrivateApi,SoonBlockedPrivateApi,DiscouragedPrivateApi")
+public final class Workarounds {
+
+ private static final Class> ACTIVITY_THREAD_CLASS;
+ private static final Object ACTIVITY_THREAD;
+
+ static {
+ prepareMainLooper();
+
+ try {
+ // ActivityThread activityThread = new ActivityThread();
+ ACTIVITY_THREAD_CLASS = Class.forName("android.app.ActivityThread");
+ Constructor> activityThreadConstructor = ACTIVITY_THREAD_CLASS.getDeclaredConstructor();
+ activityThreadConstructor.setAccessible(true);
+ ACTIVITY_THREAD = activityThreadConstructor.newInstance();
+
+ // ActivityThread.sCurrentActivityThread = activityThread;
+ Field sCurrentActivityThreadField = ACTIVITY_THREAD_CLASS.getDeclaredField("sCurrentActivityThread");
+ sCurrentActivityThreadField.setAccessible(true);
+ sCurrentActivityThreadField.set(null, ACTIVITY_THREAD);
+ } catch (Exception e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ private Workarounds() {
+ // not instantiable
+ }
+
+ public static void apply(boolean audio, boolean camera) {
+ boolean mustFillConfigurationController = false;
+ boolean mustFillAppInfo = false;
+ boolean mustFillAppContext = false;
+
+ if (Build.BRAND.equalsIgnoreCase("meizu")) {
+ // Workarounds must be applied for Meizu phones:
+ // -
+ // -
+ // -
+ //
+ // But only apply when strictly necessary, since workarounds can cause other issues:
+ // -
+ // -
+ mustFillAppInfo = true;
+ } else if (Build.BRAND.equalsIgnoreCase("honor")) {
+ // More workarounds must be applied for Honor devices:
+ // -
+ //
+ // The system context must not be set for all devices, because it would cause other problems:
+ // -
+ // -
+ mustFillAppInfo = true;
+ mustFillAppContext = true;
+ }
+
+ if (audio && Build.VERSION.SDK_INT == Build.VERSION_CODES.R) {
+ // Before Android 11, audio is not supported.
+ // Since Android 12, we can properly set a context on the AudioRecord.
+ // Only on Android 11 we must fill the application context for the AudioRecord to work.
+ mustFillAppContext = true;
+ }
+
+ if (camera) {
+ mustFillAppInfo = true;
+ mustFillAppContext = true;
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ // On some Samsung devices, DisplayManagerGlobal.getDisplayInfoLocked() calls ActivityThread.currentActivityThread().getConfiguration(),
+ // which requires a non-null ConfigurationController.
+ // ConfigurationController was introduced in Android 12, so do not attempt to set it on lower versions.
+ //
+ mustFillConfigurationController = true;
+ }
+
+ if (mustFillConfigurationController) {
+ // Must be call before fillAppContext() because it is necessary to get a valid system context
+ fillConfigurationController();
+ }
+ if (mustFillAppInfo) {
+ fillAppInfo();
+ }
+ if (mustFillAppContext) {
+ fillAppContext();
+ }
+ }
+
+ @SuppressWarnings("deprecation")
+ private static void prepareMainLooper() {
+ // Some devices internally create a Handler when creating an input Surface, causing an exception:
+ // "Can't create handler inside thread that has not called Looper.prepare()"
+ //
+ //
+ // Use Looper.prepareMainLooper() instead of Looper.prepare() to avoid a NullPointerException:
+ // "Attempt to read from field 'android.os.MessageQueue android.os.Looper.mQueue'
+ // on a null object reference"
+ //
+ Looper.prepareMainLooper();
+ }
+
+ private static void fillAppInfo() {
+ try {
+ // ActivityThread.AppBindData appBindData = new ActivityThread.AppBindData();
+ Class> appBindDataClass = Class.forName("android.app.ActivityThread$AppBindData");
+ Constructor> appBindDataConstructor = appBindDataClass.getDeclaredConstructor();
+ appBindDataConstructor.setAccessible(true);
+ Object appBindData = appBindDataConstructor.newInstance();
+
+ ApplicationInfo applicationInfo = new ApplicationInfo();
+ applicationInfo.packageName = FakeContext.PACKAGE_NAME;
+
+ // appBindData.appInfo = applicationInfo;
+ Field appInfoField = appBindDataClass.getDeclaredField("appInfo");
+ appInfoField.setAccessible(true);
+ appInfoField.set(appBindData, applicationInfo);
+
+ // activityThread.mBoundApplication = appBindData;
+ Field mBoundApplicationField = ACTIVITY_THREAD_CLASS.getDeclaredField("mBoundApplication");
+ mBoundApplicationField.setAccessible(true);
+ mBoundApplicationField.set(ACTIVITY_THREAD, appBindData);
+ } catch (Throwable throwable) {
+ // this is a workaround, so failing is not an error
+ Ln.d("Could not fill app info: " + throwable.getMessage());
+ }
+ }
+
+ private static void fillAppContext() {
+ try {
+ Application app = new Application();
+ Field baseField = ContextWrapper.class.getDeclaredField("mBase");
+ baseField.setAccessible(true);
+ baseField.set(app, FakeContext.get());
+
+ // activityThread.mInitialApplication = app;
+ Field mInitialApplicationField = ACTIVITY_THREAD_CLASS.getDeclaredField("mInitialApplication");
+ mInitialApplicationField.setAccessible(true);
+ mInitialApplicationField.set(ACTIVITY_THREAD, app);
+ } catch (Throwable throwable) {
+ // this is a workaround, so failing is not an error
+ Ln.d("Could not fill app context: " + throwable.getMessage());
+ }
+ }
+
+ private static void fillConfigurationController() {
+ try {
+ Class> configurationControllerClass = Class.forName("android.app.ConfigurationController");
+ Class> activityThreadInternalClass = Class.forName("android.app.ActivityThreadInternal");
+ Constructor> configurationControllerConstructor = configurationControllerClass.getDeclaredConstructor(activityThreadInternalClass);
+ configurationControllerConstructor.setAccessible(true);
+ Object configurationController = configurationControllerConstructor.newInstance(ACTIVITY_THREAD);
+
+ Field configurationControllerField = ACTIVITY_THREAD_CLASS.getDeclaredField("mConfigurationController");
+ configurationControllerField.setAccessible(true);
+ configurationControllerField.set(ACTIVITY_THREAD, configurationController);
+ } catch (Throwable throwable) {
+ Ln.d("Could not fill configuration: " + throwable.getMessage());
+ }
+ }
+
+ static Context getSystemContext() {
+ try {
+ Method getSystemContextMethod = ACTIVITY_THREAD_CLASS.getDeclaredMethod("getSystemContext");
+ return (Context) getSystemContextMethod.invoke(ACTIVITY_THREAD);
+ } catch (Throwable throwable) {
+ // this is a workaround, so failing is not an error
+ Ln.d("Could not get system context: " + throwable.getMessage());
+ return null;
+ }
+ }
+
+ @TargetApi(Build.VERSION_CODES.R)
+ @SuppressLint("WrongConstant,MissingPermission")
+ public static AudioRecord createAudioRecord(int source, int sampleRate, int channelConfig, int channels, int channelMask, int encoding) {
+ // Vivo (and maybe some other third-party ROMs) modified `AudioRecord`'s constructor, requiring `Context`s from real App environment.
+ //
+ // This method invokes the `AudioRecord(long nativeRecordInJavaObj)` constructor to create an empty `AudioRecord` instance, then uses
+ // reflections to initialize it like the normal constructor do (or the `AudioRecord.Builder.build()` method do).
+ // As a result, the modified code was not executed.
+ try {
+ // AudioRecord audioRecord = new AudioRecord(0L);
+ Constructor audioRecordConstructor = AudioRecord.class.getDeclaredConstructor(long.class);
+ audioRecordConstructor.setAccessible(true);
+ AudioRecord audioRecord = audioRecordConstructor.newInstance(0L);
+
+ // audioRecord.mRecordingState = RECORDSTATE_STOPPED;
+ Field mRecordingStateField = AudioRecord.class.getDeclaredField("mRecordingState");
+ mRecordingStateField.setAccessible(true);
+ mRecordingStateField.set(audioRecord, AudioRecord.RECORDSTATE_STOPPED);
+
+ Looper looper = Looper.myLooper();
+ if (looper == null) {
+ looper = Looper.getMainLooper();
+ }
+
+ // audioRecord.mInitializationLooper = looper;
+ Field mInitializationLooperField = AudioRecord.class.getDeclaredField("mInitializationLooper");
+ mInitializationLooperField.setAccessible(true);
+ mInitializationLooperField.set(audioRecord, looper);
+
+ // Create `AudioAttributes` with fixed capture preset
+ int capturePreset = source;
+ AudioAttributes.Builder audioAttributesBuilder = new AudioAttributes.Builder();
+ Method setInternalCapturePresetMethod = AudioAttributes.Builder.class.getMethod("setInternalCapturePreset", int.class);
+ setInternalCapturePresetMethod.invoke(audioAttributesBuilder, capturePreset);
+ AudioAttributes attributes = audioAttributesBuilder.build();
+
+ // audioRecord.mAudioAttributes = attributes;
+ Field mAudioAttributesField = AudioRecord.class.getDeclaredField("mAudioAttributes");
+ mAudioAttributesField.setAccessible(true);
+ mAudioAttributesField.set(audioRecord, attributes);
+
+ // audioRecord.audioParamCheck(capturePreset, sampleRate, encoding);
+ Method audioParamCheckMethod = AudioRecord.class.getDeclaredMethod("audioParamCheck", int.class, int.class, int.class);
+ audioParamCheckMethod.setAccessible(true);
+ audioParamCheckMethod.invoke(audioRecord, capturePreset, sampleRate, encoding);
+
+ // audioRecord.mChannelCount = channels
+ Field mChannelCountField = AudioRecord.class.getDeclaredField("mChannelCount");
+ mChannelCountField.setAccessible(true);
+ mChannelCountField.set(audioRecord, channels);
+
+ // audioRecord.mChannelMask = channelMask
+ Field mChannelMaskField = AudioRecord.class.getDeclaredField("mChannelMask");
+ mChannelMaskField.setAccessible(true);
+ mChannelMaskField.set(audioRecord, channelMask);
+
+ int minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, encoding);
+ int bufferSizeInBytes = minBufferSize * 8;
+
+ // audioRecord.audioBuffSizeCheck(bufferSizeInBytes)
+ Method audioBuffSizeCheckMethod = AudioRecord.class.getDeclaredMethod("audioBuffSizeCheck", int.class);
+ audioBuffSizeCheckMethod.setAccessible(true);
+ audioBuffSizeCheckMethod.invoke(audioRecord, bufferSizeInBytes);
+
+ final int channelIndexMask = 0;
+
+ int[] sampleRateArray = new int[]{sampleRate};
+ int[] session = new int[]{AudioManager.AUDIO_SESSION_ID_GENERATE};
+
+ int initResult;
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
+ // private native final int native_setup(Object audiorecord_this,
+ // Object /*AudioAttributes*/ attributes,
+ // int[] sampleRate, int channelMask, int channelIndexMask, int audioFormat,
+ // int buffSizeInBytes, int[] sessionId, String opPackageName,
+ // long nativeRecordInJavaObj);
+ Method nativeSetupMethod = AudioRecord.class.getDeclaredMethod("native_setup", Object.class, Object.class, int[].class, int.class,
+ int.class, int.class, int.class, int[].class, String.class, long.class);
+ nativeSetupMethod.setAccessible(true);
+ initResult = (int) nativeSetupMethod.invoke(audioRecord, new WeakReference(audioRecord), attributes, sampleRateArray,
+ channelMask, channelIndexMask, audioRecord.getAudioFormat(), bufferSizeInBytes, session, FakeContext.get().getOpPackageName(),
+ 0L);
+ } else {
+ // Assume `context` is never `null`
+ AttributionSource attributionSource = FakeContext.get().getAttributionSource();
+
+ // Assume `attributionSource.getPackageName()` is never null
+
+ // ScopedParcelState attributionSourceState = attributionSource.asScopedParcelState()
+ Method asScopedParcelStateMethod = AttributionSource.class.getDeclaredMethod("asScopedParcelState");
+ asScopedParcelStateMethod.setAccessible(true);
+
+ try (AutoCloseable attributionSourceState = (AutoCloseable) asScopedParcelStateMethod.invoke(attributionSource)) {
+ Method getParcelMethod = attributionSourceState.getClass().getDeclaredMethod("getParcel");
+ Parcel attributionSourceParcel = (Parcel) getParcelMethod.invoke(attributionSourceState);
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ // private native int native_setup(Object audiorecordThis,
+ // Object /*AudioAttributes*/ attributes,
+ // int[] sampleRate, int channelMask, int channelIndexMask, int audioFormat,
+ // int buffSizeInBytes, int[] sessionId, @NonNull Parcel attributionSource,
+ // long nativeRecordInJavaObj, int maxSharedAudioHistoryMs);
+ Method nativeSetupMethod = AudioRecord.class.getDeclaredMethod("native_setup", Object.class, Object.class, int[].class,
+ int.class, int.class, int.class, int.class, int[].class, Parcel.class, long.class, int.class);
+ nativeSetupMethod.setAccessible(true);
+ initResult = (int) nativeSetupMethod.invoke(audioRecord, new WeakReference(audioRecord), attributes,
+ sampleRateArray, channelMask, channelIndexMask, audioRecord.getAudioFormat(), bufferSizeInBytes, session,
+ attributionSourceParcel, 0L, 0);
+ } else {
+ // Android 14 added a new int parameter "halInputFlags"
+ //
+ Method nativeSetupMethod = AudioRecord.class.getDeclaredMethod("native_setup", Object.class, Object.class, int[].class,
+ int.class, int.class, int.class, int.class, int[].class, Parcel.class, long.class, int.class, int.class);
+ nativeSetupMethod.setAccessible(true);
+ initResult = (int) nativeSetupMethod.invoke(audioRecord, new WeakReference(audioRecord), attributes,
+ sampleRateArray, channelMask, channelIndexMask, audioRecord.getAudioFormat(), bufferSizeInBytes, session,
+ attributionSourceParcel, 0L, 0, 0);
+ }
+ }
+ }
+
+ if (initResult != AudioRecord.SUCCESS) {
+ Log.d("Workarounds", "Error code " + initResult + " when initializing native AudioRecord object.");
+ throw new RuntimeException("Cannot create AudioRecord");
+ }
+
+ // mSampleRate = sampleRate[0]
+ Field mSampleRateField = AudioRecord.class.getDeclaredField("mSampleRate");
+ mSampleRateField.setAccessible(true);
+ mSampleRateField.set(audioRecord, sampleRateArray[0]);
+
+ // audioRecord.mSessionId = session[0]
+ Field mSessionIdField = AudioRecord.class.getDeclaredField("mSessionId");
+ mSessionIdField.setAccessible(true);
+ mSessionIdField.set(audioRecord, session[0]);
+
+ // audioRecord.mState = AudioRecord.STATE_INITIALIZED
+ Field mStateField = AudioRecord.class.getDeclaredField("mState");
+ mStateField.setAccessible(true);
+ mStateField.set(audioRecord, AudioRecord.STATE_INITIALIZED);
+
+ return audioRecord;
+ } catch (Exception e) {
+ Ln.e("Failed to invoke AudioRecord..", e);
+ throw new RuntimeException("Cannot create AudioRecord");
+ }
+ }
+}
diff --git a/server/src/main/java/org/cagnulein/android_remote/wrappers/ActivityManager.java b/server/src/main/java/org/cagnulein/android_remote/wrappers/ActivityManager.java
new file mode 100644
index 0000000..bf72bb0
--- /dev/null
+++ b/server/src/main/java/org/cagnulein/android_remote/wrappers/ActivityManager.java
@@ -0,0 +1,159 @@
+package org.cagnulein.android_remote.wrappers;
+
+import org.cagnulein.android_remote.FakeContext;
+import org.cagnulein.android_remote.Ln;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.content.Intent;
+import android.os.Binder;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.os.IInterface;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+
+@SuppressLint("PrivateApi,DiscouragedPrivateApi")
+public final class ActivityManager {
+
+ private final IInterface manager;
+ private Method getContentProviderExternalMethod;
+ private boolean getContentProviderExternalMethodNewVersion = true;
+ private Method removeContentProviderExternalMethod;
+ private Method startActivityAsUserMethod;
+ private Method forceStopPackageMethod;
+
+ static ActivityManager create() {
+ try {
+ // On old Android versions, the ActivityManager is not exposed via AIDL,
+ // so use ActivityManagerNative.getDefault()
+ Class> cls = Class.forName("android.app.ActivityManagerNative");
+ Method getDefaultMethod = cls.getDeclaredMethod("getDefault");
+ IInterface am = (IInterface) getDefaultMethod.invoke(null);
+ return new ActivityManager(am);
+ } catch (ReflectiveOperationException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ ActivityManager(IInterface manager) {
+ this.manager = manager;
+ }
+
+ private Method getGetContentProviderExternalMethod() throws NoSuchMethodException {
+ if (getContentProviderExternalMethod == null) {
+ try {
+ getContentProviderExternalMethod = manager.getClass()
+ .getMethod("getContentProviderExternal", String.class, int.class, IBinder.class, String.class);
+ } catch (NoSuchMethodException e) {
+ // old version
+ getContentProviderExternalMethod = manager.getClass().getMethod("getContentProviderExternal", String.class, int.class, IBinder.class);
+ getContentProviderExternalMethodNewVersion = false;
+ }
+ }
+ return getContentProviderExternalMethod;
+ }
+
+ private Method getRemoveContentProviderExternalMethod() throws NoSuchMethodException {
+ if (removeContentProviderExternalMethod == null) {
+ removeContentProviderExternalMethod = manager.getClass().getMethod("removeContentProviderExternal", String.class, IBinder.class);
+ }
+ return removeContentProviderExternalMethod;
+ }
+
+ @TargetApi(Build.VERSION_CODES.Q)
+ private ContentProvider getContentProviderExternal(String name, IBinder token) {
+ try {
+ Method method = getGetContentProviderExternalMethod();
+ Object[] args;
+ if (getContentProviderExternalMethodNewVersion) {
+ // new version
+ args = new Object[]{name, FakeContext.ROOT_UID, token, null};
+ } else {
+ // old version
+ args = new Object[]{name, FakeContext.ROOT_UID, token};
+ }
+ // ContentProviderHolder providerHolder = getContentProviderExternal(...);
+ Object providerHolder = method.invoke(manager, args);
+ if (providerHolder == null) {
+ return null;
+ }
+ // IContentProvider provider = providerHolder.provider;
+ Field providerField = providerHolder.getClass().getDeclaredField("provider");
+ providerField.setAccessible(true);
+ Object provider = providerField.get(providerHolder);
+ if (provider == null) {
+ return null;
+ }
+ return new ContentProvider(this, provider, name, token);
+ } catch (ReflectiveOperationException e) {
+ Ln.e("Could not invoke method", e);
+ return null;
+ }
+ }
+
+ void removeContentProviderExternal(String name, IBinder token) {
+ try {
+ Method method = getRemoveContentProviderExternalMethod();
+ method.invoke(manager, name, token);
+ } catch (ReflectiveOperationException e) {
+ Ln.e("Could not invoke method", e);
+ }
+ }
+
+ public ContentProvider createSettingsProvider() {
+ return getContentProviderExternal("settings", new Binder());
+ }
+
+ private Method getStartActivityAsUserMethod() throws NoSuchMethodException, ClassNotFoundException {
+ if (startActivityAsUserMethod == null) {
+ Class> iApplicationThreadClass = Class.forName("android.app.IApplicationThread");
+ Class> profilerInfo = Class.forName("android.app.ProfilerInfo");
+ startActivityAsUserMethod = manager.getClass()
+ .getMethod("startActivityAsUser", iApplicationThreadClass, String.class, Intent.class, String.class, IBinder.class, String.class,
+ int.class, int.class, profilerInfo, Bundle.class, int.class);
+ }
+ return startActivityAsUserMethod;
+ }
+
+ @SuppressWarnings("ConstantConditions")
+ public int startActivity(Intent intent) {
+ try {
+ Method method = getStartActivityAsUserMethod();
+ return (int) method.invoke(
+ /* this */ manager,
+ /* caller */ null,
+ /* callingPackage */ FakeContext.PACKAGE_NAME,
+ /* intent */ intent,
+ /* resolvedType */ null,
+ /* resultTo */ null,
+ /* resultWho */ null,
+ /* requestCode */ 0,
+ /* startFlags */ 0,
+ /* profilerInfo */ null,
+ /* bOptions */ null,
+ /* userId */ /* UserHandle.USER_CURRENT */ -2);
+ } catch (Throwable e) {
+ Ln.e("Could not invoke method", e);
+ return 0;
+ }
+ }
+
+ private Method getForceStopPackageMethod() throws NoSuchMethodException {
+ if (forceStopPackageMethod == null) {
+ forceStopPackageMethod = manager.getClass().getMethod("forceStopPackage", String.class, int.class);
+ }
+ return forceStopPackageMethod;
+ }
+
+ public void forceStopPackage(String packageName) {
+ try {
+ Method method = getForceStopPackageMethod();
+ method.invoke(manager, packageName, /* userId */ /* UserHandle.USER_CURRENT */ -2);
+ } catch (Throwable e) {
+ Ln.e("Could not invoke method", e);
+ }
+ }
+}
diff --git a/server/src/main/java/org/cagnulein/android_remote/wrappers/ContentProvider.java b/server/src/main/java/org/cagnulein/android_remote/wrappers/ContentProvider.java
new file mode 100644
index 0000000..df36ce2
--- /dev/null
+++ b/server/src/main/java/org/cagnulein/android_remote/wrappers/ContentProvider.java
@@ -0,0 +1,161 @@
+package org.cagnulein.android_remote.wrappers;
+
+import org.cagnulein.android_remote.FakeContext;
+import org.cagnulein.android_remote.Ln;
+import org.cagnulein.android_remote.SettingsException;
+
+import android.annotation.SuppressLint;
+import android.content.AttributionSource;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.IBinder;
+
+import java.io.Closeable;
+import java.lang.reflect.Method;
+
+public final class ContentProvider implements Closeable {
+
+ public static final String TABLE_SYSTEM = "system";
+ public static final String TABLE_SECURE = "secure";
+ public static final String TABLE_GLOBAL = "global";
+
+ // See android/providerHolder/Settings.java
+ private static final String CALL_METHOD_GET_SYSTEM = "GET_system";
+ private static final String CALL_METHOD_GET_SECURE = "GET_secure";
+ private static final String CALL_METHOD_GET_GLOBAL = "GET_global";
+
+ private static final String CALL_METHOD_PUT_SYSTEM = "PUT_system";
+ private static final String CALL_METHOD_PUT_SECURE = "PUT_secure";
+ private static final String CALL_METHOD_PUT_GLOBAL = "PUT_global";
+
+ private static final String CALL_METHOD_USER_KEY = "_user";
+
+ private static final String NAME_VALUE_TABLE_VALUE = "value";
+
+ private final ActivityManager manager;
+ // android.content.IContentProvider
+ private final Object provider;
+ private final String name;
+ private final IBinder token;
+
+ private Method callMethod;
+ private int callMethodVersion;
+
+ ContentProvider(ActivityManager manager, Object provider, String name, IBinder token) {
+ this.manager = manager;
+ this.provider = provider;
+ this.name = name;
+ this.token = token;
+ }
+
+ @SuppressLint("PrivateApi")
+ private Method getCallMethod() throws NoSuchMethodException {
+ if (callMethod == null) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ callMethod = provider.getClass().getMethod("call", AttributionSource.class, String.class, String.class, String.class, Bundle.class);
+ callMethodVersion = 0;
+ } else {
+ // old versions
+ try {
+ callMethod = provider.getClass()
+ .getMethod("call", String.class, String.class, String.class, String.class, String.class, Bundle.class);
+ callMethodVersion = 1;
+ } catch (NoSuchMethodException e1) {
+ try {
+ callMethod = provider.getClass().getMethod("call", String.class, String.class, String.class, String.class, Bundle.class);
+ callMethodVersion = 2;
+ } catch (NoSuchMethodException e2) {
+ callMethod = provider.getClass().getMethod("call", String.class, String.class, String.class, Bundle.class);
+ callMethodVersion = 3;
+ }
+ }
+ }
+ }
+ return callMethod;
+ }
+
+ private Bundle call(String callMethod, String arg, Bundle extras) throws ReflectiveOperationException {
+ try {
+ Method method = getCallMethod();
+ Object[] args;
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && callMethodVersion == 0) {
+ args = new Object[]{FakeContext.get().getAttributionSource(), "settings", callMethod, arg, extras};
+ } else {
+ switch (callMethodVersion) {
+ case 1:
+ args = new Object[]{FakeContext.PACKAGE_NAME, null, "settings", callMethod, arg, extras};
+ break;
+ case 2:
+ args = new Object[]{FakeContext.PACKAGE_NAME, "settings", callMethod, arg, extras};
+ break;
+ default:
+ args = new Object[]{FakeContext.PACKAGE_NAME, callMethod, arg, extras};
+ break;
+ }
+ }
+ return (Bundle) method.invoke(provider, args);
+ } catch (ReflectiveOperationException e) {
+ Ln.e("Could not invoke method", e);
+ throw e;
+ }
+ }
+
+ public void close() {
+ manager.removeContentProviderExternal(name, token);
+ }
+
+ private static String getGetMethod(String table) {
+ switch (table) {
+ case TABLE_SECURE:
+ return CALL_METHOD_GET_SECURE;
+ case TABLE_SYSTEM:
+ return CALL_METHOD_GET_SYSTEM;
+ case TABLE_GLOBAL:
+ return CALL_METHOD_GET_GLOBAL;
+ default:
+ throw new IllegalArgumentException("Invalid table: " + table);
+ }
+ }
+
+ private static String getPutMethod(String table) {
+ switch (table) {
+ case TABLE_SECURE:
+ return CALL_METHOD_PUT_SECURE;
+ case TABLE_SYSTEM:
+ return CALL_METHOD_PUT_SYSTEM;
+ case TABLE_GLOBAL:
+ return CALL_METHOD_PUT_GLOBAL;
+ default:
+ throw new IllegalArgumentException("Invalid table: " + table);
+ }
+ }
+
+ public String getValue(String table, String key) throws SettingsException {
+ String method = getGetMethod(table);
+ Bundle arg = new Bundle();
+ arg.putInt(CALL_METHOD_USER_KEY, FakeContext.ROOT_UID);
+ try {
+ Bundle bundle = call(method, key, arg);
+ if (bundle == null) {
+ return null;
+ }
+ return bundle.getString("value");
+ } catch (Exception e) {
+ throw new SettingsException(table, "get", key, null, e);
+ }
+
+ }
+
+ public void putValue(String table, String key, String value) throws SettingsException {
+ String method = getPutMethod(table);
+ Bundle arg = new Bundle();
+ arg.putInt(CALL_METHOD_USER_KEY, FakeContext.ROOT_UID);
+ arg.putString(NAME_VALUE_TABLE_VALUE, value);
+ try {
+ call(method, key, arg);
+ } catch (Exception e) {
+ throw new SettingsException(table, "put", key, value, e);
+ }
+ }
+}
diff --git a/server/src/main/java/org/cagnulein/android_remote/wrappers/ServiceManager.java b/server/src/main/java/org/cagnulein/android_remote/wrappers/ServiceManager.java
index 2b21647..2049911 100644
--- a/server/src/main/java/org/cagnulein/android_remote/wrappers/ServiceManager.java
+++ b/server/src/main/java/org/cagnulein/android_remote/wrappers/ServiceManager.java
@@ -8,13 +8,15 @@
@SuppressLint("PrivateApi")
public final class ServiceManager {
- private final Method getServiceMethod;
+ private static Method getServiceMethod = null;
private WindowManager windowManager;
private DisplayManager displayManager;
private InputManager inputManager;
private PowerManager powerManager;
+ private static ActivityManager activityManager;
+
public ServiceManager() {
try {
getServiceMethod = Class.forName("android.os.ServiceManager").getDeclaredMethod("getService", String.class);
@@ -23,7 +25,7 @@ public ServiceManager() {
}
}
- private IInterface getService(String service, String type) {
+ private static IInterface getService(String service, String type) {
try {
IBinder binder = (IBinder) getServiceMethod.invoke(null, service);
Method asInterfaceMethod = Class.forName(type + "$Stub").getMethod("asInterface", IBinder.class);
@@ -60,4 +62,11 @@ public PowerManager getPowerManager() {
}
return powerManager;
}
+
+ public static ActivityManager getActivityManager() {
+ if (activityManager == null) {
+ activityManager = new ActivityManager(getService("activity", "android.app.IActivityManager"));
+ }
+ return activityManager;
+ }
}