diff --git a/README.md b/README.md index ba68dfb199..19649eb257 100644 --- a/README.md +++ b/README.md @@ -721,6 +721,73 @@ The target directory can be changed on start: scrcpy --push-target=/sdcard/Download/ ``` +### 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: + + | [Intent Action] | Description | + |:--------------------------------|:----------------------------------------------| + | `com.genymobile.scrcpy.START` | scrcpy starts + | `com.genymobile.scrcpy.STOP` | scrcpy stopped and cleaned up (best effort) + + +[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` **may not happen** in specific cases. + Examples: + 1. Debugging is turned off. + 2. Scrcpy cleanup process is killed by android or another app. +2. 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 +3. 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.STARTED` + + +[Android intent rules]: https://developer.android.com/reference/android/content/Intent#setAction(java.lang.String) + + +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 diff --git a/app/src/cli.c b/app/src/cli.c index 3e5d613dff..c182ca17c3 100644 --- a/app/src/cli.c +++ b/app/src/cli.c @@ -28,6 +28,38 @@ 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, stop, cleaned\n" + " Each of these will arm the corresponding intent\n" + " start: announce finished setting up\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" + " 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" @@ -656,6 +688,7 @@ guess_record_format(const char *filename) { return 0; } + #define OPT_RENDER_EXPIRED_FRAMES 1000 #define OPT_WINDOW_TITLE 1001 #define OPT_PUSH_TARGET 1002 @@ -684,6 +717,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[]) { @@ -739,6 +773,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 }, }; @@ -917,6 +953,10 @@ scrcpy_parse_args(struct scrcpy_cli_args *args, int argc, char *argv[]) { opts->v4l2_device = optarg; break; #endif + + case OPT_INTENT_BROADCAST: + opts->intent_broadcast = true; + break; default: // getopt prints the error message on stderr return false; diff --git a/app/src/scrcpy.c b/app/src/scrcpy.c index 179021563b..8d4df813e8 100644 --- a/app/src/scrcpy.c +++ b/app/src/scrcpy.c @@ -278,6 +278,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_broadcast = options->intent_broadcast, }; if (!server_start(&s->server, ¶ms)) { goto end; diff --git a/app/src/scrcpy.h b/app/src/scrcpy.h index 405dc7f3b6..c7f5c58f5e 100644 --- a/app/src/scrcpy.h +++ b/app/src/scrcpy.h @@ -53,6 +53,13 @@ struct sc_port_range { #define SC_WINDOW_POSITION_UNDEFINED (-0x8000) + +enum sc_intent_broadcast { + SC_INTENT_BROADCAST_START = 1 << 0, + SC_INTENT_BROADCAST_STOP = 1 << 31, +}; + + struct scrcpy_options { const char *serial; const char *crop; @@ -93,6 +100,7 @@ struct scrcpy_options { bool forward_all_clicks; bool legacy_paste; bool power_off_on_close; + bool intent_broadcast; }; #define SCRCPY_OPTIONS_DEFAULT { \ @@ -141,6 +149,7 @@ struct scrcpy_options { .forward_all_clicks = false, \ .legacy_paste = false, \ .power_off_on_close = false, \ + .intent_broadcast = false, \ } bool diff --git a/app/src/server.c b/app/src/server.c index 41e8166c0a..7470c5b845 100644 --- a/app/src/server.c +++ b/app/src/server.c @@ -294,6 +294,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", + params->intent_broadcast ? "true" : "false", }; #ifdef SERVER_DEBUGGER LOGI("Server debugger waiting for a client on device port " diff --git a/app/src/server.h b/app/src/server.h index c249b3748d..cd10f83560 100644 --- a/app/src/server.h +++ b/app/src/server.h @@ -49,6 +49,7 @@ struct server_params { bool stay_awake; bool force_adb_forward; bool power_off_on_close; + bool intent_broadcast; }; // init default values diff --git a/server/src/main/java/com/genymobile/scrcpy/CleanUp.java b/server/src/main/java/com/genymobile/scrcpy/CleanUp.java index ec61a1c04d..f1d0a35714 100644 --- a/server/src/main/java/com/genymobile/scrcpy/CleanUp.java +++ b/server/src/main/java/com/genymobile/scrcpy/CleanUp.java @@ -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 Config[] newArray(int size) { } }; - 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_STOPPED = 1 << 3; private int displayId; @@ -47,6 +50,7 @@ public Config[] newArray(int size) { private boolean disableShowTouches; private boolean restoreNormalPowerMode; private boolean powerOffScreen; + private boolean broadcastStopped; public Config() { // Default constructor, the fields are initialized by CleanUp.configure() @@ -59,6 +63,7 @@ protected Config(Parcel in) { disableShowTouches = (options & FLAG_DISABLE_SHOW_TOUCHES) != 0; restoreNormalPowerMode = (options & FLAG_RESTORE_NORMAL_POWER_MODE) != 0; powerOffScreen = (options & FLAG_POWER_OFF_SCREEN) != 0; + broadcastStopped = (options & FLAG_BROADCAST_STOPPED) != 0; } @Override @@ -75,11 +80,14 @@ public void writeToParcel(Parcel dest, int flags) { if (powerOffScreen) { options |= FLAG_POWER_OFF_SCREEN; } + if (broadcastStopped) { + options |= FLAG_BROADCAST_STOPPED; + } dest.writeByte(options); } private boolean hasWork() { - return disableShowTouches || restoreStayOn != -1 || restoreNormalPowerMode || powerOffScreen; + return disableShowTouches || restoreStayOn != -1 || restoreNormalPowerMode || powerOffScreen || broadcastStopped; } @Override @@ -117,7 +125,9 @@ private 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 broadcastStopped + ) throws IOException { Config config = new Config(); config.displayId = displayId; @@ -125,6 +135,7 @@ public static void configure(int displayId, int restoreStayOn, boolean disableSh config.restoreStayOn = restoreStayOn; config.restoreNormalPowerMode = restoreNormalPowerMode; config.powerOffScreen = powerOffScreen; + config.broadcastStopped = broadcastStopped; if (config.hasWork()) { startProcess(config); @@ -187,5 +198,17 @@ public static void main(String... args) { Device.setScreenPowerMode(Device.POWER_MODE_NORMAL); } } + + if(config.broadcastStopped){ + Ln.i("Announce stopped"); + announceScrcpyStopped(); + } + } + + private static void announceScrcpyStopped() { + + Intent cleaned = new Intent(Server.scrcpyPrefix("STOPPED")); + cleaned.setData(Uri.parse("scrcpy-status:stopped")); + Device.sendBroadcast(cleaned); } } diff --git a/server/src/main/java/com/genymobile/scrcpy/Device.java b/server/src/main/java/com/genymobile/scrcpy/Device.java index 3e71fe9ce3..411cee2f51 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Device.java +++ b/server/src/main/java/com/genymobile/scrcpy/Device.java @@ -8,6 +8,7 @@ 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 static void rotateDevice() { public static ContentProvider createSettingsProvider() { return SERVICE_MANAGER.getActivityManager().createSettingsProvider(); } + + public static void sendBroadcast(Intent intent) { + SERVICE_MANAGER.getActivityManager().sendBroadcast(intent); + } } diff --git a/server/src/main/java/com/genymobile/scrcpy/Options.java b/server/src/main/java/com/genymobile/scrcpy/Options.java index cf11df0f19..99549bf6e7 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Options.java +++ b/server/src/main/java/com/genymobile/scrcpy/Options.java @@ -2,6 +2,9 @@ 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 boolean broadcastIntents; public Ln.Level getLogLevel() { return logLevel; @@ -83,6 +87,10 @@ public void setSendFrameMeta(boolean sendFrameMeta) { this.sendFrameMeta = sendFrameMeta; } + public void setBroadcastIntents(boolean broadcastIntents) { + this.broadcastIntents = broadcastIntents; + } + public boolean getControl() { return control; } @@ -138,4 +146,8 @@ public void setPowerOffScreenOnClose(boolean powerOffScreenOnClose) { public boolean getPowerOffScreenOnClose() { return this.powerOffScreenOnClose; } + + public boolean getBroadcastIntents() { + return broadcastIntents; + } } diff --git a/server/src/main/java/com/genymobile/scrcpy/Server.java b/server/src/main/java/com/genymobile/scrcpy/Server.java index fdd9db8877..2e1f806bc3 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Server.java +++ b/server/src/main/java/com/genymobile/scrcpy/Server.java @@ -1,5 +1,7 @@ package com.genymobile.scrcpy; +import android.content.Intent; +import android.net.Uri; import com.genymobile.scrcpy.wrappers.ContentProvider; import android.graphics.Rect; @@ -15,6 +17,8 @@ public final class Server { + public static final String SCRCPY_PREFIX = "com.genymobile.scrcpy."; + private Server() { // not instantiable } @@ -50,7 +54,8 @@ private static void scrcpy(Options options) throws IOException { } } - CleanUp.configure(options.getDisplayId(), restoreStayOn, mustDisableShowTouchesOnCleanUp, true, options.getPowerOffScreenOnClose()); + CleanUp.configure(options.getDisplayId(), restoreStayOn, mustDisableShowTouchesOnCleanUp, true, options.getPowerOffScreenOnClose(), + options.getBroadcastIntents()); boolean tunnelForward = options.isTunnelForward(); @@ -75,6 +80,10 @@ public void onClipboardTextChanged(String text) { }); } + if(options.getBroadcastIntents()){ + announceScrcpyStarting(); + } + try { // synchronous screenEncoder.streamScreen(device, connection.getVideoFd()); @@ -92,6 +101,12 @@ public void onClipboardTextChanged(String text) { } } + private static void announceScrcpyStarting() { + + Intent starting = new Intent(scrcpyPrefix("STARTED")); + starting.setData(Uri.parse("scrcpy-status:started")); + Device.sendBroadcast(starting); + } private static Thread startController(final Controller controller) { Thread thread = new Thread(new Runnable() { @Override @@ -135,7 +150,7 @@ private static Options createOptions(String... args) { "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 +203,9 @@ private static Options createOptions(String... args) { boolean powerOffScreenOnClose = Boolean.parseBoolean(args[15]); options.setPowerOffScreenOnClose(powerOffScreenOnClose); + boolean broadcastIntents = Boolean.parseBoolean(args[16]); + options.setBroadcastIntents(broadcastIntents); + return options; } @@ -254,4 +272,8 @@ public void uncaughtException(Thread t, Throwable e) { scrcpy(options); } + + public static String scrcpyPrefix(String unprefixed){ + return SCRCPY_PREFIX + unprefixed; + } } 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 93ed452875..786c5b2c55 100644 --- a/server/src/main/java/com/genymobile/scrcpy/wrappers/ActivityManager.java +++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/ActivityManager.java @@ -2,7 +2,9 @@ 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 @@ private Method getRemoveContentProviderExternalMethod() throws NoSuchMethodExcep 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 @@ void removeContentProviderExternal(String name, IBinder token) { 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); + } + } }