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=1920x1080 --start-app=org.videolan.vlc

PR #5370 <https://github.com/Genymobile/scrcpy/pull/5370>

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 9c9d92fb1c
commit 13ce277e1f
16 changed files with 227 additions and 9 deletions

View File

@ -79,6 +79,7 @@ _scrcpy() {
-s --serial=
-S --turn-screen-off
--shortcut-mod=
--start-app=
-t --show-touches
--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,--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]'

View File

@ -494,6 +494,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.

View File

@ -104,6 +104,7 @@ enum {
OPT_GAMEPAD,
OPT_NEW_DISPLAY,
OPT_LIST_APPS,
OPT_START_APP,
};
struct sc_option {
@ -806,6 +807,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",
@ -2696,6 +2703,9 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[],
case OPT_NEW_DISPLAY:
opts->new_display = optarg ? optarg : "";
break;
case OPT_START_APP:
opts->start_app = optarg;
break;
default:
// getopt prints the error message on stderr
return false;
@ -3138,6 +3148,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

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:
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;

View File

@ -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;
};
};

View File

@ -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

View File

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

View File

@ -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...");

View File

@ -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;
}

View File

@ -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();

View File

@ -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> displayData = new AtomicReference<>();
private final Object displayDataAvailable = new Object(); // condition variable
private long lastTouchDown;
private final PointersState pointersState = new PointersState();
@ -128,7 +132,13 @@ 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) {
// The very first time the Controller is notified of a new virtual display
synchronized (displayDataAvailable) {
displayDataAvailable.notify();
}
}
}
private UhidManager getUhidManager() {
@ -287,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
}
@ -570,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;
}
}
}

View File

@ -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;
@ -215,9 +218,7 @@ public final class Device {
List<DeviceApp> 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));
apps.add(toApp(pm, appInfo));
}
return apps;
@ -243,4 +244,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);
}
@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;
}
@SuppressLint("QueryPermissionsNeeded")
public static String buildAppListMessage() {
StringBuilder builder = new StringBuilder("List of apps:");
public static String buildAppListMessage() {
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:
// 1. system flag (system apps are before non-system apps)

View File

@ -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);

View File

@ -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();