From d2989920c159c57a05ace10d1f0b1d2a7ed1bc04 Mon Sep 17 00:00:00 2001 From: Romain Vimont Date: Sat, 19 Oct 2024 18:19:10 +0200 Subject: [PATCH] Add --start-app Add a command line option --start-app=name to start an Android app by its package name. For example: scrcpy --start-app=org.mozilla.firefox The app will be started on the correct target display: scrcpy --new-display --start-app=org.mozilla.firefox Co-authored-by: Simon Chan <1330321+yume-chan@users.noreply.github.com> --- app/data/bash-completion/scrcpy | 1 + app/data/zsh-completion/_scrcpy | 1 + app/scrcpy.1 | 4 + app/src/cli.c | 14 ++++ app/src/control_msg.c | 10 +++ app/src/control_msg.h | 4 + app/src/options.c | 1 + app/src/options.h | 1 + app/src/scrcpy.c | 19 +++++ .../scrcpy/control/ControlMessage.java | 8 ++ .../scrcpy/control/ControlMessageReader.java | 7 ++ .../genymobile/scrcpy/control/Controller.java | 78 ++++++++++++++++++- .../com/genymobile/scrcpy/device/Device.java | 48 +++++++++++- .../com/genymobile/scrcpy/util/LogUtils.java | 10 ++- .../scrcpy/wrappers/ActivityManager.java | 8 +- .../control/ControlMessageReaderTest.java | 21 +++++ 16 files changed, 226 insertions(+), 9 deletions(-) diff --git a/app/data/bash-completion/scrcpy b/app/data/bash-completion/scrcpy index f37da13a..223c5264 100644 --- a/app/data/bash-completion/scrcpy +++ b/app/data/bash-completion/scrcpy @@ -79,6 +79,7 @@ _scrcpy() { -s --serial= -S --turn-screen-off --shortcut-mod= + --start-app= -t --show-touches --tcpip --tcpip= diff --git a/app/data/zsh-completion/_scrcpy b/app/data/zsh-completion/_scrcpy index 3f25b88d..8d1189c0 100644 --- a/app/data/zsh-completion/_scrcpy +++ b/app/data/zsh-completion/_scrcpy @@ -82,6 +82,7 @@ arguments=( {-s,--serial=}'[The device serial number \(mandatory for multiple devices only\)]:serial:($("${ADB-adb}" devices | awk '\''$2 == "device" {print $1}'\''))' {-S,--turn-screen-off}'[Turn the device screen off immediately]' '--shortcut-mod=[\[key1,key2+key3,...\] Specify the modifiers to use for scrcpy shortcuts]:shortcut mod:(lctrl rctrl lalt ralt lsuper rsuper)' + '--start-app=[Start an Android app]' {-t,--show-touches}'[Show physical touches]' '--tcpip[\(optional \[ip\:port\]\) Configure and connect the device over TCP/IP]' '--time-limit=[Set the maximum mirroring time, in seconds]' diff --git a/app/scrcpy.1 b/app/scrcpy.1 index 252ecc54..a7793a68 100644 --- a/app/scrcpy.1 +++ b/app/scrcpy.1 @@ -493,6 +493,10 @@ For example, to use either LCtrl or LSuper for scrcpy shortcuts, pass "lctrl,lsu Default is "lalt,lsuper" (left-Alt or left-Super). +.TP +.BI "\-\-start\-app " name +Start an Android app, by its exact package name. + .TP .B \-t, \-\-show\-touches Enable "show touches" on start, restore the initial value on exit. diff --git a/app/src/cli.c b/app/src/cli.c index 89b1fbd2..4a0ce143 100644 --- a/app/src/cli.c +++ b/app/src/cli.c @@ -104,6 +104,7 @@ enum { OPT_GAMEPAD, OPT_NEW_DISPLAY, OPT_LIST_APPS, + OPT_START_APP, }; struct sc_option { @@ -805,6 +806,12 @@ static const struct sc_option options[] = { "shortcuts, pass \"lctrl,lsuper\".\n" "Default is \"lalt,lsuper\" (left-Alt or left-Super).", }, + { + .longopt_id = OPT_START_APP, + .longopt = "start-app", + .argdesc = "name", + .text = "Start an Android app, by its exact package name.", + }, { .shortopt = 't', .longopt = "show-touches", @@ -2695,6 +2702,9 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[], case OPT_NEW_DISPLAY: opts->new_display = optarg ? optarg : "auto"; break; + case OPT_START_APP: + opts->start_app = optarg; + break; default: // getopt prints the error message on stderr return false; @@ -3123,6 +3133,10 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[], LOGE("Cannot request power off on close if control is disabled"); return false; } + if (opts->start_app) { + LOGE("Cannot start an Android app if control is disabled"); + return false; + } } # ifdef _WIN32 diff --git a/app/src/control_msg.c b/app/src/control_msg.c index d599b62d..a71bf445 100644 --- a/app/src/control_msg.c +++ b/app/src/control_msg.c @@ -183,6 +183,10 @@ sc_control_msg_serialize(const struct sc_control_msg *msg, uint8_t *buf) { case SC_CONTROL_MSG_TYPE_UHID_DESTROY: sc_write16be(&buf[1], msg->uhid_destroy.id); return 3; + case SC_CONTROL_MSG_TYPE_START_APP: { + size_t len = write_string_tiny(&buf[1], msg->start_app.name, 255); + return 1 + len; + } case SC_CONTROL_MSG_TYPE_EXPAND_NOTIFICATION_PANEL: case SC_CONTROL_MSG_TYPE_EXPAND_SETTINGS_PANEL: case SC_CONTROL_MSG_TYPE_COLLAPSE_PANELS: @@ -308,6 +312,9 @@ sc_control_msg_log(const struct sc_control_msg *msg) { case SC_CONTROL_MSG_TYPE_OPEN_HARD_KEYBOARD_SETTINGS: LOG_CMSG("open hard keyboard settings"); break; + case SC_CONTROL_MSG_TYPE_START_APP: + LOG_CMSG("start app \"%s\"", msg->start_app.name); + break; default: LOG_CMSG("unknown type: %u", (unsigned) msg->type); break; @@ -333,6 +340,9 @@ sc_control_msg_destroy(struct sc_control_msg *msg) { case SC_CONTROL_MSG_TYPE_SET_CLIPBOARD: free(msg->set_clipboard.text); break; + case SC_CONTROL_MSG_TYPE_START_APP: + free(msg->start_app.name); + break; default: // do nothing break; diff --git a/app/src/control_msg.h b/app/src/control_msg.h index 1ae8cae4..a809a154 100644 --- a/app/src/control_msg.h +++ b/app/src/control_msg.h @@ -41,6 +41,7 @@ enum sc_control_msg_type { SC_CONTROL_MSG_TYPE_UHID_INPUT, SC_CONTROL_MSG_TYPE_UHID_DESTROY, SC_CONTROL_MSG_TYPE_OPEN_HARD_KEYBOARD_SETTINGS, + SC_CONTROL_MSG_TYPE_START_APP, }; enum sc_screen_power_mode { @@ -110,6 +111,9 @@ struct sc_control_msg { struct { uint16_t id; } uhid_destroy; + struct { + char *name; + } start_app; }; }; diff --git a/app/src/options.c b/app/src/options.c index 62fcd925..8106ce3d 100644 --- a/app/src/options.c +++ b/app/src/options.c @@ -104,6 +104,7 @@ const struct scrcpy_options scrcpy_options_default = { .mouse_hover = true, .audio_dup = false, .new_display = NULL, + .start_app = NULL, }; enum sc_orientation diff --git a/app/src/options.h b/app/src/options.h index 7cbe2e5b..ec5e71ea 100644 --- a/app/src/options.h +++ b/app/src/options.h @@ -310,6 +310,7 @@ struct scrcpy_options { bool mouse_hover; bool audio_dup; const char *new_display; // [x][/] parsed by the server + const char *start_app; }; extern const struct scrcpy_options scrcpy_options_default; diff --git a/app/src/scrcpy.c b/app/src/scrcpy.c index 502498ad..64a2fa10 100644 --- a/app/src/scrcpy.c +++ b/app/src/scrcpy.c @@ -907,6 +907,25 @@ aoa_complete: init_sdl_gamepads(); } + if (options->control && options->start_app) { + assert(controller); + + char *name = strdup(options->start_app); + if (!name) { + LOG_OOM(); + goto end; + } + + struct sc_control_msg msg; + msg.type = SC_CONTROL_MSG_TYPE_START_APP; + msg.start_app.name = name; + + if (!sc_controller_push_msg(controller, &msg)) { + LOGW("Could not request start app '%s'", name); + free(name); + } + } + ret = event_loop(s); terminate_event_loop(); LOGD("quit..."); diff --git a/server/src/main/java/com/genymobile/scrcpy/control/ControlMessage.java b/server/src/main/java/com/genymobile/scrcpy/control/ControlMessage.java index d1406ed0..36dbd03a 100644 --- a/server/src/main/java/com/genymobile/scrcpy/control/ControlMessage.java +++ b/server/src/main/java/com/genymobile/scrcpy/control/ControlMessage.java @@ -23,6 +23,7 @@ public final class ControlMessage { public static final int TYPE_UHID_INPUT = 13; public static final int TYPE_UHID_DESTROY = 14; public static final int TYPE_OPEN_HARD_KEYBOARD_SETTINGS = 15; + public static final int TYPE_START_APP = 16; public static final long SEQUENCE_INVALID = 0; @@ -155,6 +156,13 @@ public final class ControlMessage { return msg; } + public static ControlMessage createStartApp(String name) { + ControlMessage msg = new ControlMessage(); + msg.type = TYPE_START_APP; + msg.text = name; + return msg; + } + public int getType() { return type; } diff --git a/server/src/main/java/com/genymobile/scrcpy/control/ControlMessageReader.java b/server/src/main/java/com/genymobile/scrcpy/control/ControlMessageReader.java index 17e121c2..eb5dc787 100644 --- a/server/src/main/java/com/genymobile/scrcpy/control/ControlMessageReader.java +++ b/server/src/main/java/com/genymobile/scrcpy/control/ControlMessageReader.java @@ -53,6 +53,8 @@ public class ControlMessageReader { return parseUhidInput(); case ControlMessage.TYPE_UHID_DESTROY: return parseUhidDestroy(); + case ControlMessage.TYPE_START_APP: + return parseStartApp(); default: throw new ControlProtocolException("Unknown event type: " + type); } @@ -155,6 +157,11 @@ public class ControlMessageReader { return ControlMessage.createUhidDestroy(id); } + private ControlMessage parseStartApp() throws IOException { + String name = parseString(1); + return ControlMessage.createStartApp(name); + } + private Position parsePosition() throws IOException { int x = dis.readInt(); int y = dis.readInt(); diff --git a/server/src/main/java/com/genymobile/scrcpy/control/Controller.java b/server/src/main/java/com/genymobile/scrcpy/control/Controller.java index 21ac1936..07ce076a 100644 --- a/server/src/main/java/com/genymobile/scrcpy/control/Controller.java +++ b/server/src/main/java/com/genymobile/scrcpy/control/Controller.java @@ -4,6 +4,7 @@ import com.genymobile.scrcpy.AndroidVersions; import com.genymobile.scrcpy.AsyncProcessor; import com.genymobile.scrcpy.CleanUp; import com.genymobile.scrcpy.device.Device; +import com.genymobile.scrcpy.device.DeviceApp; import com.genymobile.scrcpy.device.Point; import com.genymobile.scrcpy.device.Position; import com.genymobile.scrcpy.util.Ln; @@ -22,6 +23,7 @@ import android.view.KeyEvent; import android.view.MotionEvent; import java.io.IOException; +import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -61,6 +63,7 @@ public class Controller implements AsyncProcessor, VirtualDisplayListener { private static final int POINTER_ID_MOUSE = -1; private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(); + private ExecutorService startAppExecutor; private Thread thread; @@ -79,6 +82,7 @@ public class Controller implements AsyncProcessor, VirtualDisplayListener { private final AtomicBoolean isSettingClipboard = new AtomicBoolean(); private final AtomicReference displayData = new AtomicReference<>(); + private final Object displayDataAvailable = new Object(); // condition variable private long lastTouchDown; private final PointersState pointersState = new PointersState(); @@ -129,7 +133,12 @@ public class Controller implements AsyncProcessor, VirtualDisplayListener { @Override public void onNewVirtualDisplay(int virtualDisplayId, PositionMapper positionMapper) { DisplayData data = new DisplayData(virtualDisplayId, positionMapper); - this.displayData.set(data); + DisplayData old = this.displayData.getAndSet(data); + if (old == null) { + synchronized (displayDataAvailable) { + displayDataAvailable.notify(); + } + } } private UhidManager getUhidManager() { @@ -288,6 +297,9 @@ public class Controller implements AsyncProcessor, VirtualDisplayListener { case ControlMessage.TYPE_OPEN_HARD_KEYBOARD_SETTINGS: openHardKeyboardSettings(); break; + case ControlMessage.TYPE_START_APP: + startAppAsync(msg.getText()); + break; default: // do nothing } @@ -571,4 +583,68 @@ public class Controller implements AsyncProcessor, VirtualDisplayListener { return data.virtualDisplayId; } + + private void startAppAsync(String name) { + if (startAppExecutor == null) { + startAppExecutor = Executors.newSingleThreadExecutor(); + } + + // Listing and selecting the app may take a lot of time + startAppExecutor.submit(() -> startApp(name)); + } + + private void startApp(String name) { + DeviceApp app = Device.findByPackageName(name); + if (app == null) { + Ln.w("No app found for package \"" + name + "\""); + return; + } + + int startAppDisplayId = getStartAppDisplayId(); + if (startAppDisplayId == Device.DISPLAY_ID_NONE) { + Ln.e("No known display id to start app \"" + name + "\""); + return; + } + + Ln.i("Starting app \"" + app.getName() + "\" [" + app.getPackageName() + "] on display " + startAppDisplayId + "..."); + Device.startApp(app.getPackageName(), startAppDisplayId); + } + + private int getStartAppDisplayId() { + if (displayId != Device.DISPLAY_ID_NONE) { + return displayId; + } + + // Mirroring a new virtual display id (using --new-display-id feature) + try { + // Wait for at most 1 second until a virtual display id is known + DisplayData data = waitDisplayData(1000); + if (data != null) { + return data.virtualDisplayId; + } + } catch (InterruptedException e) { + // do nothing + } + + // No display id available + return Device.DISPLAY_ID_NONE; + } + + private DisplayData waitDisplayData(long timeoutMillis) throws InterruptedException { + long deadline = System.currentTimeMillis() + timeoutMillis; + + synchronized (displayDataAvailable) { + DisplayData data = displayData.get(); + while (data == null) { + long timeout = deadline - System.currentTimeMillis(); + if (timeout < 0) { + return null; + } + displayDataAvailable.wait(timeout); + data = displayData.get(); + } + + return data; + } + } } diff --git a/server/src/main/java/com/genymobile/scrcpy/device/Device.java b/server/src/main/java/com/genymobile/scrcpy/device/Device.java index f619f1e9..1da26100 100644 --- a/server/src/main/java/com/genymobile/scrcpy/device/Device.java +++ b/server/src/main/java/com/genymobile/scrcpy/device/Device.java @@ -3,6 +3,7 @@ package com.genymobile.scrcpy.device; import com.genymobile.scrcpy.AndroidVersions; import com.genymobile.scrcpy.FakeContext; import com.genymobile.scrcpy.util.Ln; +import com.genymobile.scrcpy.wrappers.ActivityManager; import com.genymobile.scrcpy.wrappers.ClipboardManager; import com.genymobile.scrcpy.wrappers.DisplayControl; import com.genymobile.scrcpy.wrappers.InputManager; @@ -12,9 +13,11 @@ import com.genymobile.scrcpy.wrappers.WindowManager; import android.annotation.SuppressLint; import android.content.Intent; +import android.app.ActivityOptions; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.os.Build; +import android.os.Bundle; import android.os.IBinder; import android.os.SystemClock; import android.view.InputDevice; @@ -214,9 +217,7 @@ public final class Device { List apps = new ArrayList<>(); PackageManager pm = FakeContext.get().getPackageManager(); for (ApplicationInfo appInfo : getLaunchableApps(pm)) { - String name = pm.getApplicationLabel(appInfo).toString(); - boolean system = (appInfo.flags & ApplicationInfo.FLAG_SYSTEM) != 0; - apps.add(new DeviceApp(appInfo.packageName, name, system, appInfo.enabled)); + apps.add(toApp(pm, appInfo)); } return apps; @@ -242,4 +243,45 @@ public final class Device { return pm.getLeanbackLaunchIntentForPackage(packageName); } + + private static DeviceApp toApp(PackageManager pm, ApplicationInfo appInfo) { + String name = pm.getApplicationLabel(appInfo).toString(); + boolean system = (appInfo.flags & ApplicationInfo.FLAG_SYSTEM) != 0; + return new DeviceApp(appInfo.packageName, name, system, appInfo.enabled); + } + + @SuppressLint("QueryPermissionsNeeded") + public static DeviceApp findByPackageName(String packageName) { + PackageManager pm = FakeContext.get().getPackageManager(); + // No need to filter by "launchable" apps, an error will be reported on start if the app is not launchable + for (ApplicationInfo appInfo : pm.getInstalledApplications(PackageManager.GET_META_DATA)) { + if (packageName.equals(appInfo.packageName)) { + return toApp(pm, appInfo); + } + } + + return null; + } + + public static void startApp(String packageName, int displayId) { + PackageManager pm = FakeContext.get().getPackageManager(); + + Intent launchIntent = getLaunchIntent(pm, packageName); + if (launchIntent == null) { + Ln.w("Cannot create launch intent for app " + packageName); + return; + } + + launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + Bundle options = null; + if (Build.VERSION.SDK_INT >= AndroidVersions.API_26_ANDROID_8_0) { + ActivityOptions launchOptions = ActivityOptions.makeBasic(); + launchOptions.setLaunchDisplayId(displayId); + options = launchOptions.toBundle(); + } + + ActivityManager am = ServiceManager.getActivityManager(); + am.startActivity(launchIntent, options); + } } diff --git a/server/src/main/java/com/genymobile/scrcpy/util/LogUtils.java b/server/src/main/java/com/genymobile/scrcpy/util/LogUtils.java index b15c1db6..a2503123 100644 --- a/server/src/main/java/com/genymobile/scrcpy/util/LogUtils.java +++ b/server/src/main/java/com/genymobile/scrcpy/util/LogUtils.java @@ -160,11 +160,15 @@ public final class LogUtils { return set; } - @SuppressLint("QueryPermissionsNeeded") - public static String buildAppListMessage() { - StringBuilder builder = new StringBuilder("List of apps:"); + public static String buildAppListMessage() { List apps = Device.listApps(); + return buildAppListMessage("List of apps:", apps); + } + + @SuppressLint("QueryPermissionsNeeded") + public static String buildAppListMessage(String title, List apps) { + StringBuilder builder = new StringBuilder(title); // Sort by: // 1. system flag (system apps are before non-system apps) diff --git a/server/src/main/java/com/genymobile/scrcpy/wrappers/ActivityManager.java b/server/src/main/java/com/genymobile/scrcpy/wrappers/ActivityManager.java index c907e12f..f052dee0 100644 --- a/server/src/main/java/com/genymobile/scrcpy/wrappers/ActivityManager.java +++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/ActivityManager.java @@ -118,8 +118,12 @@ public final class ActivityManager { return startActivityAsUserMethod; } - @SuppressWarnings("ConstantConditions") public int startActivity(Intent intent) { + return startActivity(intent, null); + } + + @SuppressWarnings("ConstantConditions") + public int startActivity(Intent intent, Bundle options) { try { Method method = getStartActivityAsUserMethod(); return (int) method.invoke( @@ -133,7 +137,7 @@ public final class ActivityManager { /* requestCode */ 0, /* startFlags */ 0, /* profilerInfo */ null, - /* bOptions */ null, + /* bOptions */ options, /* userId */ /* UserHandle.USER_CURRENT */ -2); } catch (Throwable e) { Ln.e("Could not invoke method", e); diff --git a/server/src/test/java/com/genymobile/scrcpy/control/ControlMessageReaderTest.java b/server/src/test/java/com/genymobile/scrcpy/control/ControlMessageReaderTest.java index f29be2f4..d8489fc3 100644 --- a/server/src/test/java/com/genymobile/scrcpy/control/ControlMessageReaderTest.java +++ b/server/src/test/java/com/genymobile/scrcpy/control/ControlMessageReaderTest.java @@ -399,6 +399,27 @@ public class ControlMessageReaderTest { Assert.assertEquals(-1, bis.read()); // EOS } + @Test + public void testParseStartApp() throws IOException { + byte[] name = "firefox".getBytes(StandardCharsets.UTF_8); + + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + DataOutputStream dos = new DataOutputStream(bos); + dos.writeByte(ControlMessage.TYPE_START_APP); + dos.writeByte(name.length); + dos.write(name); + byte[] packet = bos.toByteArray(); + + ByteArrayInputStream bis = new ByteArrayInputStream(packet); + ControlMessageReader reader = new ControlMessageReader(bis); + + ControlMessage event = reader.read(); + Assert.assertEquals(ControlMessage.TYPE_START_APP, event.getType()); + Assert.assertEquals("firefox", event.getText()); + + Assert.assertEquals(-1, bis.read()); // EOS + } + @Test public void testMultiEvents() throws IOException { ByteArrayOutputStream bos = new ByteArrayOutputStream();