This commit is contained in:
brunoais 2021-07-14 14:26:33 +08:00 committed by GitHub
commit e33d0e887c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 408 additions and 11 deletions

View file

@ -721,6 +721,89 @@ The target directory can be changed on start:
scrcpy --push-target=/sdcard/Movies/
```
### Android features
#### Announce scrcpy state of execution
**(Advanced feature)**
Turn on the announcement of scrcpy current status.
Those announcements are done using the [broadcast intents] feature of Android.
If no value is provided with this argument, all intents are turned on.
[broadcast intents]: https://developer.android.com/reference/android/content/Intent
Currently, the only events that exist are:
| Option | Description | [Intent Action] | [Intent Extras]
| ----------|:---------------------------------------------- |:---------------------------------|:---------------
| `start` | scrcpy starts | `com.genymobile.scrcpy.START` | STARTUP: true
| `socket` | a socket for the duration of scrcpy's run created | `com.genymobile.scrcpy.SOCKET` | SOCKET: int
| `stop` | scrcpy stops (best effort) | `com.genymobile.scrcpy.STOP` | SHUTDOWN: true
| `cleaned` | scrcpy has finished cleaning up (best effort) | `com.genymobile.scrcpy.CLEANED` | SHUTDOWN: true
[Intent Action]: https://developer.android.com/reference/android/content/Intent#setAction(java.lang.String)
[Intent Extras]: https://developer.android.com/reference/android/content/Intent#putExtra(java.lang.String,%20android.os.Parcelable)
**Important:**
1. `stop` and `cleaned` **may not happen** in specific cases. For example,
if debugging is turned off, scrcpy process is immediately killed without a chance to cleanup.
2. The only guaranteed way to know if scrcpy has exited is by listening for a connection reset on
the socket
3. This option is intended **for advanced users**. By using this
feature, all apps on your phone will know scrcpy has connected
Unless that is what you want, and you know what that means
do not use this feature
4. In order for this argument to produce visible results you must create
some automation to listen to android broadcast intents.
Such as with your own app or with automation apps such as [Tasker].
Following [Android intent rules], all intents fields/keys prefixed with:
`com.genymobile.scrcpy.`
In case of Actions, it is followed by the intent name in caps. For example,
the 'start' intent has the action:
`com.genymobile.scrcpy.START`
[Android intent rules]: https://developer.android.com/reference/android/content/Intent#setAction(java.lang.String)
Additionally, there are two boolean fields (that may not be present) in the extra data section of the intents:
1. `com.genymobile.scrcpy.STARTUP` if present and `true`, scrcpy is starting up.
2. `com.genymobile.scrcpy.SHUTDOWN` if present and `true`, scrcpy is shutting down.
3. `com.genymobile.scrcpy.SOCKET` if present and an int, scrcpy has created a socket on the specified
port and you can listen to it to confirm when scrcpy exits
More extra fields will be present in the future.
In case you listen to the socket provided by `com.genymobile.scrcpy.SOCKET`, note that **no information will
be exchanged through it**. Even though bytes will be transmitted through it, they are only a test to
ensure the connection is still alive and have no meaning.
A connection reset followed by connection refused when trying to reestablish the connection is the
only infallible way to ensure that scrcpy has turned off.
For convinience with automation tools such as [Tasker], scrcpy also writes to the data field of the intents.
The scheme is `scrcpy-status`.
[Tasker]: https://tasker.joaoapps.com/
**Example usages:**
```bash
scrcpy --broadcast-intents
```
```bash
scrcpy --broadcast-intents=start
```
```bash
scrcpy --broadcast-intents start,cleaned
```
### Audio forwarding

View file

@ -28,6 +28,45 @@ scrcpy_print_usage(const char *arg0) {
" Unit suffixes are supported: 'K' (x1000) and 'M' (x1000000).\n"
" Default is " STR(DEFAULT_BIT_RATE) ".\n"
"\n"
" --broadcast-intents [value[, ...]]\n"
" (Advanced feature)\n"
" Turn on the broadcast of intents with the status of scrcpy \n"
" options are: start, socket, stop, cleaned\n"
" Each of these will arm the corresponding intent\n"
" start: announce finished setting up\n"
" socket: announce isAlive server port\n"
" stop: announce shut down started (best effort)\n"
" cleaned: announce cleanup finished (best effort)\n"
" \n"
" If you ommit the value, all intents are turned on\n"
" \n"
" All intents have the action and extra fields prefixed with: \n"
" com.genymobile.scrcpy.\n"
" Which is then followed by the intent name in caps. For example,\n"
" the 'start' intent has the action:\n"
" com.genymobile.scrcpy.START\n"
"\n"
" There are two boolean extras use to ease\n"
" the parsing process of the intents:\n"
" 1. com.genymobile.scrcpy.STARTUP if present and true,\n"
" scrcpy is starting up.\n"
" 2. com.genymobile.scrcpy.SHUTDOWN if present and true,\n"
" scrcpy is shutting down.\n"
"\n"
" socket has a different extra\n"
" com.genymobile.scrcpy.SOCKET which is the port where the socket\n"
" listens to.\n"
" Listening for a connection reset on the socket is the only\n"
" guaranteed way to know when scrcpy disconnects or crashes\n"
" \n"
" Notes:\n"
" 1. stop and cleaned may not happen in specific cases. For example, \n"
" if debugging is turned off, scrcpy process is immediately killed \n"
" 2. This option is intended for advanced users. By using this \n"
" feature, all apps on your phone will know scrcpy has connected\n"
" Unless that is what you want, and you know what that means\n"
" do not use this feature\n"
"\n"
" --codec-options key[:type]=value[,...]\n"
" Set a list of comma-separated key:type=value options for the\n"
" device encoder.\n"
@ -661,6 +700,53 @@ guess_record_format(const char *filename) {
return 0;
}
static bool
parse_intent_broadcast(const char *s, uint32_t *intents) {
// if no arg provided activates all intents for all intents and purposes
if(!s){
*intents = -1;
return true;
}
for (;;) {
char *comma = strchr(s, ',');
assert(!comma || comma > s);
size_t limit = comma ? (size_t) (comma - s) : strlen(s);
#define STREQ(literal, s, len) \
((sizeof(literal)-1 == len) && !memcmp(literal, s, len))
if (STREQ("start", s, limit)) {
*intents |= SC_INTENT_BROADCAST_START;
} else if (STREQ("socket", s, limit)) {
*intents |= SC_INTENT_BROADCAST_SOCKET;
} else if (STREQ("stop", s, limit)) {
*intents |= SC_INTENT_BROADCAST_STOP;
} else if (STREQ("cleaned", s, limit)) {
*intents |= SC_INTENT_BROADCAST_CLEANED;
} else {
LOGE("Unknown broadcast intent: %.*s "
"(must be one of: start, socket, stop, cleaned)",
(int) limit, s);
return false;
}
#undef STREQ
if (!comma) {
break;
}
s = comma + 1;
}
return true;
}
#define OPT_RENDER_EXPIRED_FRAMES 1000
#define OPT_WINDOW_TITLE 1001
#define OPT_PUSH_TARGET 1002
@ -689,6 +775,7 @@ guess_record_format(const char *filename) {
#define OPT_ENCODER_NAME 1025
#define OPT_POWER_OFF_ON_CLOSE 1026
#define OPT_V4L2_SINK 1027
#define OPT_INTENT_BROADCAST 1028
bool
scrcpy_parse_args(struct scrcpy_cli_args *args, int argc, char *argv[]) {
@ -744,6 +831,8 @@ scrcpy_parse_args(struct scrcpy_cli_args *args, int argc, char *argv[]) {
OPT_WINDOW_BORDERLESS},
{"power-off-on-close", no_argument, NULL,
OPT_POWER_OFF_ON_CLOSE},
{"intent-broadcast", optional_argument, NULL,
OPT_INTENT_BROADCAST},
{NULL, 0, NULL, 0 },
};
@ -922,6 +1011,12 @@ scrcpy_parse_args(struct scrcpy_cli_args *args, int argc, char *argv[]) {
opts->v4l2_device = optarg;
break;
#endif
case OPT_INTENT_BROADCAST:
if (!parse_intent_broadcast(optarg, &opts->intent_broadcasts)) {
return false;
}
break;
default:
// getopt prints the error message on stderr
return false;

View file

@ -279,6 +279,7 @@ scrcpy(const struct scrcpy_options *options) {
.encoder_name = options->encoder_name,
.force_adb_forward = options->force_adb_forward,
.power_off_on_close = options->power_off_on_close,
.intent_broadcasts = options->intent_broadcasts,
};
if (!server_start(&s->server, &params)) {
goto end;

View file

@ -54,6 +54,15 @@ struct sc_port_range {
#define SC_WINDOW_POSITION_UNDEFINED (-0x8000)
enum sc_intent_broadcast {
SC_INTENT_BROADCAST_START = 1 << 0,
SC_INTENT_BROADCAST_SOCKET = 1 << 1,
SC_INTENT_BROADCAST_STOP = 1 << 30,
SC_INTENT_BROADCAST_CLEANED = 1 << 31,
};
struct scrcpy_options {
const char *serial;
const char *crop;
@ -94,6 +103,7 @@ struct scrcpy_options {
bool forward_all_clicks;
bool legacy_paste;
bool power_off_on_close;
uint32_t intent_broadcasts;
};
#define SCRCPY_OPTIONS_DEFAULT { \
@ -142,6 +152,7 @@ struct scrcpy_options {
.forward_all_clicks = false, \
.legacy_paste = false, \
.power_off_on_close = false, \
.intent_broadcasts = 0, \
}
bool

View file

@ -258,11 +258,13 @@ execute_server(struct server *server, const struct server_params *params) {
char max_fps_string[6];
char lock_video_orientation_string[5];
char display_id_string[11];
char intent_broadcasts_string[11];
sprintf(max_size_string, "%"PRIu16, params->max_size);
sprintf(bit_rate_string, "%"PRIu32, params->bit_rate);
sprintf(max_fps_string, "%"PRIu16, params->max_fps);
sprintf(lock_video_orientation_string, "%"PRIi8, params->lock_video_orientation);
sprintf(display_id_string, "%"PRIu32, params->display_id);
sprintf(intent_broadcasts_string, "%"PRIu32, params->intent_broadcasts);
const char *const cmd[] = {
"shell",
"CLASSPATH=" DEVICE_SERVER_PATH,
@ -296,6 +298,7 @@ execute_server(struct server *server, const struct server_params *params) {
params->codec_options ? params->codec_options : "-",
params->encoder_name ? params->encoder_name : "-",
params->power_off_on_close ? "true" : "false",
intent_broadcasts_string,
};
#ifdef SERVER_DEBUGGER
LOGI("Server debugger waiting for a client on device port "

View file

@ -49,6 +49,7 @@ struct server_params {
bool stay_awake;
bool force_adb_forward;
bool power_off_on_close;
uint32_t intent_broadcasts;
};
// init default values

View file

@ -1,5 +1,7 @@
package com.genymobile.scrcpy;
import android.content.Intent;
import android.net.Uri;
import com.genymobile.scrcpy.wrappers.ContentProvider;
import com.genymobile.scrcpy.wrappers.ServiceManager;
@ -34,9 +36,10 @@ public final class CleanUp {
}
};
private static final int FLAG_DISABLE_SHOW_TOUCHES = 1;
private static final int FLAG_RESTORE_NORMAL_POWER_MODE = 2;
private static final int FLAG_POWER_OFF_SCREEN = 4;
private static final int FLAG_DISABLE_SHOW_TOUCHES = 1 << 0;
private static final int FLAG_RESTORE_NORMAL_POWER_MODE = 1 << 1;
private static final int FLAG_POWER_OFF_SCREEN = 1 << 2;
private static final int FLAG_BROADCAST_CLEANED = 1 << 3;
private int displayId;
@ -47,6 +50,7 @@ public final class CleanUp {
private boolean disableShowTouches;
private boolean restoreNormalPowerMode;
private boolean powerOffScreen;
private boolean broadcastCleaned;
public Config() {
// Default constructor, the fields are initialized by CleanUp.configure()
@ -59,6 +63,7 @@ public final class CleanUp {
disableShowTouches = (options & FLAG_DISABLE_SHOW_TOUCHES) != 0;
restoreNormalPowerMode = (options & FLAG_RESTORE_NORMAL_POWER_MODE) != 0;
powerOffScreen = (options & FLAG_POWER_OFF_SCREEN) != 0;
broadcastCleaned = (options & FLAG_BROADCAST_CLEANED) != 0;
}
@Override
@ -75,11 +80,14 @@ public final class CleanUp {
if (powerOffScreen) {
options |= FLAG_POWER_OFF_SCREEN;
}
if (broadcastCleaned) {
options |= FLAG_BROADCAST_CLEANED;
}
dest.writeByte(options);
}
private boolean hasWork() {
return disableShowTouches || restoreStayOn != -1 || restoreNormalPowerMode || powerOffScreen;
return disableShowTouches || restoreStayOn != -1 || restoreNormalPowerMode || powerOffScreen || broadcastCleaned;
}
@Override
@ -117,7 +125,8 @@ public final class CleanUp {
// not instantiable
}
public static void configure(int displayId, int restoreStayOn, boolean disableShowTouches, boolean restoreNormalPowerMode, boolean powerOffScreen)
public static void configure(int displayId, int restoreStayOn, boolean disableShowTouches, boolean restoreNormalPowerMode,
boolean powerOffScreen, boolean broadcastCleaned)
throws IOException {
Config config = new Config();
config.displayId = displayId;
@ -125,6 +134,7 @@ public final class CleanUp {
config.restoreStayOn = restoreStayOn;
config.restoreNormalPowerMode = restoreNormalPowerMode;
config.powerOffScreen = powerOffScreen;
config.broadcastCleaned = broadcastCleaned;
if (config.hasWork()) {
startProcess(config);
@ -187,5 +197,19 @@ public final class CleanUp {
Device.setScreenPowerMode(Device.POWER_MODE_NORMAL);
}
}
if(config.broadcastCleaned){
Ln.i("Announce cleaned");
announceScrcpyCleaned();
}
}
private static void announceScrcpyCleaned() {
Intent cleaned = new Intent(Intents.scrcpyPrefix("CLEANED"));
cleaned.setData(Uri.parse("scrcpy-status:cleaned"));
cleaned.putExtra(Intents.scrcpyPrefix("STARTUP"), false);
cleaned.putExtra(Intents.scrcpyPrefix("SHUTDOWN"), true);
Device.sendBroadcast(cleaned);
}
}

View file

@ -8,6 +8,7 @@ import com.genymobile.scrcpy.wrappers.SurfaceControl;
import com.genymobile.scrcpy.wrappers.WindowManager;
import android.content.IOnPrimaryClipChangedListener;
import android.content.Intent;
import android.graphics.Rect;
import android.os.Build;
import android.os.IBinder;
@ -299,4 +300,8 @@ public final class Device {
public static ContentProvider createSettingsProvider() {
return SERVICE_MANAGER.getActivityManager().createSettingsProvider();
}
public static void sendBroadcast(Intent intent) {
SERVICE_MANAGER.getActivityManager().sendBroadcast(intent);
}
}

View file

@ -0,0 +1,37 @@
package com.genymobile.scrcpy;
import java.io.IOException;
import java.util.*;
enum Intents {
START(1),
SOCKET(2),
STOP(30),
CLEANED(31),
;
public static final String SCRCPY_PREFIX = "com.genymobile.scrcpy.";
int shift;
Intents(int shift) {
this.shift = shift;
}
public static EnumSet<Intents> fromBitSet(BitSet bits) {
EnumSet<Intents> es = EnumSet.allOf(Intents.class);
Iterator<Intents> it = es.iterator();
Intents intent;
while (it.hasNext()) {
intent = it.next();
if (!bits.get(intent.shift - 1)) {
it.remove();
}
}
return es;
}
public static String scrcpyPrefix(String unprefixed){
return SCRCPY_PREFIX + unprefixed;
}
}

View file

@ -2,6 +2,9 @@ package com.genymobile.scrcpy;
import android.graphics.Rect;
import java.util.BitSet;
import java.util.EnumSet;
public class Options {
private Ln.Level logLevel;
private int maxSize;
@ -18,6 +21,7 @@ public class Options {
private String codecOptions;
private String encoderName;
private boolean powerOffScreenOnClose;
private EnumSet<Intents> broadcastIntents;
public Ln.Level getLogLevel() {
return logLevel;
@ -83,6 +87,10 @@ public class Options {
this.sendFrameMeta = sendFrameMeta;
}
public void setBroadcastIntents(EnumSet<Intents> broadcastIntents) {
this.broadcastIntents = broadcastIntents;
}
public boolean getControl() {
return control;
}
@ -138,4 +146,8 @@ public class Options {
public boolean getPowerOffScreenOnClose() {
return this.powerOffScreenOnClose;
}
public EnumSet<Intents> getBroadcastIntents() {
return broadcastIntents;
}
}

View file

@ -1,16 +1,20 @@
package com.genymobile.scrcpy;
import com.genymobile.scrcpy.wrappers.ContentProvider;
import android.content.Intent;
import android.graphics.Rect;
import android.media.MediaCodec;
import android.media.MediaCodecInfo;
import android.net.Uri;
import android.os.BatteryManager;
import android.os.Build;
import com.genymobile.scrcpy.wrappers.ContentProvider;
import java.io.IOException;
import java.util.List;
import java.util.Locale;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketException;
import java.util.*;
public final class Server {
@ -50,7 +54,8 @@ public final class Server {
}
}
CleanUp.configure(options.getDisplayId(), restoreStayOn, mustDisableShowTouchesOnCleanUp, true, options.getPowerOffScreenOnClose());
CleanUp.configure(options.getDisplayId(), restoreStayOn, mustDisableShowTouchesOnCleanUp, true, options.getPowerOffScreenOnClose(),
options.getBroadcastIntents().contains(Intents.CLEANED));
boolean tunnelForward = options.isTunnelForward();
@ -75,6 +80,13 @@ public final class Server {
});
}
if(options.getBroadcastIntents().contains(Intents.START)){
announceScrcpyStarting();
}
if(options.getBroadcastIntents().contains(Intents.SOCKET)){
scrcpyRunningSocket();
}
try {
// synchronous
screenEncoder.streamScreen(device, connection.getVideoFd());
@ -88,10 +100,92 @@ public final class Server {
if (deviceMessageSenderThread != null) {
deviceMessageSenderThread.interrupt();
}
if(options.getBroadcastIntents().contains(Intents.STOP)){
Ln.i("Announcing stopped");
announceScrcpyStopping();
}
}
}
}
private static void announceScrcpyStarting() {
Intent starting = new Intent(Intents.scrcpyPrefix("START"));
starting.setData(Uri.parse("scrcpy-status:start"));
starting.putExtra(Intents.scrcpyPrefix("STARTUP"), true);
starting.putExtra(Intents.scrcpyPrefix("SHUTDOWN"), false);
Device.sendBroadcast(starting);
}
private static void announceScrcpyStopping() {
Intent stopping = new Intent(Intents.scrcpyPrefix("STOP"));
stopping.setData(Uri.parse("scrcpy-status:stop"));
stopping.putExtra(Intents.scrcpyPrefix("STARTUP"), false);
stopping.putExtra(Intents.scrcpyPrefix("SHUTDOWN"), true);
Device.sendBroadcast(stopping);
}
private static Thread scrcpyRunningSocket() {
// Thread runs until scrcpy exits and doesn't block exiting
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
while(true) {
ArrayList<Socket> acceptedSockets = new ArrayList<>();
try (ServerSocket ss = new ServerSocket(0, -1, InetAddress.getLocalHost())) {
int localPort = ss.getLocalPort();
Ln.i("Running socket on " + localPort);
Intent starting = new Intent(Intents.scrcpyPrefix("SOCKET"));
starting.setData(Uri.parse("scrcpy-status:socket"));
starting.putExtra(Intents.scrcpyPrefix("SOCKET"), localPort);
Device.sendBroadcast(starting);
while (true) {
Socket accepted = ss.accept();
if (acceptedSockets.size() < 50) {
acceptedSockets.add(accepted);
Ln.d("Running socket: Added listener");
}
ListIterator<Socket> iter = acceptedSockets.listIterator();
for (Socket s = iter.next(); iter.hasNext(); s = iter.next()) {
try {
s.getOutputStream().write(1);
} catch (SocketException e) {
iter.remove();
Ln.d("Running socket: Removed listener");
try {
s.close();
} catch (IOException ignored) {
}
}
}
}
} catch (IOException e) {
Ln.e("Running socket: Cannot start server", e);
} catch (Exception e) {
Ln.e("Running socket: failed with an unexpected exception", e);
}
try {
// Avoid wasting resources in case of infinite loop
Thread.sleep(5000);
} catch (InterruptedException ignored) {
}
}
}
});
thread.setName("Running socket");
thread.setDaemon(true);
thread.start();
return thread;
}
private static Thread startController(final Controller controller) {
Thread thread = new Thread(new Runnable() {
@Override
@ -135,7 +229,7 @@ public final class Server {
"The server version (" + BuildConfig.VERSION_NAME + ") does not match the client " + "(" + clientVersion + ")");
}
final int expectedParameters = 16;
final int expectedParameters = 17;
if (args.length != expectedParameters) {
throw new IllegalArgumentException("Expecting " + expectedParameters + " parameters");
}
@ -188,6 +282,9 @@ public final class Server {
boolean powerOffScreenOnClose = Boolean.parseBoolean(args[15]);
options.setPowerOffScreenOnClose(powerOffScreenOnClose);
EnumSet<Intents> broadcastIntents = Intents.fromBitSet(BitSet.valueOf(new long[]{Long.parseLong(args[16])}));
options.setBroadcastIntents(broadcastIntents);
return options;
}

View file

@ -2,7 +2,9 @@ package com.genymobile.scrcpy.wrappers;
import com.genymobile.scrcpy.Ln;
import android.content.Intent;
import android.os.Binder;
import android.os.Bundle;
import android.os.IBinder;
import android.os.IInterface;
@ -16,6 +18,7 @@ public class ActivityManager {
private Method getContentProviderExternalMethod;
private boolean getContentProviderExternalMethodNewVersion = true;
private Method removeContentProviderExternalMethod;
private Method broadcastIntentMethod;
public ActivityManager(IInterface manager) {
this.manager = manager;
@ -42,6 +45,22 @@ public class ActivityManager {
return removeContentProviderExternalMethod;
}
private Method getBroadcastIntentMethod() throws NoSuchMethodException {
if (broadcastIntentMethod == null) {
try {
Class<?> iApplicationThreadClass = Class.forName("android.app.IApplicationThread");
Class<?> iIntentReceiverClass = Class.forName("android.content.IIntentReceiver");
broadcastIntentMethod = manager.getClass()
.getMethod("broadcastIntent", iApplicationThreadClass, Intent.class, String.class, iIntentReceiverClass, int.class,
String.class, Bundle.class, String[].class, int.class, Bundle.class, boolean.class, boolean.class, int.class);
return broadcastIntentMethod;
} catch (ClassNotFoundException e) {
throw new AssertionError(e);
}
}
return broadcastIntentMethod;
}
private ContentProvider getContentProviderExternal(String name, IBinder token) {
try {
Method method = getGetContentProviderExternalMethod();
@ -84,4 +103,13 @@ public class ActivityManager {
public ContentProvider createSettingsProvider() {
return getContentProviderExternal("settings", new Binder());
}
public void sendBroadcast(Intent intent) {
try {
Method method = getBroadcastIntentMethod();
method.invoke(manager, null, intent, null, null, 0, null, null, null, -1, null, true, false, ServiceManager.USER_ID);
} catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) {
Ln.e("Could not invoke method", e);
}
}
}