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>
This commit is contained in:
Romain Vimont 2024-10-19 18:19:10 +02:00
parent 6ff3d9b571
commit d2989920c1
16 changed files with 226 additions and 9 deletions

View File

@ -79,6 +79,7 @@ _scrcpy() {
-s --serial= -s --serial=
-S --turn-screen-off -S --turn-screen-off
--shortcut-mod= --shortcut-mod=
--start-app=
-t --show-touches -t --show-touches
--tcpip --tcpip
--tcpip= --tcpip=

View File

@ -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,--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]' {-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)' '--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]' {-t,--show-touches}'[Show physical touches]'
'--tcpip[\(optional \[ip\:port\]\) Configure and connect the device over TCP/IP]' '--tcpip[\(optional \[ip\:port\]\) Configure and connect the device over TCP/IP]'
'--time-limit=[Set the maximum mirroring time, in seconds]' '--time-limit=[Set the maximum mirroring time, in seconds]'

View File

@ -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). Default is "lalt,lsuper" (left-Alt or left-Super).
.TP
.BI "\-\-start\-app " name
Start an Android app, by its exact package name.
.TP .TP
.B \-t, \-\-show\-touches .B \-t, \-\-show\-touches
Enable "show touches" on start, restore the initial value on exit. Enable "show touches" on start, restore the initial value on exit.

View File

@ -104,6 +104,7 @@ enum {
OPT_GAMEPAD, OPT_GAMEPAD,
OPT_NEW_DISPLAY, OPT_NEW_DISPLAY,
OPT_LIST_APPS, OPT_LIST_APPS,
OPT_START_APP,
}; };
struct sc_option { struct sc_option {
@ -805,6 +806,12 @@ static const struct sc_option options[] = {
"shortcuts, pass \"lctrl,lsuper\".\n" "shortcuts, pass \"lctrl,lsuper\".\n"
"Default is \"lalt,lsuper\" (left-Alt or left-Super).", "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', .shortopt = 't',
.longopt = "show-touches", .longopt = "show-touches",
@ -2695,6 +2702,9 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[],
case OPT_NEW_DISPLAY: case OPT_NEW_DISPLAY:
opts->new_display = optarg ? optarg : "auto"; opts->new_display = optarg ? optarg : "auto";
break; break;
case OPT_START_APP:
opts->start_app = optarg;
break;
default: default:
// getopt prints the error message on stderr // getopt prints the error message on stderr
return false; 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"); LOGE("Cannot request power off on close if control is disabled");
return false; return false;
} }
if (opts->start_app) {
LOGE("Cannot start an Android app if control is disabled");
return false;
}
} }
# ifdef _WIN32 # ifdef _WIN32

View File

@ -183,6 +183,10 @@ sc_control_msg_serialize(const struct sc_control_msg *msg, uint8_t *buf) {
case SC_CONTROL_MSG_TYPE_UHID_DESTROY: case SC_CONTROL_MSG_TYPE_UHID_DESTROY:
sc_write16be(&buf[1], msg->uhid_destroy.id); sc_write16be(&buf[1], msg->uhid_destroy.id);
return 3; 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_NOTIFICATION_PANEL:
case SC_CONTROL_MSG_TYPE_EXPAND_SETTINGS_PANEL: case SC_CONTROL_MSG_TYPE_EXPAND_SETTINGS_PANEL:
case SC_CONTROL_MSG_TYPE_COLLAPSE_PANELS: 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: case SC_CONTROL_MSG_TYPE_OPEN_HARD_KEYBOARD_SETTINGS:
LOG_CMSG("open hard keyboard settings"); LOG_CMSG("open hard keyboard settings");
break; break;
case SC_CONTROL_MSG_TYPE_START_APP:
LOG_CMSG("start app \"%s\"", msg->start_app.name);
break;
default: default:
LOG_CMSG("unknown type: %u", (unsigned) msg->type); LOG_CMSG("unknown type: %u", (unsigned) msg->type);
break; break;
@ -333,6 +340,9 @@ sc_control_msg_destroy(struct sc_control_msg *msg) {
case SC_CONTROL_MSG_TYPE_SET_CLIPBOARD: case SC_CONTROL_MSG_TYPE_SET_CLIPBOARD:
free(msg->set_clipboard.text); free(msg->set_clipboard.text);
break; break;
case SC_CONTROL_MSG_TYPE_START_APP:
free(msg->start_app.name);
break;
default: default:
// do nothing // do nothing
break; break;

View File

@ -41,6 +41,7 @@ enum sc_control_msg_type {
SC_CONTROL_MSG_TYPE_UHID_INPUT, SC_CONTROL_MSG_TYPE_UHID_INPUT,
SC_CONTROL_MSG_TYPE_UHID_DESTROY, SC_CONTROL_MSG_TYPE_UHID_DESTROY,
SC_CONTROL_MSG_TYPE_OPEN_HARD_KEYBOARD_SETTINGS, SC_CONTROL_MSG_TYPE_OPEN_HARD_KEYBOARD_SETTINGS,
SC_CONTROL_MSG_TYPE_START_APP,
}; };
enum sc_screen_power_mode { enum sc_screen_power_mode {
@ -110,6 +111,9 @@ struct sc_control_msg {
struct { struct {
uint16_t id; uint16_t id;
} uhid_destroy; } uhid_destroy;
struct {
char *name;
} start_app;
}; };
}; };

View File

@ -104,6 +104,7 @@ const struct scrcpy_options scrcpy_options_default = {
.mouse_hover = true, .mouse_hover = true,
.audio_dup = false, .audio_dup = false,
.new_display = NULL, .new_display = NULL,
.start_app = NULL,
}; };
enum sc_orientation enum sc_orientation

View File

@ -310,6 +310,7 @@ struct scrcpy_options {
bool mouse_hover; bool mouse_hover;
bool audio_dup; bool audio_dup;
const char *new_display; // [<width>x<height>][/<dpi>] parsed by the server const char *new_display; // [<width>x<height>][/<dpi>] parsed by the server
const char *start_app;
}; };
extern const struct scrcpy_options scrcpy_options_default; extern const struct scrcpy_options scrcpy_options_default;

View File

@ -907,6 +907,25 @@ aoa_complete:
init_sdl_gamepads(); 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); ret = event_loop(s);
terminate_event_loop(); terminate_event_loop();
LOGD("quit..."); LOGD("quit...");

View File

@ -23,6 +23,7 @@ public final class ControlMessage {
public static final int TYPE_UHID_INPUT = 13; public static final int TYPE_UHID_INPUT = 13;
public static final int TYPE_UHID_DESTROY = 14; public static final int TYPE_UHID_DESTROY = 14;
public static final int TYPE_OPEN_HARD_KEYBOARD_SETTINGS = 15; 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; public static final long SEQUENCE_INVALID = 0;
@ -155,6 +156,13 @@ public final class ControlMessage {
return msg; 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() { public int getType() {
return type; return type;
} }

View File

@ -53,6 +53,8 @@ public class ControlMessageReader {
return parseUhidInput(); return parseUhidInput();
case ControlMessage.TYPE_UHID_DESTROY: case ControlMessage.TYPE_UHID_DESTROY:
return parseUhidDestroy(); return parseUhidDestroy();
case ControlMessage.TYPE_START_APP:
return parseStartApp();
default: default:
throw new ControlProtocolException("Unknown event type: " + type); throw new ControlProtocolException("Unknown event type: " + type);
} }
@ -155,6 +157,11 @@ public class ControlMessageReader {
return ControlMessage.createUhidDestroy(id); return ControlMessage.createUhidDestroy(id);
} }
private ControlMessage parseStartApp() throws IOException {
String name = parseString(1);
return ControlMessage.createStartApp(name);
}
private Position parsePosition() throws IOException { private Position parsePosition() throws IOException {
int x = dis.readInt(); int x = dis.readInt();
int y = dis.readInt(); int y = dis.readInt();

View File

@ -4,6 +4,7 @@ import com.genymobile.scrcpy.AndroidVersions;
import com.genymobile.scrcpy.AsyncProcessor; import com.genymobile.scrcpy.AsyncProcessor;
import com.genymobile.scrcpy.CleanUp; import com.genymobile.scrcpy.CleanUp;
import com.genymobile.scrcpy.device.Device; import com.genymobile.scrcpy.device.Device;
import com.genymobile.scrcpy.device.DeviceApp;
import com.genymobile.scrcpy.device.Point; import com.genymobile.scrcpy.device.Point;
import com.genymobile.scrcpy.device.Position; import com.genymobile.scrcpy.device.Position;
import com.genymobile.scrcpy.util.Ln; import com.genymobile.scrcpy.util.Ln;
@ -22,6 +23,7 @@ import android.view.KeyEvent;
import android.view.MotionEvent; import android.view.MotionEvent;
import java.io.IOException; import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit; 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 int POINTER_ID_MOUSE = -1;
private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(); private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor();
private ExecutorService startAppExecutor;
private Thread thread; private Thread thread;
@ -79,6 +82,7 @@ public class Controller implements AsyncProcessor, VirtualDisplayListener {
private final AtomicBoolean isSettingClipboard = new AtomicBoolean(); private final AtomicBoolean isSettingClipboard = new AtomicBoolean();
private final AtomicReference<DisplayData> displayData = new AtomicReference<>(); private final AtomicReference<DisplayData> displayData = new AtomicReference<>();
private final Object displayDataAvailable = new Object(); // condition variable
private long lastTouchDown; private long lastTouchDown;
private final PointersState pointersState = new PointersState(); private final PointersState pointersState = new PointersState();
@ -129,7 +133,12 @@ public class Controller implements AsyncProcessor, VirtualDisplayListener {
@Override @Override
public void onNewVirtualDisplay(int virtualDisplayId, PositionMapper positionMapper) { public void onNewVirtualDisplay(int virtualDisplayId, PositionMapper positionMapper) {
DisplayData data = new DisplayData(virtualDisplayId, 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() { private UhidManager getUhidManager() {
@ -288,6 +297,9 @@ public class Controller implements AsyncProcessor, VirtualDisplayListener {
case ControlMessage.TYPE_OPEN_HARD_KEYBOARD_SETTINGS: case ControlMessage.TYPE_OPEN_HARD_KEYBOARD_SETTINGS:
openHardKeyboardSettings(); openHardKeyboardSettings();
break; break;
case ControlMessage.TYPE_START_APP:
startAppAsync(msg.getText());
break;
default: default:
// do nothing // do nothing
} }
@ -571,4 +583,68 @@ public class Controller implements AsyncProcessor, VirtualDisplayListener {
return data.virtualDisplayId; 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;
}
}
} }

View File

@ -3,6 +3,7 @@ package com.genymobile.scrcpy.device;
import com.genymobile.scrcpy.AndroidVersions; import com.genymobile.scrcpy.AndroidVersions;
import com.genymobile.scrcpy.FakeContext; import com.genymobile.scrcpy.FakeContext;
import com.genymobile.scrcpy.util.Ln; import com.genymobile.scrcpy.util.Ln;
import com.genymobile.scrcpy.wrappers.ActivityManager;
import com.genymobile.scrcpy.wrappers.ClipboardManager; import com.genymobile.scrcpy.wrappers.ClipboardManager;
import com.genymobile.scrcpy.wrappers.DisplayControl; import com.genymobile.scrcpy.wrappers.DisplayControl;
import com.genymobile.scrcpy.wrappers.InputManager; import com.genymobile.scrcpy.wrappers.InputManager;
@ -12,9 +13,11 @@ import com.genymobile.scrcpy.wrappers.WindowManager;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.content.Intent; import android.content.Intent;
import android.app.ActivityOptions;
import android.content.pm.ApplicationInfo; import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.os.Build; import android.os.Build;
import android.os.Bundle;
import android.os.IBinder; import android.os.IBinder;
import android.os.SystemClock; import android.os.SystemClock;
import android.view.InputDevice; import android.view.InputDevice;
@ -214,9 +217,7 @@ public final class Device {
List<DeviceApp> apps = new ArrayList<>(); List<DeviceApp> apps = new ArrayList<>();
PackageManager pm = FakeContext.get().getPackageManager(); PackageManager pm = FakeContext.get().getPackageManager();
for (ApplicationInfo appInfo : getLaunchableApps(pm)) { for (ApplicationInfo appInfo : getLaunchableApps(pm)) {
String name = pm.getApplicationLabel(appInfo).toString(); apps.add(toApp(pm, appInfo));
boolean system = (appInfo.flags & ApplicationInfo.FLAG_SYSTEM) != 0;
apps.add(new DeviceApp(appInfo.packageName, name, system, appInfo.enabled));
} }
return apps; return apps;
@ -242,4 +243,45 @@ public final class Device {
return pm.getLeanbackLaunchIntentForPackage(packageName); 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);
}
} }

View File

@ -160,11 +160,15 @@ public final class LogUtils {
return set; return set;
} }
@SuppressLint("QueryPermissionsNeeded")
public static String buildAppListMessage() {
StringBuilder builder = new StringBuilder("List of apps:");
public static String buildAppListMessage() {
List<DeviceApp> apps = Device.listApps(); List<DeviceApp> apps = Device.listApps();
return buildAppListMessage("List of apps:", apps);
}
@SuppressLint("QueryPermissionsNeeded")
public static String buildAppListMessage(String title, List<DeviceApp> apps) {
StringBuilder builder = new StringBuilder(title);
// Sort by: // Sort by:
// 1. system flag (system apps are before non-system apps) // 1. system flag (system apps are before non-system apps)

View File

@ -118,8 +118,12 @@ public final class ActivityManager {
return startActivityAsUserMethod; return startActivityAsUserMethod;
} }
@SuppressWarnings("ConstantConditions")
public int startActivity(Intent intent) { public int startActivity(Intent intent) {
return startActivity(intent, null);
}
@SuppressWarnings("ConstantConditions")
public int startActivity(Intent intent, Bundle options) {
try { try {
Method method = getStartActivityAsUserMethod(); Method method = getStartActivityAsUserMethod();
return (int) method.invoke( return (int) method.invoke(
@ -133,7 +137,7 @@ public final class ActivityManager {
/* requestCode */ 0, /* requestCode */ 0,
/* startFlags */ 0, /* startFlags */ 0,
/* profilerInfo */ null, /* profilerInfo */ null,
/* bOptions */ null, /* bOptions */ options,
/* userId */ /* UserHandle.USER_CURRENT */ -2); /* userId */ /* UserHandle.USER_CURRENT */ -2);
} catch (Throwable e) { } catch (Throwable e) {
Ln.e("Could not invoke method", e); Ln.e("Could not invoke method", e);

View File

@ -399,6 +399,27 @@ public class ControlMessageReaderTest {
Assert.assertEquals(-1, bis.read()); // EOS 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 @Test
public void testMultiEvents() throws IOException { public void testMultiEvents() throws IOException {
ByteArrayOutputStream bos = new ByteArrayOutputStream(); ByteArrayOutputStream bos = new ByteArrayOutputStream();