Compare commits

...

60 Commits

Author SHA1 Message Date
0200760f87 Determine debugger command at runtime
When server_debugger is enabled, retrieve the device SDK version to
execute the correct command.

PR #5466 <https://github.com/Genymobile/scrcpy/pull/5466>
2024-11-17 15:01:05 +01:00
bd9d93194b Pass Options instance directly
Many constructors take a lot of parameters copied from Options. For
simplicity, just pass the Options instance.
2024-11-15 20:16:04 +01:00
794595e3f0 Set displayId to NONE in Options on new display
If a new display is set, force options.getDisplayId() to return
Device.DISPLAY_ID_NONE, to avoid any confusion between a local displayId
and options.getDisplayId().
2024-11-15 20:16:04 +01:00
5e10c37f02 Define all DisplayManager flags locally
For consistency.
2024-11-15 20:16:04 +01:00
0e399b65bd Remove [] around app package names
This simplifies copy-pasting from the result of:

    scrcpy --list-apps
2024-11-15 20:16:04 +01:00
2337f524d1 Improve error message on unknown camera id
If the camera id is explicitly provided (via --camera-id), report a
user-friendly error if no camera with this id is found.
2024-11-15 20:16:04 +01:00
df74cceb6f Use camera prepare() step
For consistency with screen capture.

Refs b60e174780
2024-11-15 20:16:04 +01:00
91373d906b Add FakeContext.getContentResolver()
This avoids the following error on some devices:

    Given calling package android does not match caller's uid 2000

Refs #4639 comment <https://github.com/Genymobile/scrcpy/issues/4639#issuecomment-2466081589>
Fixes #4639 <https://github.com/Genymobile/scrcpy/issues/4639>
PR #5476 <https://github.com/Genymobile/scrcpy/pull/5476>

Signed-off-by: Romain Vimont <rom@rom1v.com>
2024-11-14 10:29:03 +01:00
c0e2e27cf9 Force javac to use UTF-8
The source files are encoded in UTF-8.

Refs <https://github.com/Genymobile/scrcpy/issues/4639#issuecomment-2467206100>

Signed-off-by: Romain Vimont <rom@rom1v.com>
2024-11-11 09:00:40 +01:00
eff5b4b219 Add --screen-off-timeout
Change the Android "screen off timeout" (the idle delay before the
screen automatically turns off) and restore the initial value on
exit.

PR #5447 <https://github.com/Genymobile/scrcpy/pull/5447>
2024-11-09 10:26:59 +01:00
d3db9c4065 Refactor clean up configuration to simplify
All options were configured dynamically by sending a single byte to an
output stream. But in practice, only the power mode must be changed
dynamically, the others are configured once on start.

For simplicity, pass the value of static options as command line
arguments, and handle dynamic options in a loop only from a separate
thread once the clean up process is started.

This will allow to easily add cleanup options with values which do not
fit in 1 byte.

Also handle the clean up thread (and the loading of initial settings
values) from the CleanUp class, to expose a simpler clean up API.

Refs 9efa162949
PR #5447 <https://github.com/Genymobile/scrcpy/pull/5447>
2024-11-09 10:26:43 +01:00
5936167ff7 Store compensation state as a boolean
We don't need to store the last compensation value anymore, we just need
to know if it's non-zero.
2024-11-04 23:17:43 +01:00
e9dd0f68ad Fix audio regulator compensation
A call to swr_set_compensation() configures the resampler to drop or
duplicate "diff" samples over an interval of "distance" samples.

If the function is not called again, then after "distance" samples, no
more compensation will be applied. So it must always be called, even if
the new computed diff value happens to be the same as the previous one.

In practice, it is unlikely that the diff value is exactly the same
every second, except when it is actively clamped (to 2% of the sample
rate).
2024-11-04 23:09:54 +01:00
104195fc3b Add shortcut to reset video capture/encoding
Reset video capture/encoding on MOD+Shift+r.

Like on device rotation, this starts a new encoding session which
produces a video stream starting by a key frame.

PR #5432 <https://github.com/Genymobile/scrcpy/pull/5432>
2024-11-03 19:31:02 +01:00
9958302e6f Interrupt MediaCodec blocking call on reset
When the MediaCodec input is a Surface, no EOS (end-of-stream) will
never occur automatically: it may only be triggered manually by
MediaCodec.signalEndOfInputStream().

Use this signal to interrupt the blocking call to dequeueOutputBuffer()
immediately on reset, without waiting for the next frame to be dequeued.

PR #5432 <https://github.com/Genymobile/scrcpy/pull/5432>
2024-11-03 19:27:11 +01:00
69b836930a Handle capture reset via listener
When the capture source becomes "invalid" (because the display size
changes for example), a reset request is performed to restart the
encoder.

The reset state was stored in SurfaceCapture. The capture implementation
set the flag, and the encoder consumed it.

However, this mechanism did not allow a reset request to _interrupt_ the
encoder, which may be waiting on a blocking call (until a new frame is
produced).

To be able to interrupt the encoder, a reset request must not only set a
flag, but run a callback provided by the encoder. For that purpose,
introduce the CaptureListener interface, which is notified by the
SurfaceCapture implementation whenever the capture is invalidated.

For now, the listener implementation just set a flag as before, so the
behavior is unchanged. It lays the groundwork for the next commits.

PR #5432 <https://github.com/Genymobile/scrcpy/pull/5432>
2024-11-03 19:26:55 +01:00
790ea5e58c Check screen on for current displayId
Since Android 14, the "screen on" state can be checked per-display.

Refs <956f4084df%5E!/#F17>
PR #5442 <https://github.com/Genymobile/scrcpy/pull/5442>
2024-11-03 19:10:44 +01:00
1270997f6b Remove useless assignment
The local variable virtualDisplayId was already initialized to the exact
same value.
2024-11-03 19:02:57 +01:00
c905fbba8d Fix indentation 2024-11-03 19:02:37 +01:00
f08a6d86c5 Power on the device only for main display
Power on the device on start only if scrcpy is mirroring the main
display.
2024-11-02 18:51:05 +01:00
3ac4b64461 Register rotation watcher for non-main displays
While moving code, commit 874eaec487 added
a condition `if (displayId == 0)` to register a rotation watcher,
without good reasons.

This condition was kept when the rotation watcher was moved to a
fallback in e26bdb07a2.

Note: use `git show -b` to show this commit ignoring whitespace changes.

Refs #5428 <https://github.com/Genymobile/scrcpy/issues/5428>
2024-11-02 18:49:08 +01:00
c7378f4dc8 Extract setting display power to a separate method
For consistency with the other actions.
2024-10-31 22:49:03 +01:00
e26bdb07a2 Listen to display changed events
Replace RotationWatcher and DisplayFoldListener by a single
DisplayListener, which is notified whenever the display size or dpi
changes.

However, the DisplayListener mechanism is broken in the first versions
of Android 14 (it is fixed in android-14.0.0_r29 by commit [1]), so
continue to use the old mechanism specifically for Android 14 (where
DisplayListener may be broken), until we receive the first
"display changed" event (which proves that it works).

[1]: <5653c6b587%5E%21/>

Fixes #161 <https://github.com/Genymobile/scrcpy/issues/161>
Fixes #1918 <https://github.com/Genymobile/scrcpy/issues/1918>
Fixes #4152 <https://github.com/Genymobile/scrcpy/issues/4152>
Fixes #5362 comment <https://github.com/Genymobile/scrcpy/issues/5362#issuecomment-2416219316>
Refs #4469 <https://github.com/Genymobile/scrcpy/pull/4469>
PR #5415 <https://github.com/Genymobile/scrcpy/pull/5415>

Co-authored-by: Simon Chan <1330321+yume-chan@users.noreply.github.com>
2024-10-31 20:12:23 +01:00
04a3e6fb06 Consume reset request on encoding start
If a reset request is pending when a new encoding starts, then it is
implicitly fulfilled.

PR #5415 <https://github.com/Genymobile/scrcpy/pull/5415>
2024-10-31 20:12:23 +01:00
c29ecd0314 Rename --display-buffer to --video-buffer
For consistency with --audio-buffer, rename --display-buffer to
--video-buffer.

Fixes #5403 <https://github.com/Genymobile/scrcpy/issues/5403>
PR #5420 <https://github.com/Genymobile/scrcpy/pull/5420>
2024-10-31 19:57:52 +01:00
d62fa8880e Disable broken options on Android 14
The options --lock-video-orientation and --crop are broken since Android
14. Hopefully, they will be reimplemented differently.

Meanwhile, when running Android >= 14, fail with an error to prevent
incorrect behavior.

Refs #4011 <https://github.com/Genymobile/scrcpy/issues/4011>
Refs #4162 <https://github.com/Genymobile/scrcpy/issues/4162>
PR #5417 <https://github.com/Genymobile/scrcpy/pull/5417>
2024-10-31 19:55:47 +01:00
1f6634ea87 Document adb shell settings commands
Some scrcpy features change Android settings with `adb shell settings`.
Document the commands to execute manually.
2024-10-30 22:23:53 +01:00
58ba00fa06 Adapt "turn screen off" for Android 15
Android 15 introduced an easy way to set the display power:
<fd8b5efc7f%5E!/#F17>

Refs #3927 <https://github.com/Genymobile/scrcpy/issues/3927>
Refs <https://issuetracker.google.com/issues/303565669>
PR #5418 <https://github.com/Genymobile/scrcpy/pull/5418>
2024-10-30 19:38:35 +01:00
569c37cec1 Disable display power for virtual displays
If displayId == Device.DISPLAY_ID_NONE, then the display is virtual: its
power mode cannot be changed.

PR #5418 <https://github.com/Genymobile/scrcpy/pull/5418>
2024-10-30 19:38:07 +01:00
58a0fbbf2e Refactor display power mode
Accept a single boolean "on" rather than a "mode" (which, in practice,
could only take 2 values: NORMAL and OFF).

Also rename "screen power mode" to "display power".

PR #5418 <https://github.com/Genymobile/scrcpy/pull/5418>
2024-10-30 19:38:04 +01:00
acff5b005c Add more details to --list-encoders output
Add more information about each codec (hw/sw, vendor, alias).

Before:

    [server] INFO: List of video encoders:
        --video-codec=h264 --video-encoder=c2.exynos.h264.encoder
        --video-codec=h264 --video-encoder=c2.android.avc.encoder
        --video-codec=h264 --video-encoder=OMX.google.h264.encoder
        --video-codec=h265 --video-encoder=c2.exynos.hevc.encoder
        --video-codec=h265 --video-encoder=c2.android.hevc.encoder
        --video-codec=av1 --video-encoder=c2.google.av1.encoder
        --video-codec=av1 --video-encoder=c2.android.av1.encoder
    // audio encoders omitted

After:

    [server] INFO: List of video encoders:
        --video-codec=h264 --video-encoder=c2.exynos.h264.encoder         (hw) [vendor]
        --video-codec=h264 --video-encoder=c2.android.avc.encoder         (sw)
        --video-codec=h264 --video-encoder=OMX.google.h264.encoder        (sw) (alias for c2.android.avc.encoder)
        --video-codec=h265 --video-encoder=c2.exynos.hevc.encoder         (hw) [vendor]
        --video-codec=h265 --video-encoder=c2.android.hevc.encoder        (sw)
        --video-codec=av1 --video-encoder=c2.google.av1.encoder           (hw) [vendor]
        --video-codec=av1 --video-encoder=c2.android.av1.encoder          (sw)
    // audio encoders omitted

PR #5416 <https://github.com/Genymobile/scrcpy/pull/5416>
2024-10-30 18:51:00 +01:00
5474ae6bd6 Factorize codec info listing
Make the listing of video and audio encoders share the same code.

PR #5416 <https://github.com/Genymobile/scrcpy/pull/5416>
2024-10-30 18:50:47 +01:00
2c25fd7a80 Disable mouse by default if no video playback
If video playback is disabled, then SDK mouse (which uses absolute
positions) could not be used, so the default mouse mode was
automatically switched to UHID.

But UHID does not work on all devices, so it could make the whole scrcpy
session fail.

Instead, disable the mouse by default. It is still possible to pass -M
or --mouse=uhid to enable it explicitly.

Fixes #5410 <https://github.com/Genymobile/scrcpy/issues/5410>
2024-10-29 18:59:29 +01:00
ce21f515e3 Remove unnecessary '\n' in log 2024-10-29 18:58:54 +01:00
381fe95867 Document virtual display and "start app" features
PR #5370 <https://github.com/Genymobile/scrcpy/pull/5370>
2024-10-28 18:18:36 +01:00
566b5be0f6 Add option to start an app by its name
By adding the '?' prefix, the app is searched by its name instead of its
package name (retrieving app names on the device may take some time):

    scrcpy --start-app=?firefox

An app matches if its label starts with the given name,
case-insensitive.

If '+' is also passed to force-stop the app before starting, then the
prefixes must be in that order:

    scrcpy --start-app=+?firefox

PR #5370 <https://github.com/Genymobile/scrcpy/pull/5370>
2024-10-28 18:18:36 +01:00
dd20efa41c Add option to force-stop app before starting
The previous commit introduced:

    scrcpy --start-app=name

By adding a '+' prefix, the app is stopped beforehand:

    scrcpy --start-app=+name

This may be useful to start a fresh app on a new virtual display:

    scrcpy --new-display --start-app=+org.mozilla.firefox

PR #5370 <https://github.com/Genymobile/scrcpy/pull/5370>
2024-10-28 18:18:36 +01:00
13ce277e1f 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>
2024-10-28 18:18:36 +01:00
9c9d92fb1c Add --list-apps
Add an option to list all apps installed on the device:

    scrcpy --list-apps

PR #5370 <https://github.com/Genymobile/scrcpy/pull/5370>
2024-10-28 18:18:36 +01:00
408a388fc5 Reject --new-display for Android <= 10
Fail explicitly if a new virtual display is requested on an Android
version lower than 10.

PR #5370 <https://github.com/Genymobile/scrcpy/pull/5370>
2024-10-28 18:18:36 +01:00
98ed5eb643 Add virtual display feature
Add a feature to create a new (separate) virtual display instead of
mirroring the device screen:

    scrcpy --new-display=1920x1080
    scrcpy --new-display=1920x1080/420  # force 420 dpi
    scrcpy --new-display         # use the main display size and density
    scrcpy --new-display -m1920  # scaled to fit a max size of 1920
    scrcpy --new-display=/240    # use the main display size and 240 dpi

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

Co-authored-by: Simon Chan <1330321+yume-chan@users.noreply.github.com>
Co-authored-by: anirudhb <anirudhb@users.noreply.github.com>
2024-10-28 18:18:34 +01:00
5d0e012a4c Add DPI to DisplayInfo
It will be useful to automatically set an appropriate DPI for new
virtual displays.

PR #5370 <https://github.com/Genymobile/scrcpy/pull/5370>
2024-10-28 18:13:55 +01:00
d19396718e Inject display-related events to virtual display
Mouse and touch events must be sent to the virtual display id (used for
mirroring), other events (like key events) must be sent to the original
display id.

Fixes #4598 <https://github.com/Genymobile/scrcpy/issues/4598>
Fixes #5137 <https://github.com/Genymobile/scrcpy/issues/5137>
PR #5370 <https://github.com/Genymobile/scrcpy/pull/5370>

Co-authored-by: nightmare <mengyanshou@gmail.com>
2024-10-28 18:13:55 +01:00
7024d38199 Send PositionMapper to Controller directly
When a new capture starts, send a new PositionMapper to the Controller
without using the global Device as an intermediate.

Now all Device methods are static.

PR #5370 <https://github.com/Genymobile/scrcpy/pull/5370>
2024-10-28 18:13:55 +01:00
f1368d9a8f Introduce PositionMapper
Extract the function that converts coordinates from video space to
display space into a separate component.

It only requires the specific data it uses and does not need a full
ScreenInfo object (although it can be created from a ScreenInfo
instance).

PR #5370 <https://github.com/Genymobile/scrcpy/pull/5370>
2024-10-28 18:13:55 +01:00
d916429566 Move clipboard management to Controller
Continue to declutter the global Device.

PR #5370 <https://github.com/Genymobile/scrcpy/pull/5370>
2024-10-28 18:13:55 +01:00
7cfefae5e1 Move implicit displayId to Controller
Remove from Device the functions using an implicit displayId.

Move them to Controller, which knows best which displayId it must use.

This will allow to properly dispatch events either to the origin display
or to the virtual display created for mirroring.

PR #5370 <https://github.com/Genymobile/scrcpy/pull/5370>
2024-10-28 18:13:55 +01:00
b60e174780 Add capture prepare() step
Add a function called before each capture starts (before getSize() is
called).

This allows to compute the ScreenInfo instance once exactly when needed.

PR #5370 <https://github.com/Genymobile/scrcpy/pull/5370>
2024-10-28 18:13:55 +01:00
5851b62580 Simplify virtual display video size
Do not use an unnecessary intermediate Rect object.

PR #5370 <https://github.com/Genymobile/scrcpy/pull/5370>
2024-10-28 18:13:55 +01:00
12d5ca4d5e Move local variables in ScreenCapture
Do not initialize variables when they are not used.

PR #5370 <https://github.com/Genymobile/scrcpy/pull/5370>
2024-10-28 18:13:55 +01:00
68e54d9b0b Refactor to call getSize() only once
Avoid to call capture.getSize() (provided by the SurfaceCapture
implementation) twice.

PR #5370 <https://github.com/Genymobile/scrcpy/pull/5370>
2024-10-28 18:13:55 +01:00
5f0480c039 Ignore first displayFoldChanged event
An event is posted on registration to signal the initial state.

This had no impact when the listener was registered from Device (before
it was moved to ScreenCapture), because this first initial event was
already triggered when ScreenCapture started listening.

But now, it causes the first encoding to be reset immediately. To avoid
that, ignore the first event.

Refs <https://android.googlesource.com/platform/frameworks/base.git/+/refs/tags/android-15.0.0_r3/services/core/java/com/android/server/policy/DisplayFoldController.java#138>
PR #5370 <https://github.com/Genymobile/scrcpy/pull/5370>
2024-10-28 18:13:55 +01:00
874eaec487 Move screen-related features out of Device.java
Move the code related to screen size and rotation/fold to ScreenCapture.

For now, keep the ScreenInfo instance in the Device class to communicate
with the Controller, but it will be removed by further commits.

PR #5370 <https://github.com/Genymobile/scrcpy/pull/5370>
2024-10-28 18:13:55 +01:00
14e5439dee Update mouse documentation about capture key
The mouse capture keys are not hardcoded anymore, they use the
configured shortcut modifiers.

Refs ff9fb5994d
2024-10-28 14:55:55 +01:00
a5844e198e Add GitHub Actions release workflow
Fixes #4490 <https://github.com/Genymobile/scrcpy/issues/4490>
PR #5306 <https://github.com/Genymobile/scrcpy/pull/5306>
2024-10-27 20:31:24 +01:00
2687d20280 Rework release.mk for CI
Make it possible to build scrcpy-server and Windows binaries in
parallel from different GitHub Actions workflows, and to package
everything as a final step.

PR #5306 <https://github.com/Genymobile/scrcpy/pull/5306>
2024-10-27 20:29:13 +01:00
9c0a328498 Build server without meson in release.mk
This avoids to install meson/ninja to build scrcpy-server on the CI.

PR #5306 <https://github.com/Genymobile/scrcpy/pull/5306>
2024-10-27 20:26:38 +01:00
02ef3d57ce Split client and server tests in release.mk
This will allow to run server tests separately on the CI.

PR #5306 <https://github.com/Genymobile/scrcpy/pull/5306>
2024-10-27 20:26:26 +01:00
538a32a539 Fix .PHONY in release.mk
The prepare-deps recipe does not exist anymore. It has been split into
prepare-deps-win32 and prepare-deps-win64.

PR #5306 <https://github.com/Genymobile/scrcpy/pull/5306>
2024-10-27 20:26:13 +01:00
9578aae34e Use explicit constant for @TargetApi 2024-10-22 19:47:56 +02:00
58 changed files with 2417 additions and 818 deletions

147
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,147 @@
name: Build
on:
workflow_dispatch:
inputs:
name:
description: 'Version name (default is ref name)'
jobs:
build-scrcpy-server:
runs-on: ubuntu-latest
env:
GRADLE: gradle # use native gradle instead of ./gradlew in release.mk
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup JDK
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- name: Test scrcpy-server
run: make -f release.mk test-server
- name: Build scrcpy-server
run: make -f release.mk build-server
- name: Upload scrcpy-server artifact
uses: actions/upload-artifact@v4
with:
name: scrcpy-server
path: build-server/server/scrcpy-server
test-client:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt update
sudo apt install -y meson ninja-build nasm ffmpeg libsdl2-2.0-0 \
libsdl2-dev libavcodec-dev libavdevice-dev libavformat-dev \
libavutil-dev libswresample-dev libusb-1.0-0 libusb-1.0-0-dev
- name: Build
run: |
meson setup d -Db_sanitize=address,undefined
- name: Test
run: |
meson test -Cd
build-win32:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt update
sudo apt install -y meson ninja-build nasm ffmpeg libsdl2-2.0-0 \
libsdl2-dev libavcodec-dev libavdevice-dev libavformat-dev \
libavutil-dev libswresample-dev libusb-1.0-0 libusb-1.0-0-dev \
mingw-w64 mingw-w64-tools libz-mingw-w64-dev
- name: Workaround for old meson version run by Github Actions
run: sed -i 's/^pkg-config/pkgconfig/' cross_win32.txt
- name: Build scrcpy win32
run: make -f release.mk build-win32
- name: Upload build-win32 artifact
uses: actions/upload-artifact@v4
with:
name: build-win32-intermediate
path: build-win32/dist/
build-win64:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt update
sudo apt install -y meson ninja-build nasm ffmpeg libsdl2-2.0-0 \
libsdl2-dev libavcodec-dev libavdevice-dev libavformat-dev \
libavutil-dev libswresample-dev libusb-1.0-0 libusb-1.0-0-dev \
mingw-w64 mingw-w64-tools libz-mingw-w64-dev
- name: Workaround for old meson version run by Github Actions
run: sed -i 's/^pkg-config/pkgconfig/' cross_win64.txt
- name: Build scrcpy win64
run: make -f release.mk build-win64
- name: Upload build-win64 artifact
uses: actions/upload-artifact@v4
with:
name: build-win64-intermediate
path: build-win64/dist/
package:
needs:
- build-scrcpy-server
- build-win32
- build-win64
runs-on: ubuntu-latest
env:
# $VERSION is used by release.mk
VERSION: ${{ github.event.inputs.name || github.ref_name }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download scrcpy-server
uses: actions/download-artifact@v4
with:
name: scrcpy-server
path: build-server/server/
- name: Download build-win32
uses: actions/download-artifact@v4
with:
name: build-win32-intermediate
path: build-win32/dist/
- name: Download build-win64
uses: actions/download-artifact@v4
with:
name: build-win64-intermediate
path: build-win64/dist/
- name: Package
run: make -f release.mk package
- name: Upload release artifact
uses: actions/upload-artifact@v4
with:
name: scrcpy-release-${{ env.VERSION }}
path: release-${{ env.VERSION }}

View File

@ -31,6 +31,7 @@ It focuses on:
Its features include:
- [audio forwarding](doc/audio.md) (Android 11+)
- [recording](doc/recording.md)
- [virtual display](doc/virtual_display.md)
- mirroring with [Android device screen off](doc/device.md#turn-screen-off)
- [copy-paste](doc/control.md#copy-paste) in both directions
- [configurable quality](doc/video.md)
@ -91,6 +92,12 @@ Here are just some common examples.
scrcpy --video-codec=h265 -m1920 --max-fps=60 --no-audio -K # short version
```
- Start VLC in a new virtual display (separate from the device display):
```bash
scrcpy --new-display=1920x1080 --start-app=org.videolan.vlc
```
- Record the device camera in H.265 at 1920x1080 (and microphone) to an MP4
file:
@ -134,6 +141,7 @@ documented in the following pages:
- [Device](doc/device.md)
- [Window](doc/window.md)
- [Recording](doc/recording.md)
- [Virtual display](doc/virtual_displays.md)
- [Tunnels](doc/tunnels.md)
- [OTG](doc/otg.md)
- [Camera](doc/camera.md)

View File

@ -20,7 +20,6 @@ _scrcpy() {
--crop=
-d --select-usb
--disable-screensaver
--display-buffer=
--display-id=
--display-orientation=
-e --select-tcpip
@ -33,6 +32,7 @@ _scrcpy() {
--keyboard=
--kill-adb-on-close
--legacy-paste
--list-apps
--list-camera-sizes
--list-cameras
--list-displays
@ -46,6 +46,8 @@ _scrcpy() {
--mouse-bind=
-n --no-control
-N --no-playback
--new-display
--new-display=
--no-audio
--no-audio-playback
--no-cleanup
@ -75,7 +77,9 @@ _scrcpy() {
--rotation=
-s --serial=
-S --turn-screen-off
--screen-off-timeout=
--shortcut-mod=
--start-app=
-t --show-touches
--tcpip
--tcpip=
@ -86,6 +90,7 @@ _scrcpy() {
--v4l2-sink=
-v --version
-V --verbosity=
--video-buffer=
--video-codec=
--video-codec-options=
--video-encoder=
@ -187,7 +192,6 @@ _scrcpy() {
|--camera-size \
|--crop \
|--display-id \
|--display-buffer \
|--max-fps \
|-m|--max-size \
|-p|--port \
@ -197,6 +201,7 @@ _scrcpy() {
|--tunnel-port \
|--v4l2-buffer \
|--v4l2-sink \
|--video-buffer \
|--video-codec-options \
|--video-encoder \
|--tcpip \

View File

@ -27,7 +27,6 @@ arguments=(
'--crop=[\[width\:height\:x\:y\] Crop the device screen on the server]'
{-d,--select-usb}'[Use USB device]'
'--disable-screensaver[Disable screensaver while scrcpy is running]'
'--display-buffer=[Add a buffering delay \(in milliseconds\) before displaying]'
'--display-id=[Specify the display id to mirror]'
'--display-orientation=[Set the initial display orientation]:orientation values:(0 90 180 270 flip0 flip90 flip180 flip270)'
{-e,--select-tcpip}'[Use TCP/IP device]'
@ -40,6 +39,7 @@ arguments=(
'--keyboard=[Set the keyboard input mode]:mode:(disabled sdk uhid aoa)'
'--kill-adb-on-close[Kill adb when scrcpy terminates]'
'--legacy-paste[Inject computer clipboard text as a sequence of key events on Ctrl+v]'
'--list-apps[List Android apps installed on the device]'
'--list-camera-sizes[List the valid camera capture sizes]'
'--list-cameras[List cameras available on the device]'
'--list-displays[List displays available on the device]'
@ -52,6 +52,7 @@ arguments=(
'--mouse-bind=[Configure bindings of secondary clicks]'
{-n,--no-control}'[Disable device control \(mirror the device in read only\)]'
{-N,--no-playback}'[Disable video and audio playback]'
'--new-display=[Create a new display]'
'--no-audio[Disable audio forwarding]'
'--no-audio-playback[Disable audio playback]'
'--no-cleanup[Disable device cleanup actions on exit]'
@ -79,7 +80,9 @@ arguments=(
'--require-audio=[Make scrcpy fail if audio is enabled but does not work]'
{-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]'
'--screen-off-timeout=[Set the screen off timeout in seconds]'
'--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]'
@ -89,6 +92,7 @@ arguments=(
'--v4l2-sink=[\[\/dev\/videoN\] Output to v4l2loopback device]'
{-v,--version}'[Print the version of scrcpy]'
{-V,--verbosity=}'[Set the log level]:verbosity:(verbose debug info warn error)'
'--video-buffer=[Add a buffering delay \(in milliseconds\) before displaying video frames]'
'--video-codec=[Select the video codec]:codec:(h264 h265 av1)'
'--video-codec-options=[Set a list of comma-separated key\:type=value options for the device video encoder]'
'--video-encoder=[Use a specific MediaCodec video encoder]'

View File

@ -167,9 +167,6 @@ conf.set('DEFAULT_LOCAL_PORT_RANGE_LAST', '27199')
# run a server debugger and wait for a client to be attached
conf.set('SERVER_DEBUGGER', get_option('server_debugger'))
# select the debugger method ('old' for Android < 9, 'new' for Android >= 9)
conf.set('SERVER_DEBUGGER_METHOD_NEW', get_option('server_debugger_method') == 'new')
# enable V4L2 support (linux only)
conf.set('HAVE_V4L2', v4l2_support)

View File

@ -139,12 +139,6 @@ Also see \fB\-e\fR (\fB\-\-select\-tcpip\fR).
.BI "\-\-disable\-screensaver"
Disable screensaver while scrcpy is running.
.TP
.BI "\-\-display\-buffer " ms
Add a buffering delay (in milliseconds) before displaying. This increases latency to compensate for jitter.
Default is 0 (no buffering).
.TP
.BI "\-\-display\-id " id
Specify the device display id to mirror.
@ -227,6 +221,10 @@ Inject computer clipboard text as a sequence of key events on Ctrl+v (like MOD+S
This is a workaround for some devices not behaving as expected when setting the device clipboard programmatically.
.TP
.B \-\-list\-apps
List Android apps installed on the device.
.TP
.B \-\-list\-camera\-sizes
List the valid camera capture sizes.
@ -314,6 +312,18 @@ Disable device control (mirror the device in read\-only).
.B \-N, \-\-no\-playback
Disable video and audio playback on the computer (equivalent to \fB\-\-no\-video\-playback \-\-no\-audio\-playback\fR).
.TP
\fB\-\-new\-display\fR[=[\fIwidth\fRx\fIheight\fR][/\fIdpi\fR]]
Create a new display with the specified resolution and density. If not provided, they default to the main display dimensions and DPI, and \fB\-\-max\-size\fR is considered.
Examples:
\-\-new\-display=1920x1080
\-\-new\-display=1920x1080/420
\-\-new\-display # main display size and density
\-\-new\-display -m1920 # scaled to fit a max size of 1920
\-\-new\-display=/240 # main display size and 240 dpi
.TP
.B \-\-no\-audio
Disable audio forwarding.
@ -478,6 +488,22 @@ 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.
Add a '?' prefix to select an app whose name starts with the given name, case-insensitive (retrieving app names on the device may take some time):
scrcpy --start-app=?firefox
Add a '+' prefix to force-stop before starting the app:
scrcpy --new-display --start-app=+org.mozilla.firefox
Both prefixes can be used, in that order:
scrcpy --start-app=+?firefox
.TP
.B \-t, \-\-show\-touches
Enable "show touches" on start, restore the initial value on exit.
@ -528,7 +554,15 @@ It requires to lock the video orientation (see \fB\-\-lock\-video\-orientation\f
.BI "\-\-v4l2-buffer " ms
Add a buffering delay (in milliseconds) before pushing frames. This increases latency to compensate for jitter.
This option is similar to \fB\-\-display\-buffer\fR, but specific to V4L2 sink.
This option is similar to \fB\-\-video\-buffer\fR, but specific to V4L2 sink.
Default is 0 (no buffering).
.TP
.BI "\-\-video\-buffer " ms
Add a buffering delay (in milliseconds) before displaying video frames.
This increases latency to compensate for jitter.
Default is 0 (no buffering).
@ -637,6 +671,10 @@ Pause or re-pause display
.B MOD+Shift+z
Unpause display
.TP
.B MOD+Shift+r
Reset video capture/encoding
.TP
.B MOD+g
Resize window to 1:1 (pixel\-perfect)

View File

@ -288,7 +288,7 @@ sc_audio_regulator_push(struct sc_audio_regulator *ar, const AVFrame *frame) {
// Enable compensation when the difference exceeds +/- 4ms.
// Disable compensation when the difference is lower than +/- 1ms.
int threshold = ar->compensation != 0
int threshold = ar->compensation_active
? ar->sample_rate / 1000 /* 1ms */
: ar->sample_rate * 4 / 1000; /* 4ms */
@ -309,14 +309,12 @@ sc_audio_regulator_push(struct sc_audio_regulator *ar, const AVFrame *frame) {
LOGV("[Audio] Buffering: target=%" PRIu32 " avg=%f cur=%" PRIu32
" compensation=%d", ar->target_buffering, avg, can_read, diff);
if (diff != ar->compensation) {
int ret = swr_set_compensation(swr_ctx, diff, distance);
if (ret < 0) {
LOGW("Resampling compensation failed: %d", ret);
// not fatal
} else {
ar->compensation = diff;
}
int ret = swr_set_compensation(swr_ctx, diff, distance);
if (ret < 0) {
LOGW("Resampling compensation failed: %d", ret);
// not fatal
} else {
ar->compensation_active = diff != 0;
}
}
@ -392,7 +390,7 @@ sc_audio_regulator_init(struct sc_audio_regulator *ar, size_t sample_size,
atomic_init(&ar->played, false);
atomic_init(&ar->received, false);
atomic_init(&ar->underflow, 0);
ar->compensation = 0;
ar->compensation_active = false;
return true;

View File

@ -44,8 +44,8 @@ struct sc_audio_regulator {
// Number of silence samples inserted since the last received packet
atomic_uint_least32_t underflow;
// Current applied compensation value (only used by the receiver thread)
int compensation;
// Non-zero compensation applied (only used by the receiver thread)
bool compensation_active;
// Set to true the first time a sample is received
atomic_bool received;

View File

@ -50,6 +50,7 @@ enum {
OPT_POWER_OFF_ON_CLOSE,
OPT_V4L2_SINK,
OPT_DISPLAY_BUFFER,
OPT_VIDEO_BUFFER,
OPT_V4L2_BUFFER,
OPT_TUNNEL_HOST,
OPT_TUNNEL_PORT,
@ -102,6 +103,10 @@ enum {
OPT_NO_MOUSE_HOVER,
OPT_AUDIO_DUP,
OPT_GAMEPAD,
OPT_NEW_DISPLAY,
OPT_LIST_APPS,
OPT_START_APP,
OPT_SCREEN_OFF_TIMEOUT,
};
struct sc_option {
@ -318,12 +323,10 @@ static const struct sc_option options[] = {
.argdesc = "id",
},
{
// deprecated
.longopt_id = OPT_DISPLAY_BUFFER,
.longopt = "display-buffer",
.argdesc = "ms",
.text = "Add a buffering delay (in milliseconds) before displaying. "
"This increases latency to compensate for jitter.\n"
"Default is 0 (no buffering).",
},
{
.longopt_id = OPT_DISPLAY_ID,
@ -442,6 +445,11 @@ static const struct sc_option options[] = {
"This is a workaround for some devices not behaving as "
"expected when setting the device clipboard programmatically.",
},
{
.longopt_id = OPT_LIST_APPS,
.longopt = "list-apps",
.text = "List Android apps installed on the device.",
},
{
.longopt_id = OPT_LIST_CAMERAS,
.longopt = "list-cameras",
@ -557,6 +565,21 @@ static const struct sc_option options[] = {
.text = "Disable video and audio playback on the computer (equivalent "
"to --no-video-playback --no-audio-playback).",
},
{
.longopt_id = OPT_NEW_DISPLAY,
.longopt = "new-display",
.argdesc = "[<width>x<height>][/<dpi>]",
.optional_arg = true,
.text = "Create a new display with the specified resolution and "
"density. If not provided, they default to the main display "
"dimensions and DPI, and --max-size is considered.\n"
"Examples:\n"
" --new-display=1920x1080\n"
" --new-display=1920x1080/420 # force 420 dpi\n"
" --new-display # main display size and density\n"
" --new-display -m1920 # scaled to fit a max size of 1920\n"
" --new-display=/240 # main display size and 240 dpi",
},
{
.longopt_id = OPT_NO_AUDIO,
.longopt = "no-audio",
@ -771,6 +794,13 @@ static const struct sc_option options[] = {
.longopt = "turn-screen-off",
.text = "Turn the device screen off immediately.",
},
{
.longopt_id = OPT_SCREEN_OFF_TIMEOUT,
.longopt = "screen-off-timeout",
.argdesc = "seconds",
.text = "Set the screen off timeout while scrcpy is running (restore "
"the initial value on exit).",
},
{
.longopt_id = OPT_SHORTCUT_MOD,
.longopt = "shortcut-mod",
@ -784,6 +814,20 @@ 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.\n"
"Add a '?' prefix to select an app whose name starts with the "
"given name, case-insensitive (retrieving app names on the "
"device may take some time):\n"
" scrcpy --start-app=?firefox\n"
"Add a '+' prefix to force-stop before starting the app:\n"
" scrcpy --new-display --start-app=+org.mozilla.firefox\n"
"Both prefixes can be used, in that order:\n"
" scrcpy --start-app=+?firefox",
},
{
.shortopt = 't',
.longopt = "show-touches",
@ -861,11 +905,20 @@ static const struct sc_option options[] = {
.argdesc = "ms",
.text = "Add a buffering delay (in milliseconds) before pushing "
"frames. This increases latency to compensate for jitter.\n"
"This option is similar to --display-buffer, but specific to "
"This option is similar to --video-buffer, but specific to "
"V4L2 sink.\n"
"Default is 0 (no buffering).\n"
"This option is only available on Linux.",
},
{
.longopt_id = OPT_VIDEO_BUFFER,
.longopt = "video-buffer",
.argdesc = "ms",
.text = "Add a buffering delay (in milliseconds) before displaying "
"video frames.\n"
"This increases latency to compensate for jitter.\n"
"Default is 0 (no buffering).",
},
{
.longopt_id = OPT_VIDEO_CODEC,
.longopt = "video-codec",
@ -977,6 +1030,10 @@ static const struct sc_shortcut shortcuts[] = {
.shortcuts = { "MOD+Shift+z" },
.text = "Unpause display",
},
{
.shortcuts = { "MOD+Shift+r" },
.text = "Reset video capture/encoding",
},
{
.shortcuts = { "MOD+g" },
.text = "Resize window to 1:1 (pixel-perfect)",
@ -2106,6 +2163,20 @@ parse_time_limit(const char *s, sc_tick *tick) {
return true;
}
static bool
parse_screen_off_timeout(const char *s, sc_tick *tick) {
long value;
// value in seconds, but must fit in 31 bits in milliseconds
bool ok = parse_integer_arg(s, &value, false, 0, 0x7FFFFFFF / 1000,
"screen off timeout");
if (!ok) {
return false;
}
*tick = SC_TICK_FROM_SEC(value);
return true;
}
static bool
parse_pause_on_exit(const char *s, enum sc_pause_on_exit *pause_on_exit) {
if (!s || !strcmp(s, "true")) {
@ -2512,7 +2583,11 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[],
opts->power_off_on_close = true;
break;
case OPT_DISPLAY_BUFFER:
if (!parse_buffering_time(optarg, &opts->display_buffer)) {
LOGW("--display-buffer is deprecated, use --video-buffer "
"instead.");
// fall through
case OPT_VIDEO_BUFFER:
if (!parse_buffering_time(optarg, &opts->video_buffer)) {
return false;
}
break;
@ -2595,6 +2670,9 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[],
case OPT_LIST_CAMERA_SIZES:
opts->list |= SC_OPTION_LIST_CAMERA_SIZES;
break;
case OPT_LIST_APPS:
opts->list |= SC_OPTION_LIST_APPS;
break;
case OPT_REQUIRE_AUDIO:
opts->require_audio = true;
break;
@ -2668,6 +2746,18 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[],
return false;
}
break;
case OPT_NEW_DISPLAY:
opts->new_display = optarg ? optarg : "";
break;
case OPT_START_APP:
opts->start_app = optarg;
break;
case OPT_SCREEN_OFF_TIMEOUT:
if (!parse_screen_off_timeout(optarg,
&opts->screen_off_timeout)) {
return false;
}
break;
default:
// getopt prints the error message on stderr
return false;
@ -2766,7 +2856,8 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[],
SC_LOCK_VIDEO_ORIENTATION_UNLOCKED) {
LOGI("Video orientation is locked for v4l2 sink. "
"See --lock-video-orientation.");
opts->lock_video_orientation = SC_LOCK_VIDEO_ORIENTATION_INITIAL;
opts->lock_video_orientation =
SC_LOCK_VIDEO_ORIENTATION_INITIAL_AUTO;
}
// V4L2 could not handle size change.
@ -2776,7 +2867,7 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[],
}
if (opts->v4l2_buffer && !opts->v4l2_device) {
LOGE("V4L2 buffer value without V4L2 sink\n");
LOGE("V4L2 buffer value without V4L2 sink");
return false;
}
#endif
@ -2795,8 +2886,8 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[],
if (otg) {
opts->mouse_input_mode = SC_MOUSE_INPUT_MODE_AOA;
} else if (!opts->video_playback) {
LOGI("No video mirroring, mouse mode switched to UHID");
opts->mouse_input_mode = SC_MOUSE_INPUT_MODE_UHID;
LOGI("No video mirroring, SDK mouse disabled");
opts->mouse_input_mode = SC_MOUSE_INPUT_MODE_DISABLED;
} else {
opts->mouse_input_mode = SC_MOUSE_INPUT_MODE_SDK;
}
@ -2848,6 +2939,25 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[],
}
}
if (opts->new_display) {
if (opts->video_source != SC_VIDEO_SOURCE_DISPLAY) {
LOGE("--new-display is only available with --video-source=display");
return false;
}
if (!opts->video) {
LOGE("--new-display is incompatible with --no-video");
return false;
}
if (opts->max_size && opts->new_display[0] != '\0'
&& opts->new_display[0] != '/') {
// An explicit size is defined (not "" nor "/<dpi>")
LOGE("Cannot specify both --new-display size and -m/--max-size");
return false;
}
}
if (otg) {
if (!opts->control) {
LOGE("--no-control is not allowed in OTG mode");
@ -2954,6 +3064,11 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[],
return false;
}
if (opts->display_id != 0 && opts->new_display) {
LOGE("Cannot specify both --display-id and --new-display");
return false;
}
if (opts->audio && opts->audio_source == SC_AUDIO_SOURCE_AUTO) {
// Select the audio source according to the video source
if (opts->video_source == SC_VIDEO_SOURCE_DISPLAY) {
@ -3086,6 +3201,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

@ -22,9 +22,6 @@
#define MOTIONEVENT_ACTION_LABEL(value) \
ENUM_TO_LABEL(android_motionevent_action_labels, value)
#define SCREEN_POWER_MODE_LABEL(value) \
ENUM_TO_LABEL(screen_power_mode_labels, value)
static const char *const android_keyevent_action_labels[] = {
"down",
"up",
@ -47,14 +44,6 @@ static const char *const android_motionevent_action_labels[] = {
"btn-release",
};
static const char *const screen_power_mode_labels[] = {
"off",
"doze",
"normal",
"doze-suspend",
"suspend",
};
static const char *const copy_key_labels[] = {
"none",
"copy",
@ -158,8 +147,8 @@ sc_control_msg_serialize(const struct sc_control_msg *msg, uint8_t *buf) {
size_t len = write_string(&buf[10], msg->set_clipboard.text,
SC_CONTROL_MSG_CLIPBOARD_TEXT_MAX_LENGTH);
return 10 + len;
case SC_CONTROL_MSG_TYPE_SET_SCREEN_POWER_MODE:
buf[1] = msg->set_screen_power_mode.mode;
case SC_CONTROL_MSG_TYPE_SET_DISPLAY_POWER:
buf[1] = msg->set_display_power.on;
return 2;
case SC_CONTROL_MSG_TYPE_UHID_CREATE:
sc_write16be(&buf[1], msg->uhid_create.id);
@ -183,11 +172,16 @@ 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:
case SC_CONTROL_MSG_TYPE_ROTATE_DEVICE:
case SC_CONTROL_MSG_TYPE_OPEN_HARD_KEYBOARD_SETTINGS:
case SC_CONTROL_MSG_TYPE_RESET_VIDEO:
// no additional data
return 1;
default:
@ -264,9 +258,9 @@ sc_control_msg_log(const struct sc_control_msg *msg) {
msg->set_clipboard.paste ? "paste" : "nopaste",
msg->set_clipboard.text);
break;
case SC_CONTROL_MSG_TYPE_SET_SCREEN_POWER_MODE:
LOG_CMSG("power mode %s",
SCREEN_POWER_MODE_LABEL(msg->set_screen_power_mode.mode));
case SC_CONTROL_MSG_TYPE_SET_DISPLAY_POWER:
LOG_CMSG("display power %s",
msg->set_display_power.on ? "on" : "off");
break;
case SC_CONTROL_MSG_TYPE_EXPAND_NOTIFICATION_PANEL:
LOG_CMSG("expand notification panel");
@ -308,6 +302,12 @@ 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;
case SC_CONTROL_MSG_TYPE_RESET_VIDEO:
LOG_CMSG("reset video");
break;
default:
LOG_CMSG("unknown type: %u", (unsigned) msg->type);
break;
@ -333,6 +333,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

@ -35,18 +35,14 @@ enum sc_control_msg_type {
SC_CONTROL_MSG_TYPE_COLLAPSE_PANELS,
SC_CONTROL_MSG_TYPE_GET_CLIPBOARD,
SC_CONTROL_MSG_TYPE_SET_CLIPBOARD,
SC_CONTROL_MSG_TYPE_SET_SCREEN_POWER_MODE,
SC_CONTROL_MSG_TYPE_SET_DISPLAY_POWER,
SC_CONTROL_MSG_TYPE_ROTATE_DEVICE,
SC_CONTROL_MSG_TYPE_UHID_CREATE,
SC_CONTROL_MSG_TYPE_UHID_INPUT,
SC_CONTROL_MSG_TYPE_UHID_DESTROY,
SC_CONTROL_MSG_TYPE_OPEN_HARD_KEYBOARD_SETTINGS,
};
enum sc_screen_power_mode {
// see <https://android.googlesource.com/platform/frameworks/base.git/+/pie-release-2/core/java/android/view/SurfaceControl.java#305>
SC_SCREEN_POWER_MODE_OFF = 0,
SC_SCREEN_POWER_MODE_NORMAL = 2,
SC_CONTROL_MSG_TYPE_START_APP,
SC_CONTROL_MSG_TYPE_RESET_VIDEO,
};
enum sc_copy_key {
@ -94,8 +90,8 @@ struct sc_control_msg {
bool paste;
} set_clipboard;
struct {
enum sc_screen_power_mode mode;
} set_screen_power_mode;
bool on;
} set_display_power;
struct {
uint16_t id;
const char *name; // pointer to static data
@ -110,6 +106,9 @@ struct sc_control_msg {
struct {
uint16_t id;
} uhid_destroy;
struct {
char *name;
} start_app;
};
};

View File

@ -203,13 +203,12 @@ set_device_clipboard(struct sc_input_manager *im, bool paste,
}
static void
set_screen_power_mode(struct sc_input_manager *im,
enum sc_screen_power_mode mode) {
set_display_power(struct sc_input_manager *im, bool on) {
assert(im->controller);
struct sc_control_msg msg;
msg.type = SC_CONTROL_MSG_TYPE_SET_SCREEN_POWER_MODE;
msg.set_screen_power_mode.mode = mode;
msg.type = SC_CONTROL_MSG_TYPE_SET_DISPLAY_POWER;
msg.set_display_power.on = on;
if (!sc_controller_push_msg(im->controller, &msg)) {
LOGW("Could not request 'set screen power mode'");
@ -285,6 +284,18 @@ open_hard_keyboard_settings(struct sc_input_manager *im) {
}
}
static void
reset_video(struct sc_input_manager *im) {
assert(im->controller);
struct sc_control_msg msg;
msg.type = SC_CONTROL_MSG_TYPE_RESET_VIDEO;
if (!sc_controller_push_msg(im->controller, &msg)) {
LOGW("Could not request reset video");
}
}
static void
apply_orientation_transform(struct sc_input_manager *im,
enum sc_orientation transform) {
@ -415,10 +426,8 @@ sc_input_manager_process_key(struct sc_input_manager *im,
return;
case SDLK_o:
if (control && !repeat && down && !paused) {
enum sc_screen_power_mode mode = shift
? SC_SCREEN_POWER_MODE_NORMAL
: SC_SCREEN_POWER_MODE_OFF;
set_screen_power_mode(im, mode);
bool on = shift;
set_display_power(im, on);
}
return;
case SDLK_z:
@ -524,8 +533,12 @@ sc_input_manager_process_key(struct sc_input_manager *im,
}
return;
case SDLK_r:
if (control && !shift && !repeat && down && !paused) {
rotate_device(im);
if (control && !repeat && down && !paused) {
if (shift) {
reset_video(im);
} else {
rotate_device(im);
}
}
return;
case SDLK_k:

View File

@ -58,10 +58,11 @@ const struct scrcpy_options scrcpy_options_default = {
.window_width = 0,
.window_height = 0,
.display_id = 0,
.display_buffer = 0,
.video_buffer = 0,
.audio_buffer = -1, // depends on the audio format,
.audio_output_buffer = SC_TICK_FROM_MS(5),
.time_limit = 0,
.screen_off_timeout = -1,
#ifdef HAVE_V4L2
.v4l2_device = NULL,
.v4l2_buffer = 0,
@ -103,6 +104,8 @@ const struct scrcpy_options scrcpy_options_default = {
.window = true,
.mouse_hover = true,
.audio_dup = false,
.new_display = NULL,
.start_app = NULL,
};
enum sc_orientation

View File

@ -134,6 +134,8 @@ enum sc_lock_video_orientation {
SC_LOCK_VIDEO_ORIENTATION_UNLOCKED = -1,
// lock the current orientation when scrcpy starts
SC_LOCK_VIDEO_ORIENTATION_INITIAL = -2,
// like SC_LOCK_VIDEO_ORIENTATION_INITIAL, but set automatically
SC_LOCK_VIDEO_ORIENTATION_INITIAL_AUTO = -3,
SC_LOCK_VIDEO_ORIENTATION_0 = 0,
SC_LOCK_VIDEO_ORIENTATION_90 = 3,
SC_LOCK_VIDEO_ORIENTATION_180 = 2,
@ -259,10 +261,11 @@ struct scrcpy_options {
uint16_t window_width;
uint16_t window_height;
uint32_t display_id;
sc_tick display_buffer;
sc_tick video_buffer;
sc_tick audio_buffer;
sc_tick audio_output_buffer;
sc_tick time_limit;
sc_tick screen_off_timeout;
#ifdef HAVE_V4L2
const char *v4l2_device;
sc_tick v4l2_buffer;
@ -304,10 +307,13 @@ struct scrcpy_options {
#define SC_OPTION_LIST_DISPLAYS 0x2
#define SC_OPTION_LIST_CAMERAS 0x4
#define SC_OPTION_LIST_CAMERA_SIZES 0x8
#define SC_OPTION_LIST_APPS 0x10
uint8_t list;
bool window;
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

@ -53,7 +53,7 @@ struct scrcpy {
struct sc_decoder video_decoder;
struct sc_decoder audio_decoder;
struct sc_recorder recorder;
struct sc_delay_buffer display_buffer;
struct sc_delay_buffer video_buffer;
#ifdef HAVE_V4L2
struct sc_v4l2_sink v4l2_sink;
struct sc_delay_buffer v4l2_buffer;
@ -428,9 +428,11 @@ scrcpy(struct scrcpy_options *options) {
.video_bit_rate = options->video_bit_rate,
.audio_bit_rate = options->audio_bit_rate,
.max_fps = options->max_fps,
.screen_off_timeout = options->screen_off_timeout,
.lock_video_orientation = options->lock_video_orientation,
.control = options->control,
.display_id = options->display_id,
.new_display = options->new_display,
.video = options->video,
.audio = options->audio,
.audio_dup = options->audio_dup,
@ -814,11 +816,11 @@ aoa_complete:
if (options->video_playback) {
struct sc_frame_source *src = &s->video_decoder.frame_source;
if (options->display_buffer) {
sc_delay_buffer_init(&s->display_buffer,
options->display_buffer, true);
sc_frame_source_add_sink(src, &s->display_buffer.frame_sink);
src = &s->display_buffer.frame_source;
if (options->video_buffer) {
sc_delay_buffer_init(&s->video_buffer,
options->video_buffer, true);
sc_frame_source_add_sink(src, &s->video_buffer.frame_sink);
src = &s->video_buffer.frame_source;
}
sc_frame_source_add_sink(src, &s->screen.frame_sink);
@ -872,11 +874,11 @@ aoa_complete:
// everything is set up
if (options->control && options->turn_screen_off) {
struct sc_control_msg msg;
msg.type = SC_CONTROL_MSG_TYPE_SET_SCREEN_POWER_MODE;
msg.set_screen_power_mode.mode = SC_SCREEN_POWER_MODE_OFF;
msg.type = SC_CONTROL_MSG_TYPE_SET_DISPLAY_POWER;
msg.set_display_power.on = false;
if (!sc_controller_push_msg(&s->controller, &msg)) {
LOGW("Could not request 'set screen power mode'");
LOGW("Could not request 'set display power'");
}
}
@ -906,6 +908,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

@ -183,6 +183,27 @@ validate_string(const char *s) {
return true;
}
static uint16_t
get_device_sdk_version(struct sc_server *server) {
struct sc_intr *intr = &server->intr;
char *sdk_version =
sc_adb_getprop(intr, server->serial, "ro.build.version.sdk",
SC_ADB_SILENT);
if (!sdk_version) {
return 0;
}
long value;
bool ok = sc_str_parse_integer(sdk_version, &value);
free(sdk_version);
if (!ok || value < 0 || value > 0xFFFF) {
return 0;
}
return value;
}
static sc_pid
execute_server(struct sc_server *server,
const struct sc_server_params *params) {
@ -201,18 +222,26 @@ execute_server(struct sc_server *server,
cmd[count++] = "app_process";
#ifdef SERVER_DEBUGGER
uint16_t sdk_version = get_device_sdk_version(server);
if (!sdk_version) {
LOGE("Could not determine SDK version");
return 0;
}
# define SERVER_DEBUGGER_PORT "5005"
cmd[count++] =
# ifdef SERVER_DEBUGGER_METHOD_NEW
/* Android 9 and above */
"-XjdwpProvider:internal -XjdwpOptions:transport=dt_socket,suspend=y,"
"server=y,address="
# else
/* Android 8 and below */
"-agentlib:jdwp=transport=dt_socket,suspend=y,server=y,address="
# endif
SERVER_DEBUGGER_PORT;
const char *dbg;
if (sdk_version < 28) {
// Android < 9
dbg = "-agentlib:jdwp=transport=dt_socket,suspend=y,server=y,address="
SERVER_DEBUGGER_PORT;
} else {
// Android >= 9
dbg = "-XjdwpProvider:internal -XjdwpOptions:transport=dt_socket,"
"suspend=y,server=y,address=" SERVER_DEBUGGER_PORT;
}
cmd[count++] = dbg;
#endif
cmd[count++] = "/"; // unused
cmd[count++] = "com.genymobile.scrcpy.Server";
cmd[count++] = SCRCPY_VERSION;
@ -320,6 +349,11 @@ execute_server(struct sc_server *server,
if (params->stay_awake) {
ADD_PARAM("stay_awake=true");
}
if (params->screen_off_timeout != -1) {
assert(params->screen_off_timeout >= 0);
uint64_t ms = SC_TICK_TO_MS(params->screen_off_timeout);
ADD_PARAM("screen_off_timeout=%" PRIu64, ms);
}
if (params->video_codec_options) {
VALIDATE_STRING(params->video_codec_options);
ADD_PARAM("video_codec_options=%s", params->video_codec_options);
@ -355,6 +389,10 @@ execute_server(struct sc_server *server,
// By default, power_on is true
ADD_PARAM("power_on=false");
}
if (params->new_display) {
VALIDATE_STRING(params->new_display);
ADD_PARAM("new_display=%s", params->new_display);
}
if (params->list & SC_OPTION_LIST_ENCODERS) {
ADD_PARAM("list_encoders=true");
}
@ -367,6 +405,9 @@ execute_server(struct sc_server *server,
if (params->list & SC_OPTION_LIST_CAMERA_SIZES) {
ADD_PARAM("list_camera_sizes=true");
}
if (params->list & SC_OPTION_LIST_APPS) {
ADD_PARAM("list_apps=true");
}
#undef ADD_PARAM

View File

@ -45,9 +45,11 @@ struct sc_server_params {
uint32_t video_bit_rate;
uint32_t audio_bit_rate;
const char *max_fps; // float to be parsed by the server
sc_tick screen_off_timeout;
int8_t lock_video_orientation;
bool control;
uint32_t display_id;
const char *new_display;
bool video;
bool audio;
bool audio_dup;

View File

@ -289,11 +289,11 @@ static void test_serialize_set_clipboard_long(void) {
assert(!memcmp(buf, expected, sizeof(expected)));
}
static void test_serialize_set_screen_power_mode(void) {
static void test_serialize_set_display_power(void) {
struct sc_control_msg msg = {
.type = SC_CONTROL_MSG_TYPE_SET_SCREEN_POWER_MODE,
.set_screen_power_mode = {
.mode = SC_SCREEN_POWER_MODE_NORMAL,
.type = SC_CONTROL_MSG_TYPE_SET_DISPLAY_POWER,
.set_display_power = {
.on = true,
},
};
@ -302,8 +302,8 @@ static void test_serialize_set_screen_power_mode(void) {
assert(size == 2);
const uint8_t expected[] = {
SC_CONTROL_MSG_TYPE_SET_SCREEN_POWER_MODE,
0x02, // SC_SCREEN_POWER_MODE_NORMAL
SC_CONTROL_MSG_TYPE_SET_DISPLAY_POWER,
0x01, // true
};
assert(!memcmp(buf, expected, sizeof(expected)));
}
@ -407,6 +407,21 @@ static void test_serialize_open_hard_keyboard(void) {
assert(!memcmp(buf, expected, sizeof(expected)));
}
static void test_serialize_reset_video(void) {
struct sc_control_msg msg = {
.type = SC_CONTROL_MSG_TYPE_RESET_VIDEO,
};
uint8_t buf[SC_CONTROL_MSG_MAX_SIZE];
size_t size = sc_control_msg_serialize(&msg, buf);
assert(size == 1);
const uint8_t expected[] = {
SC_CONTROL_MSG_TYPE_RESET_VIDEO,
};
assert(!memcmp(buf, expected, sizeof(expected)));
}
int main(int argc, char *argv[]) {
(void) argc;
(void) argv;
@ -423,11 +438,12 @@ int main(int argc, char *argv[]) {
test_serialize_get_clipboard();
test_serialize_set_clipboard();
test_serialize_set_clipboard_long();
test_serialize_set_screen_power_mode();
test_serialize_set_display_power();
test_serialize_rotate_device();
test_serialize_uhid_create();
test_serialize_uhid_input();
test_serialize_uhid_destroy();
test_serialize_open_hard_keyboard();
test_serialize_reset_video();
return 0;
}

View File

@ -170,7 +170,7 @@ latency (for both [video](video.md#buffering) and audio) might be preferable to
avoid glitches and smooth the playback:
```
scrcpy --display-buffer=200 --audio-buffer=200
scrcpy --video-buffer=200 --audio-buffer=200
```
It is also possible to configure another audio buffer (the audio output buffer),

View File

@ -21,9 +21,9 @@ the client and on the server.
If video is enabled, then the server sends a raw video stream (H.264 by default)
of the device screen, with some additional headers for each packet. The client
decodes the video frames, and displays them as soon as possible, without
buffering (unless `--display-buffer=delay` is specified) to minimize latency.
The client is not aware of the device rotation (which is handled by the server),
it just knows the dimensions of the video frames it receives.
buffering (unless `--video-buffer=delay` is specified) to minimize latency. The
client is not aware of the device rotation (which is handled by the server), it
just knows the dimensions of the video frames it receives.
Similarly, if audio is enabled, then the server sends a raw audio stream (OPUS
by default) of the device audio output (or the microphone if

View File

@ -18,6 +18,21 @@ The initial state is restored when _scrcpy_ is closed.
If the device is not plugged in (i.e. only connected over TCP/IP),
`--stay-awake` has no effect (this is the Android behavior).
This changes the value of [`stay_on_while_plugged_in`], setting which can be
changed manually:
[`stay_on_while_plugged_in`]: https://developer.android.com/reference/android/provider/Settings.Global#STAY_ON_WHILE_PLUGGED_IN
```bash
# get the current show_touches value
adb shell settings get global stay_on_while_plugged_in
# enable for AC/USB/wireless chargers
adb shell settings put global stay_on_while_plugged_in 7
# disable
adb shell settings put global stay_on_while_plugged_in 0
```
## Turn screen off
@ -46,6 +61,40 @@ scrcpy --turn-screen-off --stay-awake
scrcpy -Sw # short version
```
Since Android 15, it is possible to change this setting manually:
```
# turn screen off (0 for main display)
adb shell cmd display power-off 0
# turn screen on
adb shell cmd display power-on 0
```
## Screen off timeout
The Android screen automatically turns off after some delay.
To change this delay while scrcpy is running:
```bash
scrcpy --screen-off-timeout=300 # 300 seconds (5 minutes)
```
The initial value is restored on exit.
It is possible to change this setting manually:
```bash
# get the current screen_off_timeout value
adb shell settings get system screen_off_timeout
# set a new value (in milliseconds)
adb shell settings put system screen_off_timeout 30000
```
Note that the Android value is in milliseconds, but the scrcpy command line
argument is in seconds.
## Show touches
@ -62,6 +111,16 @@ scrcpy -t # short version
Note that it only shows _physical_ touches (by a finger on the device).
It is possible to change this setting manually:
```bash
# get the current show_touches value
adb shell settings get system show_touches
# enable show_touches
adb shell settings put system show_touches 1
# disable show_touches
adb shell settings put system show_touches 0
```
## Power off on close
@ -78,3 +137,48 @@ By default, on start, the device is powered on. To prevent this behavior:
```bash
scrcpy --no-power-on
```
## Start Android app
To list the Android apps installed on the device:
```bash
scrcpy --list-apps
```
An app, selected by its package name, can be launched on start:
```
scrcpy --start-app=org.mozilla.firefox
```
This feature can be used to run an app in a [virtual
display](virtual_display.md):
```
scrcpy --new-display=1920x1080 --start-app=org.videolan.vlc
```
The app can be optionally forced-stop before being started, by adding a `+`
prefix:
```
scrcpy --start-app=+org.mozilla.firefox
```
For convenience, it is also possible to select an app by its name, by adding a
`?` prefix:
```
scrcpy --start-app=?firefox
```
But retrieving app names may take some time (sometimes several seconds), so
passing the package name is recommended.
The `+` and `?` prefixes can be combined (in that order):
```
scrcpy --start-app=+?firefox
```

View File

@ -34,9 +34,9 @@ Two modes allow to simulate a physical HID mouse on the device.
In these modes, the computer mouse is "captured": the mouse pointer disappears
from the computer and appears on the Android device instead.
Special capture keys, either <kbd>Alt</kbd> or <kbd>Super</kbd>, toggle
(disable or enable) the mouse capture. Use one of them to give the control of
the mouse back to the computer.
The [shortcut mod](shortcuts.md) (either <kbd>Alt</kbd> or <kbd>Super</kbd> by
default) toggle (disable or enable) the mouse capture. Use one of them to give
the control of the mouse back to the computer.
### UHID

View File

@ -30,6 +30,7 @@ _<kbd>[Super]</kbd> is typically the <kbd>Windows</kbd> or <kbd>Cmd</kbd> key._
| Flip display vertically | <kbd>MOD</kbd>+<kbd>Shift</kbd>+<kbd></kbd> _(up)_ \| <kbd>MOD</kbd>+<kbd>Shift</kbd>+<kbd></kbd> _(down)_
| Pause or re-pause display | <kbd>MOD</kbd>+<kbd>z</kbd>
| Unpause display | <kbd>MOD</kbd>+<kbd>Shift</kbd>+<kbd>z</kbd>
| Reset video capture/encoding | <kbd>MOD</kbd>+<kbd>Shift</kbd>+<kbd>r</kbd>
| Resize window to 1:1 (pixel-perfect) | <kbd>MOD</kbd>+<kbd>g</kbd>
| Resize window to remove black borders | <kbd>MOD</kbd>+<kbd>w</kbd> \| _Double-left-click¹_
| Click on `HOME` | <kbd>MOD</kbd>+<kbd>h</kbd> \| _Middle-click_

View File

@ -189,15 +189,15 @@ The configuration is available independently for the display,
[v4l2 sinks](video.md#video4linux) and [audio](audio.md#buffering) playback.
```bash
scrcpy --display-buffer=50 # add 50ms buffering for display
scrcpy --v4l2-buffer=300 # add 300ms buffering for v4l2 sink
scrcpy --video-buffer=50 # add 50ms buffering for video playback
scrcpy --audio-buffer=200 # set 200ms buffering for audio playback
scrcpy --v4l2-buffer=300 # add 300ms buffering for v4l2 sink
```
They can be applied simultaneously:
```bash
scrcpy --display-buffer=50 --v4l2-buffer=300
scrcpy --video-buffer=50 --v4l2-buffer=300
```

26
doc/virtual_display.md Normal file
View File

@ -0,0 +1,26 @@
# Virtual display
## New display
To mirror a new virtual display instead of the device screen:
```bash
scrcpy --new-display=1920x1080
scrcpy --new-display=1920x1080/420 # force 420 dpi
scrcpy --new-display # use the main display size and density
scrcpy --new-display -m1920 # ... scaled to fit a max size of 1920
scrcpy --new-display=/240 # use the main display size and 240 dpi
```
## Start app
On some devices, a launcher is available in the virtual display.
When no launcher is available, the virtual display is empty. In that case, you
must [start an Android app](device.md#start-android-app).
For example:
```bash
scrcpy --new-display=1920x1080 --start-app=org.videolan.vlc
```

View File

@ -3,6 +3,5 @@ option('compile_server', type: 'boolean', value: true, description: 'Build the s
option('prebuilt_server', type: 'string', description: 'Path of the prebuilt server')
option('portable', type: 'boolean', value: false, description: 'Use scrcpy-server from the same directory as the scrcpy executable')
option('server_debugger', type: 'boolean', value: false, description: 'Run a server debugger and wait for a client to be attached')
option('server_debugger_method', type: 'combo', choices: ['old', 'new'], value: 'new', description: 'Select the debugger method (Android < 9: "old", Android >= 9: "new")')
option('v4l2', type: 'boolean', value: true, description: 'Enable V4L2 feature when supported')
option('usb', type: 'boolean', value: true, description: 'Enable HID/OTG features when supported')

View File

@ -9,13 +9,12 @@
# the server to the device.
.PHONY: default clean \
test \
test test-client test-server \
build-server \
prepare-deps \
prepare-deps-win32 prepare-deps-win64 \
build-win32 build-win64 \
dist-win32 dist-win64 \
zip-win32 zip-win64 \
release
package release
GRADLE ?= ./gradlew
@ -26,7 +25,7 @@ WIN64_BUILD_DIR := build-win64
VERSION ?= $(shell git describe --tags --exclude='*install-release' --always)
DIST := dist
ZIP := zip
WIN32_TARGET_DIR := scrcpy-win32-$(VERSION)
WIN64_TARGET_DIR := scrcpy-win64-$(VERSION)
WIN32_TARGET := $(WIN32_TARGET_DIR).zip
@ -34,33 +33,28 @@ WIN64_TARGET := $(WIN64_TARGET_DIR).zip
RELEASE_DIR := release-$(VERSION)
release: clean test build-server zip-win32 zip-win64
mkdir -p "$(RELEASE_DIR)"
cp "$(SERVER_BUILD_DIR)/server/scrcpy-server" \
"$(RELEASE_DIR)/scrcpy-server-$(VERSION)"
cp "$(DIST)/$(WIN32_TARGET)" "$(RELEASE_DIR)"
cp "$(DIST)/$(WIN64_TARGET)" "$(RELEASE_DIR)"
cd "$(RELEASE_DIR)" && \
sha256sum "scrcpy-server-$(VERSION)" \
"scrcpy-win32-$(VERSION).zip" \
"scrcpy-win64-$(VERSION).zip" > SHA256SUMS.txt
@echo "Release generated in $(RELEASE_DIR)/"
release: clean test build-server build-win32 build-win64 package
clean:
$(GRADLE) clean
rm -rf "$(DIST)" "$(TEST_BUILD_DIR)" "$(SERVER_BUILD_DIR)" \
rm -rf "$(ZIP)" "$(TEST_BUILD_DIR)" "$(SERVER_BUILD_DIR)" \
"$(WIN32_BUILD_DIR)" "$(WIN64_BUILD_DIR)"
test:
test-client:
[ -d "$(TEST_BUILD_DIR)" ] || ( mkdir "$(TEST_BUILD_DIR)" && \
meson setup "$(TEST_BUILD_DIR)" -Db_sanitize=address )
ninja -C "$(TEST_BUILD_DIR)"
test-server:
$(GRADLE) -p server check
test: test-client test-server
build-server:
[ -d "$(SERVER_BUILD_DIR)" ] || ( mkdir "$(SERVER_BUILD_DIR)" && \
meson setup "$(SERVER_BUILD_DIR)" --buildtype release -Dcompile_app=false )
ninja -C "$(SERVER_BUILD_DIR)"
$(GRADLE) -p server assembleRelease
mkdir -p "$(SERVER_BUILD_DIR)/server"
cp server/build/outputs/apk/release/server-release-unsigned.apk \
"$(SERVER_BUILD_DIR)/server/scrcpy-server"
prepare-deps-win32:
@app/deps/adb.sh win32
@ -86,6 +80,15 @@ build-win32: prepare-deps-win32
-Dcompile_server=false \
-Dportable=true
ninja -C "$(WIN32_BUILD_DIR)"
# Group intermediate outputs into a 'dist' directory
mkdir -p "$(WIN32_BUILD_DIR)/dist"
cp "$(WIN32_BUILD_DIR)"/app/scrcpy.exe "$(WIN32_BUILD_DIR)/dist/"
cp app/data/scrcpy-console.bat "$(WIN32_BUILD_DIR)/dist/"
cp app/data/scrcpy-noconsole.vbs "$(WIN32_BUILD_DIR)/dist/"
cp app/data/icon.png "$(WIN32_BUILD_DIR)/dist/"
cp app/data/open_a_terminal_here.bat "$(WIN32_BUILD_DIR)/dist/"
cp app/deps/work/install/win32/bin/*.dll "$(WIN32_BUILD_DIR)/dist/"
cp app/deps/work/install/win32/bin/adb.exe "$(WIN32_BUILD_DIR)/dist/"
build-win64: prepare-deps-win64
rm -rf "$(WIN64_BUILD_DIR)"
@ -99,33 +102,40 @@ build-win64: prepare-deps-win64
-Dcompile_server=false \
-Dportable=true
ninja -C "$(WIN64_BUILD_DIR)"
# Group intermediate outputs into a 'dist' directory
mkdir -p "$(WIN64_BUILD_DIR)/dist"
cp "$(WIN64_BUILD_DIR)"/app/scrcpy.exe "$(WIN64_BUILD_DIR)/dist/"
cp app/data/scrcpy-console.bat "$(WIN64_BUILD_DIR)/dist/"
cp app/data/scrcpy-noconsole.vbs "$(WIN64_BUILD_DIR)/dist/"
cp app/data/icon.png "$(WIN64_BUILD_DIR)/dist/"
cp app/data/open_a_terminal_here.bat "$(WIN64_BUILD_DIR)/dist/"
cp app/deps/work/install/win64/bin/*.dll "$(WIN64_BUILD_DIR)/dist/"
cp app/deps/work/install/win64/bin/adb.exe "$(WIN64_BUILD_DIR)/dist/"
dist-win32: build-server build-win32
mkdir -p "$(DIST)/$(WIN32_TARGET_DIR)"
cp "$(SERVER_BUILD_DIR)"/server/scrcpy-server "$(DIST)/$(WIN32_TARGET_DIR)/"
cp "$(WIN32_BUILD_DIR)"/app/scrcpy.exe "$(DIST)/$(WIN32_TARGET_DIR)/"
cp app/data/scrcpy-console.bat "$(DIST)/$(WIN32_TARGET_DIR)/"
cp app/data/scrcpy-noconsole.vbs "$(DIST)/$(WIN32_TARGET_DIR)/"
cp app/data/icon.png "$(DIST)/$(WIN32_TARGET_DIR)/"
cp app/data/open_a_terminal_here.bat "$(DIST)/$(WIN32_TARGET_DIR)/"
cp app/deps/work/install/win32/bin/*.dll "$(DIST)/$(WIN32_TARGET_DIR)/"
cp app/deps/work/install/win32/bin/adb.exe "$(DIST)/$(WIN32_TARGET_DIR)/"
dist-win64: build-server build-win64
mkdir -p "$(DIST)/$(WIN64_TARGET_DIR)"
cp "$(SERVER_BUILD_DIR)"/server/scrcpy-server "$(DIST)/$(WIN64_TARGET_DIR)/"
cp "$(WIN64_BUILD_DIR)"/app/scrcpy.exe "$(DIST)/$(WIN64_TARGET_DIR)/"
cp app/data/scrcpy-console.bat "$(DIST)/$(WIN64_TARGET_DIR)/"
cp app/data/scrcpy-noconsole.vbs "$(DIST)/$(WIN64_TARGET_DIR)/"
cp app/data/icon.png "$(DIST)/$(WIN64_TARGET_DIR)/"
cp app/data/open_a_terminal_here.bat "$(DIST)/$(WIN64_TARGET_DIR)/"
cp app/deps/work/install/win64/bin/*.dll "$(DIST)/$(WIN64_TARGET_DIR)/"
cp app/deps/work/install/win64/bin/adb.exe "$(DIST)/$(WIN64_TARGET_DIR)/"
zip-win32: dist-win32
cd "$(DIST)"; \
zip-win32:
mkdir -p "$(ZIP)/$(WIN32_TARGET_DIR)"
cp -r "$(WIN32_BUILD_DIR)/dist/." "$(ZIP)/$(WIN32_TARGET_DIR)/"
cp "$(SERVER_BUILD_DIR)"/server/scrcpy-server "$(ZIP)/$(WIN32_TARGET_DIR)/"
cd "$(ZIP)"; \
zip -r "$(WIN32_TARGET)" "$(WIN32_TARGET_DIR)"
rm -rf "$(ZIP)/$(WIN32_TARGET_DIR)"
zip-win64: dist-win64
cd "$(DIST)"; \
zip-win64:
mkdir -p "$(ZIP)/$(WIN64_TARGET_DIR)"
cp -r "$(WIN64_BUILD_DIR)/dist/." "$(ZIP)/$(WIN64_TARGET_DIR)/"
cp "$(SERVER_BUILD_DIR)"/server/scrcpy-server "$(ZIP)/$(WIN64_TARGET_DIR)/"
cd "$(ZIP)"; \
zip -r "$(WIN64_TARGET)" "$(WIN64_TARGET_DIR)"
rm -rf "$(ZIP)/$(WIN64_TARGET_DIR)"
package: zip-win32 zip-win64
mkdir -p "$(RELEASE_DIR)"
cp "$(SERVER_BUILD_DIR)/server/scrcpy-server" \
"$(RELEASE_DIR)/scrcpy-server-$(VERSION)"
cp "$(ZIP)/$(WIN32_TARGET)" "$(RELEASE_DIR)"
cp "$(ZIP)/$(WIN64_TARGET)" "$(RELEASE_DIR)"
cd "$(RELEASE_DIR)" && \
sha256sum "scrcpy-server-$(VERSION)" \
"scrcpy-win32-$(VERSION).zip" \
"scrcpy-win64-$(VERSION).zip" > SHA256SUMS.txt
@echo "Release generated in $(RELEASE_DIR)/"

View File

@ -50,6 +50,11 @@ cd "$SERVER_DIR/src/main/aidl"
android/content/IOnPrimaryClipChangedListener.aidl
"$BUILD_TOOLS_DIR/aidl" -o"$GEN_DIR" -I. android/view/IDisplayFoldListener.aidl
# Fake sources to expose hidden Android types to the project
FAKE_SRC=( \
android/content/*java \
)
SRC=( \
com/genymobile/scrcpy/*.java \
com/genymobile/scrcpy/audio/*.java \
@ -68,10 +73,11 @@ done
echo "Compiling java sources..."
cd ../java
javac -bootclasspath "$ANDROID_JAR" \
javac -encoding UTF-8 -bootclasspath "$ANDROID_JAR" \
-cp "$LAMBDA_JAR:$GEN_DIR" \
-d "$CLASSES_DIR" \
-source 1.8 -target 1.8 \
${FAKE_SRC[@]} \
${SRC[@]}
echo "Dexing..."

View File

@ -0,0 +1,5 @@
package android.content;
public interface IContentProvider {
// android.content.IContentProvider is hidden, this is a fake one to expose the type to the project
}

View File

@ -5,6 +5,8 @@ import com.genymobile.scrcpy.util.Ln;
import com.genymobile.scrcpy.util.Settings;
import com.genymobile.scrcpy.util.SettingsException;
import android.os.BatteryManager;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
@ -16,59 +18,132 @@ import java.io.OutputStream;
*/
public final class CleanUp {
private static final int MSG_TYPE_MASK = 0b11;
private static final int MSG_TYPE_RESTORE_STAY_ON = 0;
private static final int MSG_TYPE_DISABLE_SHOW_TOUCHES = 1;
private static final int MSG_TYPE_RESTORE_NORMAL_POWER_MODE = 2;
private static final int MSG_TYPE_POWER_OFF_SCREEN = 3;
// Dynamic options
private static final int PENDING_CHANGE_DISPLAY_POWER = 1 << 0;
private int pendingChanges;
private boolean pendingRestoreDisplayPower;
private static final int MSG_PARAM_SHIFT = 2;
private Thread thread;
private final OutputStream out;
public CleanUp(OutputStream out) {
this.out = out;
private CleanUp(Options options) {
thread = new Thread(() -> runCleanUp(options), "cleanup");
thread.start();
}
public static CleanUp configure(int displayId) throws IOException {
String[] cmd = {"app_process", "/", CleanUp.class.getName(), String.valueOf(displayId)};
public static CleanUp start(Options options) {
return new CleanUp(options);
}
public void interrupt() {
thread.interrupt();
}
public void join() throws InterruptedException {
thread.join();
}
private void runCleanUp(Options options) {
boolean disableShowTouches = false;
if (options.getShowTouches()) {
try {
String oldValue = Settings.getAndPutValue(Settings.TABLE_SYSTEM, "show_touches", "1");
// If "show touches" was disabled, it must be disabled back on clean up
disableShowTouches = !"1".equals(oldValue);
} catch (SettingsException e) {
Ln.e("Could not change \"show_touches\"", e);
}
}
int restoreStayOn = -1;
if (options.getStayAwake()) {
int stayOn = BatteryManager.BATTERY_PLUGGED_AC | BatteryManager.BATTERY_PLUGGED_USB | BatteryManager.BATTERY_PLUGGED_WIRELESS;
try {
String oldValue = Settings.getAndPutValue(Settings.TABLE_GLOBAL, "stay_on_while_plugged_in", String.valueOf(stayOn));
try {
int currentStayOn = Integer.parseInt(oldValue);
// Restore only if the current value is different
if (currentStayOn != stayOn) {
restoreStayOn = currentStayOn;
}
} catch (NumberFormatException e) {
// ignore
}
} catch (SettingsException e) {
Ln.e("Could not change \"stay_on_while_plugged_in\"", e);
}
}
int restoreScreenOffTimeout = -1;
int screenOffTimeout = options.getScreenOffTimeout();
if (screenOffTimeout != -1) {
try {
String oldValue = Settings.getAndPutValue(Settings.TABLE_SYSTEM, "screen_off_timeout", String.valueOf(screenOffTimeout));
try {
int currentScreenOffTimeout = Integer.parseInt(oldValue);
// Restore only if the current value is different
if (currentScreenOffTimeout != screenOffTimeout) {
restoreScreenOffTimeout = currentScreenOffTimeout;
}
} catch (NumberFormatException e) {
// ignore
}
} catch (SettingsException e) {
Ln.e("Could not change \"screen_off_timeout\"", e);
}
}
boolean powerOffScreen = options.getPowerOffScreenOnClose();
int displayId = options.getDisplayId();
try {
run(displayId, restoreStayOn, disableShowTouches, powerOffScreen, restoreScreenOffTimeout);
} catch (InterruptedException e) {
// ignore
} catch (IOException e) {
Ln.e("Clean up I/O exception", e);
}
}
private void run(int displayId, int restoreStayOn, boolean disableShowTouches, boolean powerOffScreen, int restoreScreenOffTimeout)
throws IOException, InterruptedException {
String[] cmd = {
"app_process",
"/",
CleanUp.class.getName(),
String.valueOf(displayId),
String.valueOf(restoreStayOn),
String.valueOf(disableShowTouches),
String.valueOf(powerOffScreen),
String.valueOf(restoreScreenOffTimeout),
};
ProcessBuilder builder = new ProcessBuilder(cmd);
builder.environment().put("CLASSPATH", Server.SERVER_PATH);
Process process = builder.start();
return new CleanUp(process.getOutputStream());
}
OutputStream out = process.getOutputStream();
private boolean sendMessage(int type, int param) {
assert (type & ~MSG_TYPE_MASK) == 0;
int msg = type | param << MSG_PARAM_SHIFT;
try {
out.write(msg);
out.flush();
return true;
} catch (IOException e) {
Ln.w("Could not configure cleanup (type=" + type + ", param=" + param + ")", e);
return false;
while (true) {
int localPendingChanges;
boolean localPendingRestoreDisplayPower;
synchronized (this) {
while (pendingChanges == 0) {
wait();
}
localPendingChanges = pendingChanges;
localPendingRestoreDisplayPower = pendingRestoreDisplayPower;
pendingChanges = 0;
}
if ((localPendingChanges & PENDING_CHANGE_DISPLAY_POWER) != 0) {
out.write(localPendingRestoreDisplayPower ? 1 : 0);
out.flush();
}
}
}
public boolean setRestoreStayOn(int restoreValue) {
// Restore the value (between 0 and 7), -1 to not restore
// <https://developer.android.com/reference/android/provider/Settings.Global#STAY_ON_WHILE_PLUGGED_IN>
assert restoreValue >= -1 && restoreValue <= 7;
return sendMessage(MSG_TYPE_RESTORE_STAY_ON, restoreValue & 0b1111);
}
public boolean setDisableShowTouches(boolean disableOnExit) {
return sendMessage(MSG_TYPE_DISABLE_SHOW_TOUCHES, disableOnExit ? 1 : 0);
}
public boolean setRestoreNormalPowerMode(boolean restoreOnExit) {
return sendMessage(MSG_TYPE_RESTORE_NORMAL_POWER_MODE, restoreOnExit ? 1 : 0);
}
public boolean setPowerOffScreen(boolean powerOffScreenOnExit) {
return sendMessage(MSG_TYPE_POWER_OFF_SCREEN, powerOffScreenOnExit ? 1 : 0);
public synchronized void setRestoreDisplayPower(boolean restoreDisplayPower) {
pendingRestoreDisplayPower = restoreDisplayPower;
pendingChanges |= PENDING_CHANGE_DISPLAY_POWER;
notify();
}
public static void unlinkSelf() {
@ -83,35 +158,21 @@ public final class CleanUp {
unlinkSelf();
int displayId = Integer.parseInt(args[0]);
int restoreStayOn = Integer.parseInt(args[1]);
boolean disableShowTouches = Boolean.parseBoolean(args[2]);
boolean powerOffScreen = Boolean.parseBoolean(args[3]);
int restoreScreenOffTimeout = Integer.parseInt(args[4]);
int restoreStayOn = -1;
boolean disableShowTouches = false;
boolean restoreNormalPowerMode = false;
boolean powerOffScreen = false;
// Dynamic option
boolean restoreDisplayPower = false;
try {
// Wait for the server to die
int msg;
while ((msg = System.in.read()) != -1) {
int type = msg & MSG_TYPE_MASK;
int param = msg >> MSG_PARAM_SHIFT;
switch (type) {
case MSG_TYPE_RESTORE_STAY_ON:
restoreStayOn = param > 7 ? -1 : param;
break;
case MSG_TYPE_DISABLE_SHOW_TOUCHES:
disableShowTouches = param != 0;
break;
case MSG_TYPE_RESTORE_NORMAL_POWER_MODE:
restoreNormalPowerMode = param != 0;
break;
case MSG_TYPE_POWER_OFF_SCREEN:
powerOffScreen = param != 0;
break;
default:
Ln.w("Unexpected msg type: " + type);
break;
}
// Only restore display power
assert msg == 0 || msg == 1;
restoreDisplayPower = msg != 0;
}
} catch (IOException e) {
// Expected when the server is dead
@ -137,13 +198,22 @@ public final class CleanUp {
}
}
if (Device.isScreenOn()) {
if (restoreScreenOffTimeout != -1) {
Ln.i("Restoring \"screen off timeout\"");
try {
Settings.putValue(Settings.TABLE_SYSTEM, "screen_off_timeout", String.valueOf(restoreScreenOffTimeout));
} catch (SettingsException e) {
Ln.e("Could not restore \"screen_off_timeout\"", e);
}
}
if (displayId != Device.DISPLAY_ID_NONE && Device.isScreenOn(displayId)) {
if (powerOffScreen) {
Ln.i("Power off screen");
Device.powerOffScreen(displayId);
} else if (restoreNormalPowerMode) {
Ln.i("Restoring normal power mode");
Device.setScreenPowerMode(Device.POWER_MODE_NORMAL);
} else if (restoreDisplayPower) {
Ln.i("Restoring display power");
Device.setDisplayPower(displayId, true);
}
}

View File

@ -1,9 +1,14 @@
package com.genymobile.scrcpy;
import com.genymobile.scrcpy.wrappers.ServiceManager;
import android.annotation.TargetApi;
import android.content.AttributionSource;
import android.content.ContentResolver;
import android.content.Context;
import android.content.ContextWrapper;
import android.content.IContentProvider;
import android.os.Binder;
import android.os.Process;
public final class FakeContext extends ContextWrapper {
@ -17,6 +22,38 @@ public final class FakeContext extends ContextWrapper {
return INSTANCE;
}
private final ContentResolver contentResolver = new ContentResolver(this) {
@SuppressWarnings({"unused", "ProtectedMemberInFinalClass"})
// @Override (but super-class method not visible)
protected IContentProvider acquireProvider(Context c, String name) {
return ServiceManager.getActivityManager().getContentProviderExternal(name, new Binder());
}
@SuppressWarnings("unused")
// @Override (but super-class method not visible)
public boolean releaseProvider(IContentProvider icp) {
return false;
}
@SuppressWarnings({"unused", "ProtectedMemberInFinalClass"})
// @Override (but super-class method not visible)
protected IContentProvider acquireUnstableProvider(Context c, String name) {
return null;
}
@SuppressWarnings("unused")
// @Override (but super-class method not visible)
public boolean releaseUnstableProvider(IContentProvider icp) {
return false;
}
@SuppressWarnings("unused")
// @Override (but super-class method not visible)
public void unstableProviderDied(IContentProvider icp) {
// ignore
}
};
private FakeContext() {
super(Workarounds.getSystemContext());
}
@ -49,4 +86,9 @@ public final class FakeContext extends ContextWrapper {
public Context getApplicationContext() {
return this;
}
@Override
public ContentResolver getContentResolver() {
return contentResolver;
}
}

View File

@ -2,6 +2,8 @@ package com.genymobile.scrcpy;
import com.genymobile.scrcpy.audio.AudioCodec;
import com.genymobile.scrcpy.audio.AudioSource;
import com.genymobile.scrcpy.device.Device;
import com.genymobile.scrcpy.device.NewDisplay;
import com.genymobile.scrcpy.device.Size;
import com.genymobile.scrcpy.util.CodecOption;
import com.genymobile.scrcpy.util.Ln;
@ -30,7 +32,7 @@ public class Options {
private int videoBitRate = 8000000;
private int audioBitRate = 128000;
private float maxFps;
private int lockVideoOrientation = -1;
private int lockVideoOrientation = Device.LOCK_VIDEO_ORIENTATION_UNLOCKED;
private boolean tunnelForward;
private Rect crop;
private boolean control = true;
@ -43,6 +45,7 @@ public class Options {
private boolean cameraHighSpeed;
private boolean showTouches;
private boolean stayAwake;
private int screenOffTimeout = -1;
private List<CodecOption> videoCodecOptions;
private List<CodecOption> audioCodecOptions;
@ -54,10 +57,13 @@ public class Options {
private boolean cleanup = true;
private boolean powerOn = true;
private NewDisplay newDisplay;
private boolean listEncoders;
private boolean listDisplays;
private boolean listCameras;
private boolean listCameraSizes;
private boolean listApps;
// Options not used by the scrcpy client, but useful to use scrcpy-server directly
private boolean sendDeviceMeta = true; // send device name and size
@ -169,6 +175,10 @@ public class Options {
return stayAwake;
}
public int getScreenOffTimeout() {
return screenOffTimeout;
}
public List<CodecOption> getVideoCodecOptions() {
return videoCodecOptions;
}
@ -205,8 +215,12 @@ public class Options {
return powerOn;
}
public NewDisplay getNewDisplay() {
return newDisplay;
}
public boolean getList() {
return listEncoders || listDisplays || listCameras || listCameraSizes;
return listEncoders || listDisplays || listCameras || listCameraSizes || listApps;
}
public boolean getListEncoders() {
@ -225,6 +239,10 @@ public class Options {
return listCameraSizes;
}
public boolean getListApps() {
return listApps;
}
public boolean getSendDeviceMeta() {
return sendDeviceMeta;
}
@ -241,6 +259,10 @@ public class Options {
return sendCodecMeta;
}
public void resetLockVideoOrientation() {
this.lockVideoOrientation = Device.LOCK_VIDEO_ORIENTATION_UNLOCKED;
}
@SuppressWarnings("MethodLength")
public static Options parse(String... args) {
if (args.length < 1) {
@ -346,6 +368,12 @@ public class Options {
case "stay_awake":
options.stayAwake = Boolean.parseBoolean(value);
break;
case "screen_off_timeout":
options.screenOffTimeout = Integer.parseInt(value);
if (options.screenOffTimeout < -1) {
throw new IllegalArgumentException("Invalid screen off timeout: " + options.screenOffTimeout);
}
break;
case "video_codec_options":
options.videoCodecOptions = CodecOption.parse(value);
break;
@ -388,6 +416,9 @@ public class Options {
case "list_camera_sizes":
options.listCameraSizes = Boolean.parseBoolean(value);
break;
case "list_apps":
options.listApps = Boolean.parseBoolean(value);
break;
case "camera_id":
if (!value.isEmpty()) {
options.cameraId = value;
@ -418,6 +449,9 @@ public class Options {
case "camera_high_speed":
options.cameraHighSpeed = Boolean.parseBoolean(value);
break;
case "new_display":
options.newDisplay = parseNewDisplay(value);
break;
case "send_device_meta":
options.sendDeviceMeta = Boolean.parseBoolean(value);
break;
@ -445,6 +479,11 @@ public class Options {
}
}
if (options.newDisplay != null) {
assert options.displayId == 0 : "Must not set both displayId and newDisplay";
options.displayId = Device.DISPLAY_ID_NONE;
}
return options;
}
@ -504,4 +543,36 @@ public class Options {
throw new IllegalArgumentException("Invalid float value for " + key + ": \"" + value + "\"");
}
}
private static NewDisplay parseNewDisplay(String newDisplay) {
// Possible inputs:
// - "" (empty string)
// - "<width>x<height>/<dpi>"
// - "<width>x<height>"
// - "/<dpi>"
if (newDisplay.isEmpty()) {
return new NewDisplay();
}
String[] tokens = newDisplay.split("/");
Size size;
if (!tokens[0].isEmpty()) {
size = parseSize(tokens[0]);
} else {
size = null;
}
int dpi;
if (tokens.length >= 2) {
dpi = Integer.parseInt(tokens[1]);
if (dpi <= 0) {
throw new IllegalArgumentException("Invalid non-positive dpi: " + tokens[1]);
}
} else {
dpi = 0;
}
return new NewDisplay(size, dpi);
}
}

View File

@ -9,22 +9,20 @@ import com.genymobile.scrcpy.audio.AudioRawRecorder;
import com.genymobile.scrcpy.audio.AudioSource;
import com.genymobile.scrcpy.control.ControlChannel;
import com.genymobile.scrcpy.control.Controller;
import com.genymobile.scrcpy.control.DeviceMessage;
import com.genymobile.scrcpy.device.ConfigurationException;
import com.genymobile.scrcpy.device.DesktopConnection;
import com.genymobile.scrcpy.device.Device;
import com.genymobile.scrcpy.device.NewDisplay;
import com.genymobile.scrcpy.device.Streamer;
import com.genymobile.scrcpy.util.Ln;
import com.genymobile.scrcpy.util.LogUtils;
import com.genymobile.scrcpy.util.Settings;
import com.genymobile.scrcpy.util.SettingsException;
import com.genymobile.scrcpy.video.CameraCapture;
import com.genymobile.scrcpy.video.NewDisplayCapture;
import com.genymobile.scrcpy.video.ScreenCapture;
import com.genymobile.scrcpy.video.SurfaceCapture;
import com.genymobile.scrcpy.video.SurfaceEncoder;
import com.genymobile.scrcpy.video.VideoSource;
import android.os.BatteryManager;
import android.os.Build;
import java.io.File;
@ -75,63 +73,38 @@ public final class Server {
// not instantiable
}
private static void initAndCleanUp(Options options, CleanUp cleanUp) {
// This method is called from its own thread, so it may only configure cleanup actions which are NOT dynamic (i.e. they are configured once
// and for all, they cannot be changed from another thread)
if (options.getShowTouches()) {
try {
String oldValue = Settings.getAndPutValue(Settings.TABLE_SYSTEM, "show_touches", "1");
// If "show touches" was disabled, it must be disabled back on clean up
if (!"1".equals(oldValue)) {
if (!cleanUp.setDisableShowTouches(true)) {
Ln.e("Could not disable show touch on exit");
}
}
} catch (SettingsException e) {
Ln.e("Could not change \"show_touches\"", e);
}
}
if (options.getStayAwake()) {
int stayOn = BatteryManager.BATTERY_PLUGGED_AC | BatteryManager.BATTERY_PLUGGED_USB | BatteryManager.BATTERY_PLUGGED_WIRELESS;
try {
String oldValue = Settings.getAndPutValue(Settings.TABLE_GLOBAL, "stay_on_while_plugged_in", String.valueOf(stayOn));
try {
int restoreStayOn = Integer.parseInt(oldValue);
if (restoreStayOn != stayOn) {
// Restore only if the current value is different
if (!cleanUp.setRestoreStayOn(restoreStayOn)) {
Ln.e("Could not restore stay on on exit");
}
}
} catch (NumberFormatException e) {
// ignore
}
} catch (SettingsException e) {
Ln.e("Could not change \"stay_on_while_plugged_in\"", e);
}
}
if (options.getPowerOffScreenOnClose()) {
if (!cleanUp.setPowerOffScreen(true)) {
Ln.e("Could not power off screen on exit");
}
}
}
private static void scrcpy(Options options) throws IOException, ConfigurationException {
if (Build.VERSION.SDK_INT < AndroidVersions.API_31_ANDROID_12 && options.getVideoSource() == VideoSource.CAMERA) {
Ln.e("Camera mirroring is not supported before Android 12");
throw new ConfigurationException("Camera mirroring is not supported");
}
if (Build.VERSION.SDK_INT < AndroidVersions.API_29_ANDROID_10 && options.getNewDisplay() != null) {
Ln.e("New virtual display is not supported before Android 10");
throw new ConfigurationException("New virtual display is not supported");
}
if (Build.VERSION.SDK_INT >= AndroidVersions.API_34_ANDROID_14) {
int lockVideoOrientation = options.getLockVideoOrientation();
if (lockVideoOrientation != Device.LOCK_VIDEO_ORIENTATION_UNLOCKED) {
if (lockVideoOrientation != Device.LOCK_VIDEO_ORIENTATION_INITIAL_AUTO) {
Ln.e("--lock-video-orientation is broken on Android >= 14: <https://github.com/Genymobile/scrcpy/issues/4011>");
throw new ConfigurationException("--lock-video-orientation is broken on Android >= 14");
} else {
// If the flag has been set automatically (because v4l2 sink is enabled), do not fail
Ln.w("--lock-video-orientation is ignored on Android >= 14: <https://github.com/Genymobile/scrcpy/issues/4011>");
}
}
if (options.getCrop() != null) {
Ln.e("--crop is broken on Android >= 14: <https://github.com/Genymobile/scrcpy/issues/4162>");
throw new ConfigurationException("Crop is not broken on Android >= 14");
}
}
CleanUp cleanUp = null;
Thread initThread = null;
if (options.getCleanup()) {
cleanUp = CleanUp.configure(options.getDisplayId());
initThread = startInitThread(options, cleanUp);
cleanUp = CleanUp.start(options);
}
int scid = options.getScid();
@ -140,9 +113,6 @@ public final class Server {
boolean video = options.getVideo();
boolean audio = options.getAudio();
boolean sendDummyByte = options.getSendDummyByte();
boolean camera = video && options.getVideoSource() == VideoSource.CAMERA;
final Device device = camera ? null : new Device(options);
Workarounds.apply();
@ -154,13 +124,11 @@ public final class Server {
connection.sendDeviceMeta(Device.getDeviceName());
}
Controller controller = null;
if (control) {
ControlChannel controlChannel = connection.getControlChannel();
Controller controller = new Controller(device, controlChannel, cleanUp, options.getClipboardAutosync(), options.getPowerOn());
device.setClipboardListener(text -> {
DeviceMessage msg = DeviceMessage.createClipboard(text);
controller.getSender().send(msg);
});
controller = new Controller(controlChannel, cleanUp, options);
asyncProcessors.add(controller);
}
@ -179,8 +147,7 @@ public final class Server {
if (audioCodec == AudioCodec.RAW) {
audioRecorder = new AudioRawRecorder(audioCapture, audioStreamer);
} else {
audioRecorder = new AudioEncoder(audioCapture, audioStreamer, options.getAudioBitRate(), options.getAudioCodecOptions(),
options.getAudioEncoder());
audioRecorder = new AudioEncoder(audioCapture, audioStreamer, options);
}
asyncProcessors.add(audioRecorder);
}
@ -190,14 +157,22 @@ public final class Server {
options.getSendFrameMeta());
SurfaceCapture surfaceCapture;
if (options.getVideoSource() == VideoSource.DISPLAY) {
surfaceCapture = new ScreenCapture(device);
NewDisplay newDisplay = options.getNewDisplay();
if (newDisplay != null) {
surfaceCapture = new NewDisplayCapture(controller, options);
} else {
assert options.getDisplayId() != Device.DISPLAY_ID_NONE;
surfaceCapture = new ScreenCapture(controller, options);
}
} else {
surfaceCapture = new CameraCapture(options.getCameraId(), options.getCameraFacing(), options.getCameraSize(),
options.getMaxSize(), options.getCameraAspectRatio(), options.getCameraFps(), options.getCameraHighSpeed());
surfaceCapture = new CameraCapture(options);
}
SurfaceEncoder surfaceEncoder = new SurfaceEncoder(surfaceCapture, videoStreamer, options.getVideoBitRate(), options.getMaxFps(),
options.getVideoCodecOptions(), options.getVideoEncoder(), options.getDownsizeOnError());
SurfaceEncoder surfaceEncoder = new SurfaceEncoder(surfaceCapture, videoStreamer, options);
asyncProcessors.add(surfaceEncoder);
if (controller != null) {
controller.setSurfaceCapture(surfaceCapture);
}
}
Completion completion = new Completion(asyncProcessors.size());
@ -209,8 +184,8 @@ public final class Server {
completion.await();
} finally {
if (initThread != null) {
initThread.interrupt();
if (cleanUp != null) {
cleanUp.interrupt();
}
for (AsyncProcessor asyncProcessor : asyncProcessors) {
asyncProcessor.stop();
@ -219,8 +194,8 @@ public final class Server {
connection.shutdown();
try {
if (initThread != null) {
initThread.join();
if (cleanUp != null) {
cleanUp.join();
}
for (AsyncProcessor asyncProcessor : asyncProcessors) {
asyncProcessor.join();
@ -233,12 +208,6 @@ public final class Server {
}
}
private static Thread startInitThread(final Options options, final CleanUp cleanUp) {
Thread thread = new Thread(() -> initAndCleanUp(options, cleanUp), "init-cleanup");
thread.start();
return thread;
}
public static void main(String... args) {
int status = 0;
try {
@ -282,6 +251,11 @@ public final class Server {
Workarounds.apply();
Ln.i(LogUtils.buildCameraListMessage(options.getListCameraSizes()));
}
if (options.getListApps()) {
Workarounds.apply();
Ln.i("Processing Android apps... (this may take some time)");
Ln.i(LogUtils.buildAppListMessage());
}
// Just print the requested data, do not mirror
return;
}

View File

@ -2,6 +2,7 @@ package com.genymobile.scrcpy.audio;
import com.genymobile.scrcpy.AndroidVersions;
import com.genymobile.scrcpy.AsyncProcessor;
import com.genymobile.scrcpy.Options;
import com.genymobile.scrcpy.device.ConfigurationException;
import com.genymobile.scrcpy.device.Streamer;
import com.genymobile.scrcpy.util.Codec;
@ -67,12 +68,12 @@ public final class AudioEncoder implements AsyncProcessor {
private boolean ended;
public AudioEncoder(AudioCapture capture, Streamer streamer, int bitRate, List<CodecOption> codecOptions, String encoderName) {
public AudioEncoder(AudioCapture capture, Streamer streamer, Options options) {
this.capture = capture;
this.streamer = streamer;
this.bitRate = bitRate;
this.codecOptions = codecOptions;
this.encoderName = encoderName;
this.bitRate = options.getAudioBitRate();
this.codecOptions = options.getAudioCodecOptions();
this.encoderName = options.getAudioEncoder();
}
private static MediaFormat createFormat(String mimeType, int bitRate, List<CodecOption> codecOptions) {

View File

@ -17,12 +17,14 @@ public final class ControlMessage {
public static final int TYPE_COLLAPSE_PANELS = 7;
public static final int TYPE_GET_CLIPBOARD = 8;
public static final int TYPE_SET_CLIPBOARD = 9;
public static final int TYPE_SET_SCREEN_POWER_MODE = 10;
public static final int TYPE_SET_DISPLAY_POWER = 10;
public static final int TYPE_ROTATE_DEVICE = 11;
public static final int TYPE_UHID_CREATE = 12;
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 int TYPE_RESET_VIDEO = 17;
public static final long SEQUENCE_INVALID = 0;
@ -33,7 +35,7 @@ public final class ControlMessage {
private int type;
private String text;
private int metaState; // KeyEvent.META_*
private int action; // KeyEvent.ACTION_* or MotionEvent.ACTION_* or POWER_MODE_*
private int action; // KeyEvent.ACTION_* or MotionEvent.ACTION_*
private int keycode; // KeyEvent.KEYCODE_*
private int actionButton; // MotionEvent.BUTTON_*
private int buttons; // MotionEvent.BUTTON_*
@ -48,6 +50,7 @@ public final class ControlMessage {
private long sequence;
private int id;
private byte[] data;
private boolean on;
private ControlMessage() {
}
@ -115,13 +118,10 @@ public final class ControlMessage {
return msg;
}
/**
* @param mode one of the {@code Device.SCREEN_POWER_MODE_*} constants
*/
public static ControlMessage createSetScreenPowerMode(int mode) {
public static ControlMessage createSetDisplayPower(boolean on) {
ControlMessage msg = new ControlMessage();
msg.type = TYPE_SET_SCREEN_POWER_MODE;
msg.action = mode;
msg.type = TYPE_SET_DISPLAY_POWER;
msg.on = on;
return msg;
}
@ -155,6 +155,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;
}
@ -226,4 +233,8 @@ public final class ControlMessage {
public byte[] getData() {
return data;
}
public boolean getOn() {
return on;
}
}

View File

@ -39,13 +39,14 @@ public class ControlMessageReader {
return parseGetClipboard();
case ControlMessage.TYPE_SET_CLIPBOARD:
return parseSetClipboard();
case ControlMessage.TYPE_SET_SCREEN_POWER_MODE:
return parseSetScreenPowerMode();
case ControlMessage.TYPE_SET_DISPLAY_POWER:
return parseSetDisplayPower();
case ControlMessage.TYPE_EXPAND_NOTIFICATION_PANEL:
case ControlMessage.TYPE_EXPAND_SETTINGS_PANEL:
case ControlMessage.TYPE_COLLAPSE_PANELS:
case ControlMessage.TYPE_ROTATE_DEVICE:
case ControlMessage.TYPE_OPEN_HARD_KEYBOARD_SETTINGS:
case ControlMessage.TYPE_RESET_VIDEO:
return ControlMessage.createEmpty(type);
case ControlMessage.TYPE_UHID_CREATE:
return parseUhidCreate();
@ -53,6 +54,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);
}
@ -132,9 +135,9 @@ public class ControlMessageReader {
return ControlMessage.createSetClipboard(sequence, text, paste);
}
private ControlMessage parseSetScreenPowerMode() throws IOException {
int mode = dis.readUnsignedByte();
return ControlMessage.createSetScreenPowerMode(mode);
private ControlMessage parseSetDisplayPower() throws IOException {
boolean on = dis.readBoolean();
return ControlMessage.createSetDisplayPower(on);
}
private ControlMessage parseUhidCreate() throws IOException {
@ -155,6 +158,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

@ -3,13 +3,20 @@ package com.genymobile.scrcpy.control;
import com.genymobile.scrcpy.AndroidVersions;
import com.genymobile.scrcpy.AsyncProcessor;
import com.genymobile.scrcpy.CleanUp;
import com.genymobile.scrcpy.Options;
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;
import com.genymobile.scrcpy.util.LogUtils;
import com.genymobile.scrcpy.video.SurfaceCapture;
import com.genymobile.scrcpy.video.VirtualDisplayListener;
import com.genymobile.scrcpy.wrappers.ClipboardManager;
import com.genymobile.scrcpy.wrappers.InputManager;
import com.genymobile.scrcpy.wrappers.ServiceManager;
import android.content.IOnPrimaryClipChangedListener;
import android.content.Intent;
import android.os.Build;
import android.os.SystemClock;
@ -19,11 +26,40 @@ import android.view.KeyEvent;
import android.view.MotionEvent;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
public class Controller implements AsyncProcessor {
public class Controller implements AsyncProcessor, VirtualDisplayListener {
/*
* For event injection, there are two display ids:
* - the displayId passed to the constructor (which comes from --display-id passed by the client, 0 for the main display);
* - the virtualDisplayId used for mirroring, notified by the capture instance via the VirtualDisplayListener interface.
*
* (In case the ScreenCapture uses the "SurfaceControl API", then both ids are equals, but this is an implementation detail.)
*
* In order to make events work correctly in all cases:
* - virtualDisplayId must be used for events relative to the display (mouse and touch events with coordinates);
* - displayId must be used for other events (like key events).
*
* If a new separate virtual display is created (using --new-display), then displayId == Device.DISPLAY_ID_NONE. In that case, all events are
* sent to the virtual display id.
*/
private static final class DisplayData {
private final int virtualDisplayId;
private final PositionMapper positionMapper;
private DisplayData(int virtualDisplayId, PositionMapper positionMapper) {
this.virtualDisplayId = virtualDisplayId;
this.positionMapper = positionMapper;
}
}
private static final int DEFAULT_DEVICE_ID = 0;
@ -31,12 +67,14 @@ public class Controller implements AsyncProcessor {
private static final int POINTER_ID_MOUSE = -1;
private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor();
private ExecutorService startAppExecutor;
private Thread thread;
private UhidManager uhidManager;
private final Device device;
private final int displayId;
private final boolean supportsInputEvents;
private final ControlChannel controlChannel;
private final CleanUp cleanUp;
private final DeviceMessageSender sender;
@ -45,21 +83,73 @@ public class Controller implements AsyncProcessor {
private final KeyCharacterMap charMap = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD);
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();
private final MotionEvent.PointerProperties[] pointerProperties = new MotionEvent.PointerProperties[PointersState.MAX_POINTERS];
private final MotionEvent.PointerCoords[] pointerCoords = new MotionEvent.PointerCoords[PointersState.MAX_POINTERS];
private boolean keepPowerModeOff;
private boolean keepDisplayPowerOff;
public Controller(Device device, ControlChannel controlChannel, CleanUp cleanUp, boolean clipboardAutosync, boolean powerOn) {
this.device = device;
// Used for resetting video encoding on RESET_VIDEO message
private SurfaceCapture surfaceCapture;
public Controller(ControlChannel controlChannel, CleanUp cleanUp, Options options) {
this.displayId = options.getDisplayId();
this.controlChannel = controlChannel;
this.cleanUp = cleanUp;
this.clipboardAutosync = clipboardAutosync;
this.powerOn = powerOn;
this.clipboardAutosync = options.getClipboardAutosync();
this.powerOn = options.getPowerOn();
initPointers();
sender = new DeviceMessageSender(controlChannel);
supportsInputEvents = Device.supportsInputEvents(displayId);
if (!supportsInputEvents) {
Ln.w("Input events are not supported for secondary displays before Android 10");
}
if (clipboardAutosync) {
// If control and autosync are enabled, synchronize Android clipboard to the computer automatically
ClipboardManager clipboardManager = ServiceManager.getClipboardManager();
if (clipboardManager != null) {
clipboardManager.addPrimaryClipChangedListener(new IOnPrimaryClipChangedListener.Stub() {
@Override
public void dispatchPrimaryClipChanged() {
if (isSettingClipboard.get()) {
// This is a notification for the change we are currently applying, ignore it
return;
}
String text = Device.getClipboardText();
if (text != null) {
DeviceMessage msg = DeviceMessage.createClipboard(text);
sender.send(msg);
}
}
});
} else {
Ln.w("No clipboard manager, copy-paste between device and computer will not work");
}
}
}
@Override
public void onNewVirtualDisplay(int virtualDisplayId, PositionMapper positionMapper) {
DisplayData data = new DisplayData(virtualDisplayId, positionMapper);
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();
}
}
}
public void setSurfaceCapture(SurfaceCapture surfaceCapture) {
this.surfaceCapture = surfaceCapture;
}
private UhidManager getUhidManager() {
@ -85,8 +175,8 @@ public class Controller implements AsyncProcessor {
private void control() throws IOException {
// on start, power on the device
if (powerOn && !Device.isScreenOn()) {
device.pressReleaseKeycode(KeyEvent.KEYCODE_POWER, Device.INJECT_MODE_ASYNC);
if (powerOn && displayId == 0 && !Device.isScreenOn(displayId)) {
Device.pressReleaseKeycode(KeyEvent.KEYCODE_POWER, displayId, Device.INJECT_MODE_ASYNC);
// dirty hack
// After POWER is injected, the device is powered on asynchronously.
@ -139,10 +229,6 @@ public class Controller implements AsyncProcessor {
sender.join();
}
public DeviceMessageSender getSender() {
return sender;
}
private boolean handleEvent() throws IOException {
ControlMessage msg;
try {
@ -154,27 +240,27 @@ public class Controller implements AsyncProcessor {
switch (msg.getType()) {
case ControlMessage.TYPE_INJECT_KEYCODE:
if (device.supportsInputEvents()) {
if (supportsInputEvents) {
injectKeycode(msg.getAction(), msg.getKeycode(), msg.getRepeat(), msg.getMetaState());
}
break;
case ControlMessage.TYPE_INJECT_TEXT:
if (device.supportsInputEvents()) {
if (supportsInputEvents) {
injectText(msg.getText());
}
break;
case ControlMessage.TYPE_INJECT_TOUCH_EVENT:
if (device.supportsInputEvents()) {
if (supportsInputEvents) {
injectTouch(msg.getAction(), msg.getPointerId(), msg.getPosition(), msg.getPressure(), msg.getActionButton(), msg.getButtons());
}
break;
case ControlMessage.TYPE_INJECT_SCROLL_EVENT:
if (device.supportsInputEvents()) {
if (supportsInputEvents) {
injectScroll(msg.getPosition(), msg.getHScroll(), msg.getVScroll(), msg.getButtons());
}
break;
case ControlMessage.TYPE_BACK_OR_SCREEN_ON:
if (device.supportsInputEvents()) {
if (supportsInputEvents) {
pressBackOrTurnScreenOn(msg.getAction());
}
break;
@ -193,22 +279,13 @@ public class Controller implements AsyncProcessor {
case ControlMessage.TYPE_SET_CLIPBOARD:
setClipboard(msg.getText(), msg.getPaste(), msg.getSequence());
break;
case ControlMessage.TYPE_SET_SCREEN_POWER_MODE:
if (device.supportsInputEvents()) {
int mode = msg.getAction();
boolean setPowerModeOk = Device.setScreenPowerMode(mode);
if (setPowerModeOk) {
keepPowerModeOff = mode == Device.POWER_MODE_OFF;
Ln.i("Device screen turned " + (mode == Device.POWER_MODE_OFF ? "off" : "on"));
if (cleanUp != null) {
boolean mustRestoreOnExit = mode != Device.POWER_MODE_NORMAL;
cleanUp.setRestoreNormalPowerMode(mustRestoreOnExit);
}
}
case ControlMessage.TYPE_SET_DISPLAY_POWER:
if (supportsInputEvents && displayId != Device.DISPLAY_ID_NONE) {
setDisplayPower(msg.getOn());
}
break;
case ControlMessage.TYPE_ROTATE_DEVICE:
device.rotateDevice();
Device.rotateDevice(getActionDisplayId());
break;
case ControlMessage.TYPE_UHID_CREATE:
getUhidManager().open(msg.getId(), msg.getText(), msg.getData());
@ -222,6 +299,12 @@ public class Controller implements AsyncProcessor {
case ControlMessage.TYPE_OPEN_HARD_KEYBOARD_SETTINGS:
openHardKeyboardSettings();
break;
case ControlMessage.TYPE_START_APP:
startAppAsync(msg.getText());
break;
case ControlMessage.TYPE_RESET_VIDEO:
resetVideo();
break;
default:
// do nothing
}
@ -230,10 +313,11 @@ public class Controller implements AsyncProcessor {
}
private boolean injectKeycode(int action, int keycode, int repeat, int metaState) {
if (keepPowerModeOff && action == KeyEvent.ACTION_UP && (keycode == KeyEvent.KEYCODE_POWER || keycode == KeyEvent.KEYCODE_WAKEUP)) {
schedulePowerModeOff();
if (keepDisplayPowerOff && action == KeyEvent.ACTION_UP && (keycode == KeyEvent.KEYCODE_POWER || keycode == KeyEvent.KEYCODE_WAKEUP)) {
assert displayId != Device.DISPLAY_ID_NONE;
scheduleDisplayPowerOff(displayId);
}
return device.injectKeyEvent(action, keycode, repeat, metaState, Device.INJECT_MODE_ASYNC);
return injectKeyEvent(action, keycode, repeat, metaState, Device.INJECT_MODE_ASYNC);
}
private boolean injectChar(char c) {
@ -243,8 +327,10 @@ public class Controller implements AsyncProcessor {
if (events == null) {
return false;
}
int actionDisplayId = getActionDisplayId();
for (KeyEvent event : events) {
if (!device.injectEvent(event, Device.INJECT_MODE_ASYNC)) {
if (!Device.injectEvent(event, actionDisplayId, Device.INJECT_MODE_ASYNC)) {
return false;
}
}
@ -266,7 +352,12 @@ public class Controller implements AsyncProcessor {
private boolean injectTouch(int action, long pointerId, Position position, float pressure, int actionButton, int buttons) {
long now = SystemClock.uptimeMillis();
Point point = device.getPhysicalPoint(position);
// it hides the field on purpose, to read it with atomic access
@SuppressWarnings("checkstyle:HiddenField")
DisplayData displayData = this.displayData.get();
assert displayData != null : "Cannot receive a touch event without a display";
Point point = displayData.positionMapper.map(position);
if (point == null) {
Ln.w("Ignore touch event, it was generated for a different device size");
return false;
@ -325,7 +416,7 @@ public class Controller implements AsyncProcessor {
// First button pressed: ACTION_DOWN
MotionEvent downEvent = MotionEvent.obtain(lastTouchDown, now, MotionEvent.ACTION_DOWN, pointerCount, pointerProperties,
pointerCoords, 0, buttons, 1f, 1f, DEFAULT_DEVICE_ID, 0, source, 0);
if (!device.injectEvent(downEvent, Device.INJECT_MODE_ASYNC)) {
if (!Device.injectEvent(downEvent, displayData.virtualDisplayId, Device.INJECT_MODE_ASYNC)) {
return false;
}
}
@ -336,7 +427,7 @@ public class Controller implements AsyncProcessor {
if (!InputManager.setActionButton(pressEvent, actionButton)) {
return false;
}
if (!device.injectEvent(pressEvent, Device.INJECT_MODE_ASYNC)) {
if (!Device.injectEvent(pressEvent, displayData.virtualDisplayId, Device.INJECT_MODE_ASYNC)) {
return false;
}
@ -350,7 +441,7 @@ public class Controller implements AsyncProcessor {
if (!InputManager.setActionButton(releaseEvent, actionButton)) {
return false;
}
if (!device.injectEvent(releaseEvent, Device.INJECT_MODE_ASYNC)) {
if (!Device.injectEvent(releaseEvent, displayData.virtualDisplayId, Device.INJECT_MODE_ASYNC)) {
return false;
}
@ -358,7 +449,7 @@ public class Controller implements AsyncProcessor {
// Last button released: ACTION_UP
MotionEvent upEvent = MotionEvent.obtain(lastTouchDown, now, MotionEvent.ACTION_UP, pointerCount, pointerProperties,
pointerCoords, 0, buttons, 1f, 1f, DEFAULT_DEVICE_ID, 0, source, 0);
if (!device.injectEvent(upEvent, Device.INJECT_MODE_ASYNC)) {
if (!Device.injectEvent(upEvent, displayData.virtualDisplayId, Device.INJECT_MODE_ASYNC)) {
return false;
}
}
@ -369,14 +460,20 @@ public class Controller implements AsyncProcessor {
MotionEvent event = MotionEvent.obtain(lastTouchDown, now, action, pointerCount, pointerProperties, pointerCoords, 0, buttons, 1f, 1f,
DEFAULT_DEVICE_ID, 0, source, 0);
return device.injectEvent(event, Device.INJECT_MODE_ASYNC);
return Device.injectEvent(event, displayData.virtualDisplayId, Device.INJECT_MODE_ASYNC);
}
private boolean injectScroll(Position position, float hScroll, float vScroll, int buttons) {
long now = SystemClock.uptimeMillis();
Point point = device.getPhysicalPoint(position);
// it hides the field on purpose, to read it with atomic access
@SuppressWarnings("checkstyle:HiddenField")
DisplayData displayData = this.displayData.get();
assert displayData != null : "Cannot receive a scroll event without a display";
Point point = displayData.positionMapper.map(position);
if (point == null) {
// ignore event
Ln.w("Ignore scroll event, it was generated for a different device size");
return false;
}
@ -391,22 +488,22 @@ public class Controller implements AsyncProcessor {
MotionEvent event = MotionEvent.obtain(lastTouchDown, now, MotionEvent.ACTION_SCROLL, 1, pointerProperties, pointerCoords, 0, buttons, 1f, 1f,
DEFAULT_DEVICE_ID, 0, InputDevice.SOURCE_MOUSE, 0);
return device.injectEvent(event, Device.INJECT_MODE_ASYNC);
return Device.injectEvent(event, displayData.virtualDisplayId, Device.INJECT_MODE_ASYNC);
}
/**
* Schedule a call to set power mode to off after a small delay.
* Schedule a call to set display power to off after a small delay.
*/
private static void schedulePowerModeOff() {
private static void scheduleDisplayPowerOff(int displayId) {
EXECUTOR.schedule(() -> {
Ln.i("Forcing screen off");
Device.setScreenPowerMode(Device.POWER_MODE_OFF);
Ln.i("Forcing display off");
Device.setDisplayPower(displayId, false);
}, 200, TimeUnit.MILLISECONDS);
}
private boolean pressBackOrTurnScreenOn(int action) {
if (Device.isScreenOn()) {
return device.injectKeyEvent(action, KeyEvent.KEYCODE_BACK, 0, 0, Device.INJECT_MODE_ASYNC);
if (displayId == Device.DISPLAY_ID_NONE || Device.isScreenOn(displayId)) {
return injectKeyEvent(action, KeyEvent.KEYCODE_BACK, 0, 0, Device.INJECT_MODE_ASYNC);
}
// Screen is off
@ -416,18 +513,19 @@ public class Controller implements AsyncProcessor {
return true;
}
if (keepPowerModeOff) {
schedulePowerModeOff();
if (keepDisplayPowerOff) {
assert displayId != Device.DISPLAY_ID_NONE;
scheduleDisplayPowerOff(displayId);
}
return device.pressReleaseKeycode(KeyEvent.KEYCODE_POWER, Device.INJECT_MODE_ASYNC);
return pressReleaseKeycode(KeyEvent.KEYCODE_POWER, Device.INJECT_MODE_ASYNC);
}
private void getClipboard(int copyKey) {
// On Android >= 7, press the COPY or CUT key if requested
if (copyKey != ControlMessage.COPY_KEY_NONE && Build.VERSION.SDK_INT >= AndroidVersions.API_24_ANDROID_7_0 && device.supportsInputEvents()) {
if (copyKey != ControlMessage.COPY_KEY_NONE && Build.VERSION.SDK_INT >= AndroidVersions.API_24_ANDROID_7_0 && supportsInputEvents) {
int key = copyKey == ControlMessage.COPY_KEY_COPY ? KeyEvent.KEYCODE_COPY : KeyEvent.KEYCODE_CUT;
// Wait until the event is finished, to ensure that the clipboard text we read just after is the correct one
device.pressReleaseKeycode(key, Device.INJECT_MODE_WAIT_FOR_FINISH);
pressReleaseKeycode(key, Device.INJECT_MODE_WAIT_FOR_FINISH);
}
// If clipboard autosync is enabled, then the device clipboard is synchronized to the computer clipboard whenever it changes, in
@ -443,14 +541,16 @@ public class Controller implements AsyncProcessor {
}
private boolean setClipboard(String text, boolean paste, long sequence) {
boolean ok = device.setClipboardText(text);
isSettingClipboard.set(true);
boolean ok = Device.setClipboardText(text);
isSettingClipboard.set(false);
if (ok) {
Ln.i("Device clipboard set");
}
// On Android >= 7, also press the PASTE key if requested
if (paste && Build.VERSION.SDK_INT >= AndroidVersions.API_24_ANDROID_7_0 && device.supportsInputEvents()) {
device.pressReleaseKeycode(KeyEvent.KEYCODE_PASTE, Device.INJECT_MODE_ASYNC);
if (paste && Build.VERSION.SDK_INT >= AndroidVersions.API_24_ANDROID_7_0 && supportsInputEvents) {
pressReleaseKeycode(KeyEvent.KEYCODE_PASTE, Device.INJECT_MODE_ASYNC);
}
if (sequence != ControlMessage.SEQUENCE_INVALID) {
@ -466,4 +566,137 @@ public class Controller implements AsyncProcessor {
Intent intent = new Intent("android.settings.HARD_KEYBOARD_SETTINGS");
ServiceManager.getActivityManager().startActivity(intent);
}
private boolean injectKeyEvent(int action, int keyCode, int repeat, int metaState, int injectMode) {
return Device.injectKeyEvent(action, keyCode, repeat, metaState, getActionDisplayId(), injectMode);
}
private boolean pressReleaseKeycode(int keyCode, int injectMode) {
return Device.pressReleaseKeycode(keyCode, getActionDisplayId(), injectMode);
}
private int getActionDisplayId() {
if (displayId != Device.DISPLAY_ID_NONE) {
// Real screen mirrored, use the source display id
return displayId;
}
// Virtual display created by --new-display, use the virtualDisplayId
DisplayData data = displayData.get();
if (data == null) {
// If no virtual display id is initialized yet, use the main display id
return 0;
}
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) {
boolean forceStopBeforeStart = name.startsWith("+");
if (forceStopBeforeStart) {
name = name.substring(1);
}
DeviceApp app;
boolean searchByName = name.startsWith("?");
if (searchByName) {
name = name.substring(1);
Ln.i("Processing Android apps... (this may take some time)");
List<DeviceApp> apps = Device.findByName(name);
if (apps.isEmpty()) {
Ln.w("No app found for name \"" + name + "\"");
return;
}
if (apps.size() > 1) {
String title = "No unique app found for name \"" + name + "\":";
Ln.w(LogUtils.buildAppListMessage(title, apps));
return;
}
app = apps.get(0);
} else {
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, forceStopBeforeStart);
}
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;
}
}
private void setDisplayPower(boolean on) {
boolean setDisplayPowerOk = Device.setDisplayPower(displayId, on);
if (setDisplayPowerOk) {
keepDisplayPowerOff = !on;
Ln.i("Device display turned " + (on ? "on" : "off"));
if (cleanUp != null) {
boolean mustRestoreOnExit = !on;
cleanUp.setRestoreDisplayPower(mustRestoreOnExit);
}
}
}
private void resetVideo() {
if (surfaceCapture != null) {
Ln.i("Video capture reset");
surfaceCapture.requestInvalidate();
}
}
}

View File

@ -0,0 +1,48 @@
package com.genymobile.scrcpy.control;
import com.genymobile.scrcpy.device.Point;
import com.genymobile.scrcpy.device.Position;
import com.genymobile.scrcpy.device.Size;
import com.genymobile.scrcpy.video.ScreenInfo;
import android.graphics.Rect;
public final class PositionMapper {
private final Size videoSize;
private final Rect contentRect;
private final int coordsRotation;
public PositionMapper(Size videoSize, Rect contentRect, int videoRotation) {
this.videoSize = videoSize;
this.contentRect = contentRect;
this.coordsRotation = reverseRotation(videoRotation);
}
public static PositionMapper from(ScreenInfo screenInfo) {
// ignore the locked video orientation, the events will apply in coordinates considered in the physical device orientation
Size videoSize = screenInfo.getUnlockedVideoSize();
return new PositionMapper(videoSize, screenInfo.getContentRect(), screenInfo.getVideoRotation());
}
private static int reverseRotation(int rotation) {
return (4 - rotation) % 4;
}
public Point map(Position position) {
// reverse the video rotation to apply the events
Position devicePosition = position.rotate(coordsRotation);
Size clientVideoSize = devicePosition.getScreenSize();
if (!videoSize.equals(clientVideoSize)) {
// The client sends a click relative to a video with wrong dimensions,
// the device may have been rotated since the event was generated, so ignore the event
return null;
}
Point point = devicePosition.getPoint();
int convertedX = contentRect.left + point.getX() * contentRect.width() / videoSize.getWidth();
int convertedY = contentRect.top + point.getY() * contentRect.height() / videoSize.getHeight();
return new Point(convertedX, convertedY);
}
}

View File

@ -1,10 +1,9 @@
package com.genymobile.scrcpy.device;
import com.genymobile.scrcpy.AndroidVersions;
import com.genymobile.scrcpy.Options;
import com.genymobile.scrcpy.FakeContext;
import com.genymobile.scrcpy.util.Ln;
import com.genymobile.scrcpy.util.LogUtils;
import com.genymobile.scrcpy.video.ScreenInfo;
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,22 +11,28 @@ import com.genymobile.scrcpy.wrappers.ServiceManager;
import com.genymobile.scrcpy.wrappers.SurfaceControl;
import com.genymobile.scrcpy.wrappers.WindowManager;
import android.content.IOnPrimaryClipChangedListener;
import android.graphics.Rect;
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.IDisplayFoldListener;
import android.view.IRotationWatcher;
import android.view.InputDevice;
import android.view.InputEvent;
import android.view.KeyCharacterMap;
import android.view.KeyEvent;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
public final class Device {
public static final int DISPLAY_ID_NONE = -1;
public static final int POWER_MODE_OFF = SurfaceControl.POWER_MODE_OFF;
public static final int POWER_MODE_NORMAL = SurfaceControl.POWER_MODE_NORMAL;
@ -37,178 +42,11 @@ public final class Device {
public static final int LOCK_VIDEO_ORIENTATION_UNLOCKED = -1;
public static final int LOCK_VIDEO_ORIENTATION_INITIAL = -2;
// like SC_LOCK_VIDEO_ORIENTATION_INITIAL, but set automatically
public static final int LOCK_VIDEO_ORIENTATION_INITIAL_AUTO = -3;
public interface RotationListener {
void onRotationChanged(int rotation);
}
public interface FoldListener {
void onFoldChanged(int displayId, boolean folded);
}
public interface ClipboardListener {
void onClipboardTextChanged(String text);
}
private final Rect crop;
private int maxSize;
private final int lockVideoOrientation;
private Size deviceSize;
private ScreenInfo screenInfo;
private RotationListener rotationListener;
private FoldListener foldListener;
private ClipboardListener clipboardListener;
private final AtomicBoolean isSettingClipboard = new AtomicBoolean();
/**
* Logical display identifier
*/
private final int displayId;
/**
* The surface flinger layer stack associated with this logical display
*/
private final int layerStack;
private final boolean supportsInputEvents;
public Device(Options options) throws ConfigurationException {
displayId = options.getDisplayId();
DisplayInfo displayInfo = ServiceManager.getDisplayManager().getDisplayInfo(displayId);
if (displayInfo == null) {
Ln.e("Display " + displayId + " not found\n" + LogUtils.buildDisplayListMessage());
throw new ConfigurationException("Unknown display id: " + displayId);
}
int displayInfoFlags = displayInfo.getFlags();
deviceSize = displayInfo.getSize();
crop = options.getCrop();
maxSize = options.getMaxSize();
lockVideoOrientation = options.getLockVideoOrientation();
screenInfo = ScreenInfo.computeScreenInfo(displayInfo.getRotation(), deviceSize, crop, maxSize, lockVideoOrientation);
layerStack = displayInfo.getLayerStack();
ServiceManager.getWindowManager().registerRotationWatcher(new IRotationWatcher.Stub() {
@Override
public void onRotationChanged(int rotation) {
synchronized (Device.this) {
screenInfo = screenInfo.withDeviceRotation(rotation);
// notify
if (rotationListener != null) {
rotationListener.onRotationChanged(rotation);
}
}
}
}, displayId);
if (Build.VERSION.SDK_INT >= AndroidVersions.API_29_ANDROID_10) {
ServiceManager.getWindowManager().registerDisplayFoldListener(new IDisplayFoldListener.Stub() {
@Override
public void onDisplayFoldChanged(int displayId, boolean folded) {
if (Device.this.displayId != displayId) {
// Ignore events related to other display ids
return;
}
synchronized (Device.this) {
DisplayInfo displayInfo = ServiceManager.getDisplayManager().getDisplayInfo(displayId);
if (displayInfo == null) {
Ln.e("Display " + displayId + " not found\n" + LogUtils.buildDisplayListMessage());
return;
}
deviceSize = displayInfo.getSize();
screenInfo = ScreenInfo.computeScreenInfo(displayInfo.getRotation(), deviceSize, crop, maxSize, lockVideoOrientation);
// notify
if (foldListener != null) {
foldListener.onFoldChanged(displayId, folded);
}
}
}
});
}
if (options.getControl() && options.getClipboardAutosync()) {
// If control and autosync are enabled, synchronize Android clipboard to the computer automatically
ClipboardManager clipboardManager = ServiceManager.getClipboardManager();
if (clipboardManager != null) {
clipboardManager.addPrimaryClipChangedListener(new IOnPrimaryClipChangedListener.Stub() {
@Override
public void dispatchPrimaryClipChanged() {
if (isSettingClipboard.get()) {
// This is a notification for the change we are currently applying, ignore it
return;
}
synchronized (Device.this) {
if (clipboardListener != null) {
String text = getClipboardText();
if (text != null) {
clipboardListener.onClipboardTextChanged(text);
}
}
}
}
});
} else {
Ln.w("No clipboard manager, copy-paste between device and computer will not work");
}
}
if ((displayInfoFlags & DisplayInfo.FLAG_SUPPORTS_PROTECTED_BUFFERS) == 0) {
Ln.w("Display doesn't have FLAG_SUPPORTS_PROTECTED_BUFFERS flag, mirroring can be restricted");
}
// main display or any display on Android >= 10
supportsInputEvents = displayId == 0 || Build.VERSION.SDK_INT >= AndroidVersions.API_29_ANDROID_10;
if (!supportsInputEvents) {
Ln.w("Input events are not supported for secondary displays before Android 10");
}
}
public int getDisplayId() {
return displayId;
}
public synchronized void setMaxSize(int newMaxSize) {
maxSize = newMaxSize;
screenInfo = ScreenInfo.computeScreenInfo(screenInfo.getReverseVideoRotation(), deviceSize, crop, newMaxSize, lockVideoOrientation);
}
public synchronized ScreenInfo getScreenInfo() {
return screenInfo;
}
public int getLayerStack() {
return layerStack;
}
public Point getPhysicalPoint(Position position) {
// it hides the field on purpose, to read it with a lock
@SuppressWarnings("checkstyle:HiddenField")
ScreenInfo screenInfo = getScreenInfo(); // read with synchronization
// ignore the locked video orientation, the events will apply in coordinates considered in the physical device orientation
Size unlockedVideoSize = screenInfo.getUnlockedVideoSize();
int reverseVideoRotation = screenInfo.getReverseVideoRotation();
// reverse the video rotation to apply the events
Position devicePosition = position.rotate(reverseVideoRotation);
Size clientVideoSize = devicePosition.getScreenSize();
if (!unlockedVideoSize.equals(clientVideoSize)) {
// The client sends a click relative to a video with wrong dimensions,
// the device may have been rotated since the event was generated, so ignore the event
return null;
}
Rect contentRect = screenInfo.getContentRect();
Point point = devicePosition.getPoint();
int convertedX = contentRect.left + point.getX() * contentRect.width() / unlockedVideoSize.getWidth();
int convertedY = contentRect.top + point.getY() * contentRect.height() / unlockedVideoSize.getHeight();
return new Point(convertedX, convertedY);
private Device() {
// not instantiable
}
public static String getDeviceName() {
@ -216,13 +54,10 @@ public final class Device {
}
public static boolean supportsInputEvents(int displayId) {
// main display or any display on Android >= 10
return displayId == 0 || Build.VERSION.SDK_INT >= AndroidVersions.API_29_ANDROID_10;
}
public boolean supportsInputEvents() {
return supportsInputEvents;
}
public static boolean injectEvent(InputEvent inputEvent, int displayId, int injectMode) {
if (!supportsInputEvents(displayId)) {
throw new AssertionError("Could not inject input event if !supportsInputEvents()");
@ -235,10 +70,6 @@ public final class Device {
return ServiceManager.getInputManager().injectInputEvent(inputEvent, injectMode);
}
public boolean injectEvent(InputEvent event, int injectMode) {
return injectEvent(event, displayId, injectMode);
}
public static boolean injectKeyEvent(int action, int keyCode, int repeat, int metaState, int displayId, int injectMode) {
long now = SystemClock.uptimeMillis();
KeyEvent event = new KeyEvent(now, now, action, keyCode, repeat, metaState, KeyCharacterMap.VIRTUAL_KEYBOARD, 0, 0,
@ -246,33 +77,14 @@ public final class Device {
return injectEvent(event, displayId, injectMode);
}
public boolean injectKeyEvent(int action, int keyCode, int repeat, int metaState, int injectMode) {
return injectKeyEvent(action, keyCode, repeat, metaState, displayId, injectMode);
}
public static boolean pressReleaseKeycode(int keyCode, int displayId, int injectMode) {
return injectKeyEvent(KeyEvent.ACTION_DOWN, keyCode, 0, 0, displayId, injectMode)
&& injectKeyEvent(KeyEvent.ACTION_UP, keyCode, 0, 0, displayId, injectMode);
}
public boolean pressReleaseKeycode(int keyCode, int injectMode) {
return pressReleaseKeycode(keyCode, displayId, injectMode);
}
public static boolean isScreenOn() {
return ServiceManager.getPowerManager().isScreenOn();
}
public synchronized void setRotationListener(RotationListener rotationListener) {
this.rotationListener = rotationListener;
}
public synchronized void setFoldListener(FoldListener foldlistener) {
this.foldListener = foldlistener;
}
public synchronized void setClipboardListener(ClipboardListener clipboardListener) {
this.clipboardListener = clipboardListener;
public static boolean isScreenOn(int displayId) {
assert displayId != DISPLAY_ID_NONE;
return ServiceManager.getPowerManager().isScreenOn(displayId);
}
public static void expandNotificationPanel() {
@ -299,7 +111,7 @@ public final class Device {
return s.toString();
}
public boolean setClipboardText(String text) {
public static boolean setClipboardText(String text) {
ClipboardManager clipboardManager = ServiceManager.getClipboardManager();
if (clipboardManager == null) {
return false;
@ -314,16 +126,16 @@ public final class Device {
return false;
}
isSettingClipboard.set(true);
boolean ok = clipboardManager.setText(text);
isSettingClipboard.set(false);
return ok;
return clipboardManager.setText(text);
}
/**
* @param mode one of the {@code POWER_MODE_*} constants
*/
public static boolean setScreenPowerMode(int mode) {
public static boolean setDisplayPower(int displayId, boolean on) {
assert displayId != Device.DISPLAY_ID_NONE;
if (Build.VERSION.SDK_INT >= AndroidVersions.API_35_ANDROID_15) {
return ServiceManager.getDisplayManager().requestDisplayPower(displayId, on);
}
boolean applyToMultiPhysicalDisplays = Build.VERSION.SDK_INT >= AndroidVersions.API_29_ANDROID_10;
if (applyToMultiPhysicalDisplays
@ -336,6 +148,7 @@ public final class Device {
applyToMultiPhysicalDisplays = false;
}
int mode = on ? POWER_MODE_NORMAL : POWER_MODE_OFF;
if (applyToMultiPhysicalDisplays) {
// On Android 14, these internal methods have been moved to DisplayControl
boolean useDisplayControl =
@ -367,7 +180,9 @@ public final class Device {
}
public static boolean powerOffScreen(int displayId) {
if (!isScreenOn()) {
assert displayId != DISPLAY_ID_NONE;
if (!isScreenOn(displayId)) {
return true;
}
return pressReleaseKeycode(KeyEvent.KEYCODE_POWER, displayId, Device.INJECT_MODE_ASYNC);
@ -376,7 +191,9 @@ public final class Device {
/**
* Disable auto-rotation (if enabled), set the screen rotation and re-enable auto-rotation (if it was enabled).
*/
public void rotateDevice() {
public static void rotateDevice(int displayId) {
assert displayId != DISPLAY_ID_NONE;
WindowManager wm = ServiceManager.getWindowManager();
boolean accelerometerRotation = !wm.isRotationFrozen(displayId);
@ -395,6 +212,8 @@ public final class Device {
}
private static int getCurrentRotation(int displayId) {
assert displayId != DISPLAY_ID_NONE;
if (displayId == 0) {
return ServiceManager.getWindowManager().getRotation();
}
@ -402,4 +221,96 @@ public final class Device {
DisplayInfo displayInfo = ServiceManager.getDisplayManager().getDisplayInfo(displayId);
return displayInfo.getRotation();
}
public static List<DeviceApp> listApps() {
List<DeviceApp> apps = new ArrayList<>();
PackageManager pm = FakeContext.get().getPackageManager();
for (ApplicationInfo appInfo : getLaunchableApps(pm)) {
apps.add(toApp(pm, appInfo));
}
return apps;
}
@SuppressLint("QueryPermissionsNeeded")
private static List<ApplicationInfo> getLaunchableApps(PackageManager pm) {
List<ApplicationInfo> result = new ArrayList<>();
for (ApplicationInfo appInfo : pm.getInstalledApplications(PackageManager.GET_META_DATA)) {
if (appInfo.enabled && getLaunchIntent(pm, appInfo.packageName) != null) {
result.add(appInfo);
}
}
return result;
}
public static Intent getLaunchIntent(PackageManager pm, String packageName) {
Intent launchIntent = pm.getLaunchIntentForPackage(packageName);
if (launchIntent != null) {
return launchIntent;
}
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;
}
@SuppressLint("QueryPermissionsNeeded")
public static List<DeviceApp> findByName(String searchName) {
List<DeviceApp> result = new ArrayList<>();
searchName = searchName.toLowerCase(Locale.getDefault());
PackageManager pm = FakeContext.get().getPackageManager();
for (ApplicationInfo appInfo : getLaunchableApps(pm)) {
String name = pm.getApplicationLabel(appInfo).toString();
if (name.toLowerCase(Locale.getDefault()).startsWith(searchName)) {
boolean system = (appInfo.flags & ApplicationInfo.FLAG_SYSTEM) != 0;
result.add(new DeviceApp(appInfo.packageName, name, system));
}
}
return result;
}
public static void startApp(String packageName, int displayId, boolean forceStop) {
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();
if (forceStop) {
am.forceStopPackage(packageName);
}
am.startActivity(launchIntent, options);
}
}

View File

@ -0,0 +1,26 @@
package com.genymobile.scrcpy.device;
public final class DeviceApp {
private final String packageName;
private final String name;
private final boolean system;
public DeviceApp(String packageName, String name, boolean system) {
this.packageName = packageName;
this.name = name;
this.system = system;
}
public String getPackageName() {
return packageName;
}
public String getName() {
return name;
}
public boolean isSystem() {
return system;
}
}

View File

@ -6,15 +6,17 @@ public final class DisplayInfo {
private final int rotation;
private final int layerStack;
private final int flags;
private final int dpi;
public static final int FLAG_SUPPORTS_PROTECTED_BUFFERS = 0x00000001;
public DisplayInfo(int displayId, Size size, int rotation, int layerStack, int flags) {
public DisplayInfo(int displayId, Size size, int rotation, int layerStack, int flags, int dpi) {
this.displayId = displayId;
this.size = size;
this.rotation = rotation;
this.layerStack = layerStack;
this.flags = flags;
this.dpi = dpi;
}
public int getDisplayId() {
@ -36,5 +38,9 @@ public final class DisplayInfo {
public int getFlags() {
return flags;
}
public int getDpi() {
return dpi;
}
}

View File

@ -0,0 +1,31 @@
package com.genymobile.scrcpy.device;
public final class NewDisplay {
private Size size;
private int dpi;
public NewDisplay() {
// Auto size and dpi
}
public NewDisplay(Size size, int dpi) {
this.size = size;
this.dpi = dpi;
}
public Size getSize() {
return size;
}
public int getDpi() {
return dpi;
}
public boolean hasExplicitSize() {
return size != null;
}
public boolean hasExplicitDpi() {
return dpi != 0;
}
}

View File

@ -21,6 +21,10 @@ public final class Size {
return height;
}
public int getMax() {
return Math.max(width, height);
}
public Size rotate() {
return new Size(height, width);
}
@ -48,6 +52,6 @@ public final class Size {
@Override
public String toString() {
return "Size{" + "width=" + width + ", height=" + height + '}';
return "Size{" + width + 'x' + height + '}';
}
}

View File

@ -1,8 +1,5 @@
package com.genymobile.scrcpy.util;
import com.genymobile.scrcpy.audio.AudioCodec;
import com.genymobile.scrcpy.video.VideoCodec;
import android.media.MediaCodecInfo;
import android.media.MediaCodecList;
import android.media.MediaFormat;
@ -13,24 +10,6 @@ import java.util.List;
public final class CodecUtils {
public static final class DeviceEncoder {
private final Codec codec;
private final MediaCodecInfo info;
DeviceEncoder(Codec codec, MediaCodecInfo info) {
this.codec = codec;
this.info = info;
}
public Codec getCodec() {
return codec;
}
public MediaCodecInfo getInfo() {
return info;
}
}
private CodecUtils() {
// not instantiable
}
@ -47,7 +26,7 @@ public final class CodecUtils {
}
}
private static MediaCodecInfo[] getEncoders(MediaCodecList codecs, String mimeType) {
public static MediaCodecInfo[] getEncoders(MediaCodecList codecs, String mimeType) {
List<MediaCodecInfo> result = new ArrayList<>();
for (MediaCodecInfo codecInfo : codecs.getCodecInfos()) {
if (codecInfo.isEncoder() && Arrays.asList(codecInfo.getSupportedTypes()).contains(mimeType)) {
@ -56,26 +35,4 @@ public final class CodecUtils {
}
return result.toArray(new MediaCodecInfo[result.size()]);
}
public static List<DeviceEncoder> listVideoEncoders() {
List<DeviceEncoder> encoders = new ArrayList<>();
MediaCodecList codecs = new MediaCodecList(MediaCodecList.REGULAR_CODECS);
for (VideoCodec codec : VideoCodec.values()) {
for (MediaCodecInfo info : getEncoders(codecs, codec.getMimeType())) {
encoders.add(new DeviceEncoder(codec, info));
}
}
return encoders;
}
public static List<DeviceEncoder> listAudioEncoders() {
List<DeviceEncoder> encoders = new ArrayList<>();
MediaCodecList codecs = new MediaCodecList(MediaCodecList.REGULAR_CODECS);
for (AudioCodec codec : AudioCodec.values()) {
for (MediaCodecInfo info : getEncoders(codecs, codec.getMimeType())) {
encoders.add(new DeviceEncoder(codec, info));
}
}
return encoders;
}
}

View File

@ -1,19 +1,31 @@
package com.genymobile.scrcpy.util;
import com.genymobile.scrcpy.AndroidVersions;
import com.genymobile.scrcpy.audio.AudioCodec;
import com.genymobile.scrcpy.device.Device;
import com.genymobile.scrcpy.device.DeviceApp;
import com.genymobile.scrcpy.device.DisplayInfo;
import com.genymobile.scrcpy.device.Size;
import com.genymobile.scrcpy.video.VideoCodec;
import com.genymobile.scrcpy.wrappers.DisplayManager;
import com.genymobile.scrcpy.wrappers.ServiceManager;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.graphics.Rect;
import android.hardware.camera2.CameraAccessException;
import android.hardware.camera2.CameraCharacteristics;
import android.hardware.camera2.CameraManager;
import android.hardware.camera2.params.StreamConfigurationMap;
import android.media.MediaCodec;
import android.media.MediaCodecInfo;
import android.media.MediaCodecList;
import android.os.Build;
import android.util.Range;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.SortedSet;
import java.util.TreeSet;
@ -23,32 +35,54 @@ public final class LogUtils {
// not instantiable
}
public static String buildVideoEncoderListMessage() {
StringBuilder builder = new StringBuilder("List of video encoders:");
List<CodecUtils.DeviceEncoder> videoEncoders = CodecUtils.listVideoEncoders();
if (videoEncoders.isEmpty()) {
builder.append("\n (none)");
} else {
for (CodecUtils.DeviceEncoder encoder : videoEncoders) {
builder.append("\n --video-codec=").append(encoder.getCodec().getName());
builder.append(" --video-encoder=").append(encoder.getInfo().getName());
private static String buildEncoderListMessage(String type, Codec[] codecs) {
StringBuilder builder = new StringBuilder("List of ").append(type).append(" encoders:");
MediaCodecList codecList = new MediaCodecList(MediaCodecList.REGULAR_CODECS);
for (Codec codec : codecs) {
MediaCodecInfo[] encoders = CodecUtils.getEncoders(codecList, codec.getMimeType());
for (MediaCodecInfo info : encoders) {
int lineStart = builder.length();
builder.append("\n --").append(type).append("-codec=").append(codec.getName());
builder.append(" --").append(type).append("-encoder=").append(info.getName());
if (Build.VERSION.SDK_INT >= AndroidVersions.API_29_ANDROID_10) {
int lineLength = builder.length() - lineStart;
final int column = 70;
if (lineLength < column) {
int padding = column - lineLength;
builder.append(String.format("%" + padding + "s", " "));
}
builder.append(" (").append(getHwCodecType(info)).append(')');
if (info.isVendor()) {
builder.append(" [vendor]");
}
if (info.isAlias()) {
builder.append(" (alias for ").append(info.getCanonicalName()).append(')');
}
}
}
}
return builder.toString();
}
public static String buildVideoEncoderListMessage() {
return buildEncoderListMessage("video", VideoCodec.values());
}
public static String buildAudioEncoderListMessage() {
StringBuilder builder = new StringBuilder("List of audio encoders:");
List<CodecUtils.DeviceEncoder> audioEncoders = CodecUtils.listAudioEncoders();
if (audioEncoders.isEmpty()) {
builder.append("\n (none)");
} else {
for (CodecUtils.DeviceEncoder encoder : audioEncoders) {
builder.append("\n --audio-codec=").append(encoder.getCodec().getName());
builder.append(" --audio-encoder=").append(encoder.getInfo().getName());
}
return buildEncoderListMessage("audio", AudioCodec.values());
}
@TargetApi(AndroidVersions.API_29_ANDROID_10)
private static String getHwCodecType(MediaCodecInfo info) {
if (info.isSoftwareOnly()) {
return "sw";
}
return builder.toString();
if (info.isHardwareAccelerated()) {
return "hw";
}
return "hybrid";
}
public static String buildDisplayListMessage() {
@ -154,4 +188,57 @@ public final class LogUtils {
}
return set;
}
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)
// 2. name
// 3. package name
// Comparator.comparing() was introduced in API 24, so it cannot be used here to simplify the code
Collections.sort(apps, (thisApp, otherApp) -> {
// System apps first
int cmp = -Boolean.compare(thisApp.isSystem(), otherApp.isSystem());
if (cmp != 0) {
return cmp;
}
cmp = Objects.compare(thisApp.getName(), otherApp.getName(), String::compareTo);
if (cmp != 0) {
return cmp;
}
return Objects.compare(thisApp.getPackageName(), otherApp.getPackageName(), String::compareTo);
});
final int column = 30;
for (DeviceApp app : apps) {
String name = app.getName();
int padding = column - name.length();
builder.append("\n ");
if (app.isSystem()) {
builder.append("* ");
} else {
builder.append("- ");
}
builder.append(name);
if (padding > 0) {
builder.append(String.format("%" + padding + "s", " "));
} else {
builder.append("\n ").append(String.format("%" + column + "s", " "));
}
builder.append(" ").append(app.getPackageName());
}
return builder.toString();
}
}

View File

@ -1,9 +1,12 @@
package com.genymobile.scrcpy.video;
import com.genymobile.scrcpy.AndroidVersions;
import com.genymobile.scrcpy.Options;
import com.genymobile.scrcpy.device.ConfigurationException;
import com.genymobile.scrcpy.device.Size;
import com.genymobile.scrcpy.util.HandlerExecutor;
import com.genymobile.scrcpy.util.Ln;
import com.genymobile.scrcpy.util.LogUtils;
import com.genymobile.scrcpy.wrappers.ServiceManager;
import android.annotation.SuppressLint;
@ -56,19 +59,18 @@ public class CameraCapture extends SurfaceCapture {
private final AtomicBoolean disconnected = new AtomicBoolean();
public CameraCapture(String explicitCameraId, CameraFacing cameraFacing, Size explicitSize, int maxSize, CameraAspectRatio aspectRatio, int fps,
boolean highSpeed) {
this.explicitCameraId = explicitCameraId;
this.cameraFacing = cameraFacing;
this.explicitSize = explicitSize;
this.maxSize = maxSize;
this.aspectRatio = aspectRatio;
this.fps = fps;
this.highSpeed = highSpeed;
public CameraCapture(Options options) {
this.explicitCameraId = options.getCameraId();
this.cameraFacing = options.getCameraFacing();
this.explicitSize = options.getCameraSize();
this.maxSize = options.getMaxSize();
this.aspectRatio = options.getCameraAspectRatio();
this.fps = options.getCameraFps();
this.highSpeed = options.getCameraHighSpeed();
}
@Override
public void init() throws IOException {
protected void init() throws ConfigurationException, IOException {
cameraThread = new HandlerThread("camera");
cameraThread.start();
cameraHandler = new Handler(cameraThread.getLooper());
@ -77,12 +79,7 @@ public class CameraCapture extends SurfaceCapture {
try {
cameraId = selectCamera(explicitCameraId, cameraFacing);
if (cameraId == null) {
throw new IOException("No matching camera found");
}
size = selectSize(cameraId, explicitSize, maxSize, aspectRatio, highSpeed);
if (size == null) {
throw new IOException("Could not select camera size");
throw new ConfigurationException("No matching camera found");
}
Ln.i("Using camera '" + cameraId + "'");
@ -92,14 +89,30 @@ public class CameraCapture extends SurfaceCapture {
}
}
private static String selectCamera(String explicitCameraId, CameraFacing cameraFacing) throws CameraAccessException {
if (explicitCameraId != null) {
return explicitCameraId;
@Override
public void prepare() throws IOException {
try {
size = selectSize(cameraId, explicitSize, maxSize, aspectRatio, highSpeed);
if (size == null) {
throw new IOException("Could not select camera size");
}
} catch (CameraAccessException e) {
throw new IOException(e);
}
}
private static String selectCamera(String explicitCameraId, CameraFacing cameraFacing) throws CameraAccessException, ConfigurationException {
CameraManager cameraManager = ServiceManager.getCameraManager();
String[] cameraIds = cameraManager.getCameraIdList();
if (explicitCameraId != null) {
if (!Arrays.asList(cameraIds).contains(explicitCameraId)) {
Ln.e("Camera with id " + explicitCameraId + " not found\n" + LogUtils.buildCameraListMessage(false));
throw new ConfigurationException("Camera id not found");
}
return explicitCameraId;
}
if (cameraFacing == null) {
// Use the first one
return cameraIds.length > 0 ? cameraIds[0] : null;
@ -232,13 +245,7 @@ public class CameraCapture extends SurfaceCapture {
}
this.maxSize = maxSize;
try {
size = selectSize(cameraId, null, maxSize, aspectRatio, highSpeed);
return size != null;
} catch (CameraAccessException e) {
Ln.w("Could not select camera size", e);
return false;
}
return true;
}
@SuppressLint("MissingPermission")
@ -256,7 +263,7 @@ public class CameraCapture extends SurfaceCapture {
public void onDisconnected(CameraDevice camera) {
Ln.w("Camera disconnected");
disconnected.set(true);
requestReset();
invalidate();
}
@Override
@ -355,4 +362,9 @@ public class CameraCapture extends SurfaceCapture {
public boolean isClosed() {
return disconnected.get();
}
@Override
public void requestInvalidate() {
// do nothing (the user could not request a reset anyway for now, since there is no controller for camera mirroring)
}
}

View File

@ -0,0 +1,33 @@
package com.genymobile.scrcpy.video;
import android.media.MediaCodec;
import java.util.concurrent.atomic.AtomicBoolean;
public class CaptureReset implements SurfaceCapture.CaptureListener {
private final AtomicBoolean reset = new AtomicBoolean();
// Current instance of MediaCodec to "interrupt" on reset
private MediaCodec runningMediaCodec;
public boolean consumeReset() {
return reset.getAndSet(false);
}
public synchronized void reset() {
reset.set(true);
if (runningMediaCodec != null) {
runningMediaCodec.signalEndOfInputStream();
}
}
public synchronized void setRunningMediaCodec(MediaCodec runningMediaCodec) {
this.runningMediaCodec = runningMediaCodec;
}
@Override
public void onInvalidated() {
reset();
}
}

View File

@ -0,0 +1,159 @@
package com.genymobile.scrcpy.video;
import com.genymobile.scrcpy.AndroidVersions;
import com.genymobile.scrcpy.Options;
import com.genymobile.scrcpy.control.PositionMapper;
import com.genymobile.scrcpy.device.DisplayInfo;
import com.genymobile.scrcpy.device.NewDisplay;
import com.genymobile.scrcpy.device.Size;
import com.genymobile.scrcpy.util.Ln;
import com.genymobile.scrcpy.wrappers.ServiceManager;
import android.graphics.Rect;
import android.hardware.display.DisplayManager;
import android.hardware.display.VirtualDisplay;
import android.os.Build;
import android.view.Surface;
import java.io.IOException;
public class NewDisplayCapture extends SurfaceCapture {
// Internal fields copied from android.hardware.display.DisplayManager
private static final int VIRTUAL_DISPLAY_FLAG_PUBLIC = DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC;
private static final int VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY = DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY;
private static final int VIRTUAL_DISPLAY_FLAG_SUPPORTS_TOUCH = 1 << 6;
private static final int VIRTUAL_DISPLAY_FLAG_ROTATES_WITH_CONTENT = 1 << 7;
private static final int VIRTUAL_DISPLAY_FLAG_DESTROY_CONTENT_ON_REMOVAL = 1 << 8;
private static final int VIRTUAL_DISPLAY_FLAG_SHOULD_SHOW_SYSTEM_DECORATIONS = 1 << 9;
private static final int VIRTUAL_DISPLAY_FLAG_TRUSTED = 1 << 10;
private static final int VIRTUAL_DISPLAY_FLAG_OWN_DISPLAY_GROUP = 1 << 11;
private static final int VIRTUAL_DISPLAY_FLAG_ALWAYS_UNLOCKED = 1 << 12;
private static final int VIRTUAL_DISPLAY_FLAG_TOUCH_FEEDBACK_DISABLED = 1 << 13;
private static final int VIRTUAL_DISPLAY_FLAG_OWN_FOCUS = 1 << 14;
private static final int VIRTUAL_DISPLAY_FLAG_DEVICE_DISPLAY_GROUP = 1 << 15;
private final VirtualDisplayListener vdListener;
private final NewDisplay newDisplay;
private Size mainDisplaySize;
private int mainDisplayDpi;
private int maxSize; // only used if newDisplay.getSize() != null
private VirtualDisplay virtualDisplay;
private Size size;
private int dpi;
public NewDisplayCapture(VirtualDisplayListener vdListener, Options options) {
this.vdListener = vdListener;
this.newDisplay = options.getNewDisplay();
assert newDisplay != null;
this.maxSize = options.getMaxSize();
}
@Override
protected void init() {
size = newDisplay.getSize();
dpi = newDisplay.getDpi();
if (size == null || dpi == 0) {
DisplayInfo displayInfo = ServiceManager.getDisplayManager().getDisplayInfo(0);
if (displayInfo != null) {
mainDisplaySize = displayInfo.getSize();
mainDisplayDpi = displayInfo.getDpi();
} else {
Ln.w("Main display not found, fallback to 1920x1080 240dpi");
mainDisplaySize = new Size(1920, 1080);
mainDisplayDpi = 240;
}
}
}
@Override
public void prepare() {
if (!newDisplay.hasExplicitSize()) {
size = ScreenInfo.computeVideoSize(mainDisplaySize.getWidth(), mainDisplaySize.getHeight(), maxSize);
}
if (!newDisplay.hasExplicitDpi()) {
dpi = scaleDpi(mainDisplaySize, mainDisplayDpi, size);
}
}
public void startNew(Surface surface) {
int virtualDisplayId;
try {
int flags = VIRTUAL_DISPLAY_FLAG_PUBLIC
| VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY
| VIRTUAL_DISPLAY_FLAG_SUPPORTS_TOUCH
| VIRTUAL_DISPLAY_FLAG_ROTATES_WITH_CONTENT
| VIRTUAL_DISPLAY_FLAG_DESTROY_CONTENT_ON_REMOVAL
| VIRTUAL_DISPLAY_FLAG_SHOULD_SHOW_SYSTEM_DECORATIONS;
if (Build.VERSION.SDK_INT >= AndroidVersions.API_33_ANDROID_13) {
flags |= VIRTUAL_DISPLAY_FLAG_TRUSTED
| VIRTUAL_DISPLAY_FLAG_OWN_DISPLAY_GROUP
| VIRTUAL_DISPLAY_FLAG_ALWAYS_UNLOCKED
| VIRTUAL_DISPLAY_FLAG_TOUCH_FEEDBACK_DISABLED;
if (Build.VERSION.SDK_INT >= AndroidVersions.API_34_ANDROID_14) {
flags |= VIRTUAL_DISPLAY_FLAG_OWN_FOCUS
| VIRTUAL_DISPLAY_FLAG_DEVICE_DISPLAY_GROUP;
}
}
virtualDisplay = ServiceManager.getDisplayManager()
.createNewVirtualDisplay("scrcpy", size.getWidth(), size.getHeight(), dpi, surface, flags);
virtualDisplayId = virtualDisplay.getDisplay().getDisplayId();
Ln.i("New display: " + size.getWidth() + "x" + size.getHeight() + "/" + dpi + " (id=" + virtualDisplayId + ")");
} catch (Exception e) {
Ln.e("Could not create display", e);
throw new AssertionError("Could not create display");
}
if (vdListener != null) {
Rect contentRect = new Rect(0, 0, size.getWidth(), size.getHeight());
PositionMapper positionMapper = new PositionMapper(size, contentRect, 0);
vdListener.onNewVirtualDisplay(virtualDisplayId, positionMapper);
}
}
@Override
public void start(Surface surface) throws IOException {
if (virtualDisplay == null) {
startNew(surface);
} else {
virtualDisplay.setSurface(surface);
}
}
@Override
public void release() {
if (virtualDisplay != null) {
virtualDisplay.release();
virtualDisplay = null;
}
}
@Override
public synchronized Size getSize() {
return size;
}
@Override
public synchronized boolean setMaxSize(int newMaxSize) {
if (newDisplay.hasExplicitSize()) {
// Cannot retry with a different size if the display size was explicitly provided
return false;
}
maxSize = newMaxSize;
return true;
}
private static int scaleDpi(Size initialSize, int initialDpi, Size size) {
int den = initialSize.getMax();
int num = size.getMax();
return initialDpi * num / den;
}
@Override
public void requestInvalidate() {
invalidate();
}
}

View File

@ -1,44 +1,137 @@
package com.genymobile.scrcpy.video;
import com.genymobile.scrcpy.AndroidVersions;
import com.genymobile.scrcpy.Options;
import com.genymobile.scrcpy.control.PositionMapper;
import com.genymobile.scrcpy.device.ConfigurationException;
import com.genymobile.scrcpy.device.Device;
import com.genymobile.scrcpy.device.DisplayInfo;
import com.genymobile.scrcpy.device.Size;
import com.genymobile.scrcpy.util.Ln;
import com.genymobile.scrcpy.util.LogUtils;
import com.genymobile.scrcpy.wrappers.DisplayManager;
import com.genymobile.scrcpy.wrappers.ServiceManager;
import com.genymobile.scrcpy.wrappers.SurfaceControl;
import android.graphics.Rect;
import android.hardware.display.VirtualDisplay;
import android.os.Build;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.IBinder;
import android.view.IDisplayFoldListener;
import android.view.IRotationWatcher;
import android.view.Surface;
public class ScreenCapture extends SurfaceCapture implements Device.RotationListener, Device.FoldListener {
public class ScreenCapture extends SurfaceCapture {
private final VirtualDisplayListener vdListener;
private final int displayId;
private int maxSize;
private final Rect crop;
private final int lockVideoOrientation;
private DisplayInfo displayInfo;
private ScreenInfo screenInfo;
// Source display size (before resizing/crop) for the current session
private Size sessionDisplaySize;
private final Device device;
private IBinder display;
private VirtualDisplay virtualDisplay;
public ScreenCapture(Device device) {
this.device = device;
private DisplayManager.DisplayListenerHandle displayListenerHandle;
private HandlerThread handlerThread;
// On Android 14, the DisplayListener may be broken (it never sends events). This is fixed in recent Android 14 upgrades, but we can't really
// detect it directly, so register a RotationWatcher and a DisplayFoldListener as a fallback, until we receive the first event from
// DisplayListener (which proves that it works).
private boolean displayListenerWorks; // only accessed from the display listener thread
private IRotationWatcher rotationWatcher;
private IDisplayFoldListener displayFoldListener;
public ScreenCapture(VirtualDisplayListener vdListener, Options options) {
this.vdListener = vdListener;
this.displayId = options.getDisplayId();
assert displayId != Device.DISPLAY_ID_NONE;
this.maxSize = options.getMaxSize();
this.crop = options.getCrop();
this.lockVideoOrientation = options.getLockVideoOrientation();
}
@Override
public void init() {
device.setRotationListener(this);
device.setFoldListener(this);
if (Build.VERSION.SDK_INT == AndroidVersions.API_34_ANDROID_14) {
registerDisplayListenerFallbacks();
}
handlerThread = new HandlerThread("DisplayListener");
handlerThread.start();
Handler handler = new Handler(handlerThread.getLooper());
displayListenerHandle = ServiceManager.getDisplayManager().registerDisplayListener(displayId -> {
if (Ln.isEnabled(Ln.Level.VERBOSE)) {
Ln.v("ScreenCapture: onDisplayChanged(" + displayId + ")");
}
if (Build.VERSION.SDK_INT == AndroidVersions.API_34_ANDROID_14) {
if (!displayListenerWorks) {
// On the first display listener event, we know it works, we can unregister the fallbacks
displayListenerWorks = true;
unregisterDisplayListenerFallbacks();
}
}
if (this.displayId == displayId) {
DisplayInfo di = ServiceManager.getDisplayManager().getDisplayInfo(displayId);
if (di == null) {
Ln.w("DisplayInfo for " + displayId + " cannot be retrieved");
// We can't compare with the current size, so reset unconditionally
if (Ln.isEnabled(Ln.Level.VERBOSE)) {
Ln.v("ScreenCapture: requestReset(): " + getSessionDisplaySize() + " -> (unknown)");
}
setSessionDisplaySize(null);
invalidate();
} else {
Size size = di.getSize();
// The field is hidden on purpose, to read it with synchronization
@SuppressWarnings("checkstyle:HiddenField")
Size sessionDisplaySize = getSessionDisplaySize(); // synchronized
// .equals() also works if sessionDisplaySize == null
if (!size.equals(sessionDisplaySize)) {
// Reset only if the size is different
if (Ln.isEnabled(Ln.Level.VERBOSE)) {
Ln.v("ScreenCapture: requestReset(): " + sessionDisplaySize + " -> " + size);
}
// Set the new size immediately, so that a future onDisplayChanged() event called before the asynchronous prepare()
// considers that the current size is the requested size (to avoid a duplicate requestReset())
setSessionDisplaySize(size);
invalidate();
} else if (Ln.isEnabled(Ln.Level.VERBOSE)) {
Ln.v("ScreenCapture: Size not changed (" + size + "): do not requestReset()");
}
}
}
}, handler);
}
@Override
public void prepare() throws ConfigurationException {
displayInfo = ServiceManager.getDisplayManager().getDisplayInfo(displayId);
if (displayInfo == null) {
Ln.e("Display " + displayId + " not found\n" + LogUtils.buildDisplayListMessage());
throw new ConfigurationException("Unknown display id: " + displayId);
}
if ((displayInfo.getFlags() & DisplayInfo.FLAG_SUPPORTS_PROTECTED_BUFFERS) == 0) {
Ln.w("Display doesn't have FLAG_SUPPORTS_PROTECTED_BUFFERS flag, mirroring can be restricted");
}
setSessionDisplaySize(displayInfo.getSize());
screenInfo = ScreenInfo.computeScreenInfo(displayInfo.getRotation(), displayInfo.getSize(), crop, maxSize, lockVideoOrientation);
}
@Override
public void start(Surface surface) {
ScreenInfo screenInfo = device.getScreenInfo();
Rect contentRect = screenInfo.getContentRect();
// does not include the locked video orientation
Rect unlockedVideoRect = screenInfo.getUnlockedVideoSize().toRect();
int videoRotation = screenInfo.getVideoRotation();
int layerStack = device.getLayerStack();
if (display != null) {
SurfaceControl.destroyDisplay(display);
display = null;
@ -48,15 +141,31 @@ public class ScreenCapture extends SurfaceCapture implements Device.RotationList
virtualDisplay = null;
}
int virtualDisplayId;
PositionMapper positionMapper;
try {
Rect videoRect = screenInfo.getVideoSize().toRect();
Size videoSize = screenInfo.getVideoSize();
virtualDisplay = ServiceManager.getDisplayManager()
.createVirtualDisplay("scrcpy", videoRect.width(), videoRect.height(), device.getDisplayId(), surface);
.createVirtualDisplay("scrcpy", videoSize.getWidth(), videoSize.getHeight(), displayId, surface);
virtualDisplayId = virtualDisplay.getDisplay().getDisplayId();
Rect contentRect = new Rect(0, 0, videoSize.getWidth(), videoSize.getHeight());
// The position are relative to the virtual display, not the original display
positionMapper = new PositionMapper(videoSize, contentRect, 0);
Ln.d("Display: using DisplayManager API");
} catch (Exception displayManagerException) {
try {
display = createDisplay();
Rect contentRect = screenInfo.getContentRect();
// does not include the locked video orientation
Rect unlockedVideoRect = screenInfo.getUnlockedVideoSize().toRect();
int videoRotation = screenInfo.getVideoRotation();
int layerStack = displayInfo.getLayerStack();
setDisplaySurface(display, surface, videoRotation, contentRect, unlockedVideoRect, layerStack);
virtualDisplayId = displayId;
positionMapper = PositionMapper.from(screenInfo);
Ln.d("Display: using SurfaceControl API");
} catch (Exception surfaceControlException) {
Ln.e("Could not create display using DisplayManager", displayManagerException);
@ -64,12 +173,27 @@ public class ScreenCapture extends SurfaceCapture implements Device.RotationList
throw new AssertionError("Could not create display");
}
}
if (vdListener != null) {
vdListener.onNewVirtualDisplay(virtualDisplayId, positionMapper);
}
}
@Override
public void release() {
device.setRotationListener(null);
device.setFoldListener(null);
if (Build.VERSION.SDK_INT == AndroidVersions.API_34_ANDROID_14) {
unregisterDisplayListenerFallbacks();
}
handlerThread.quitSafely();
handlerThread = null;
// displayListenerHandle may be null if registration failed
if (displayListenerHandle != null) {
ServiceManager.getDisplayManager().unregisterDisplayListener(displayListenerHandle);
displayListenerHandle = null;
}
if (display != null) {
SurfaceControl.destroyDisplay(display);
display = null;
@ -82,25 +206,15 @@ public class ScreenCapture extends SurfaceCapture implements Device.RotationList
@Override
public Size getSize() {
return device.getScreenInfo().getVideoSize();
return screenInfo.getVideoSize();
}
@Override
public boolean setMaxSize(int maxSize) {
device.setMaxSize(maxSize);
public boolean setMaxSize(int newMaxSize) {
maxSize = newMaxSize;
return true;
}
@Override
public void onFoldChanged(int displayId, boolean folded) {
requestReset();
}
@Override
public void onRotationChanged(int rotation) {
requestReset();
}
private static IBinder createDisplay() throws Exception {
// Since Android 12 (preview), secure displays could not be created with shell permissions anymore.
// On Android 12 preview, SDK_INT is still R (not S), but CODENAME is "S".
@ -119,4 +233,70 @@ public class ScreenCapture extends SurfaceCapture implements Device.RotationList
SurfaceControl.closeTransaction();
}
}
private synchronized Size getSessionDisplaySize() {
return sessionDisplaySize;
}
private synchronized void setSessionDisplaySize(Size sessionDisplaySize) {
this.sessionDisplaySize = sessionDisplaySize;
}
private void registerDisplayListenerFallbacks() {
rotationWatcher = new IRotationWatcher.Stub() {
@Override
public void onRotationChanged(int rotation) {
if (Ln.isEnabled(Ln.Level.VERBOSE)) {
Ln.v("ScreenCapture: onRotationChanged(" + rotation + ")");
}
invalidate();
}
};
ServiceManager.getWindowManager().registerRotationWatcher(rotationWatcher, displayId);
// Build.VERSION.SDK_INT >= AndroidVersions.API_29_ANDROID_10 (but implied by == API_34_ANDROID 14)
displayFoldListener = new IDisplayFoldListener.Stub() {
private boolean first = true;
@Override
public void onDisplayFoldChanged(int displayId, boolean folded) {
if (first) {
// An event is posted on registration to signal the initial state. Ignore it to avoid restarting encoding.
first = false;
return;
}
if (Ln.isEnabled(Ln.Level.VERBOSE)) {
Ln.v("ScreenCapture: onDisplayFoldChanged(" + displayId + ", " + folded + ")");
}
if (ScreenCapture.this.displayId != displayId) {
// Ignore events related to other display ids
return;
}
invalidate();
}
};
ServiceManager.getWindowManager().registerDisplayFoldListener(displayFoldListener);
}
private void unregisterDisplayListenerFallbacks() {
synchronized (this) {
if (rotationWatcher != null) {
ServiceManager.getWindowManager().unregisterRotationWatcher(rotationWatcher);
rotationWatcher = null;
}
if (displayFoldListener != null) {
// Build.VERSION.SDK_INT >= AndroidVersions.API_29_ANDROID_10 (but implied by == API_34_ANDROID 14)
ServiceManager.getWindowManager().unregisterDisplayFoldListener(displayFoldListener);
displayFoldListener = null;
}
}
}
@Override
public void requestInvalidate() {
invalidate();
}
}

View File

@ -63,30 +63,8 @@ public final class ScreenInfo {
return unlockedVideoSize.rotate();
}
public int getDeviceRotation() {
return deviceRotation;
}
public ScreenInfo withDeviceRotation(int newDeviceRotation) {
if (newDeviceRotation == deviceRotation) {
return this;
}
// true if changed between portrait and landscape
boolean orientationChanged = (deviceRotation + newDeviceRotation) % 2 != 0;
Rect newContentRect;
Size newUnlockedVideoSize;
if (orientationChanged) {
newContentRect = flipRect(contentRect);
newUnlockedVideoSize = unlockedVideoSize.rotate();
} else {
newContentRect = contentRect;
newUnlockedVideoSize = unlockedVideoSize;
}
return new ScreenInfo(newContentRect, newUnlockedVideoSize, newDeviceRotation, lockedVideoOrientation);
}
public static ScreenInfo computeScreenInfo(int rotation, Size deviceSize, Rect crop, int maxSize, int lockedVideoOrientation) {
if (lockedVideoOrientation == Device.LOCK_VIDEO_ORIENTATION_INITIAL) {
if (lockedVideoOrientation == Device.LOCK_VIDEO_ORIENTATION_INITIAL || lockedVideoOrientation == Device.LOCK_VIDEO_ORIENTATION_INITIAL_AUTO) {
// The user requested to lock the video orientation to the current orientation
lockedVideoOrientation = rotation;
}
@ -112,7 +90,7 @@ public final class ScreenInfo {
return rect.width() + ":" + rect.height() + ":" + rect.left + ":" + rect.top;
}
private static Size computeVideoSize(int w, int h, int maxSize) {
public static Size computeVideoSize(int w, int h, int maxSize) {
// Compute the video size and the padding of the content inside this video.
// Principle:
// - scale down the great side of the screen to maxSize (if necessary);

View File

@ -1,46 +1,55 @@
package com.genymobile.scrcpy.video;
import com.genymobile.scrcpy.device.ConfigurationException;
import com.genymobile.scrcpy.device.Size;
import android.view.Surface;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* A video source which can be rendered on a Surface for encoding.
*/
public abstract class SurfaceCapture {
private final AtomicBoolean resetCapture = new AtomicBoolean();
public interface CaptureListener {
void onInvalidated();
}
private CaptureListener listener;
/**
* Request the encoding session to be restarted, for example if the capture implementation detects that the video source size has changed (on
* device rotation for example).
* Notify the listener that the capture has been invalidated (for example, because its size changed).
*/
protected void requestReset() {
resetCapture.set(true);
protected void invalidate() {
listener.onInvalidated();
}
/**
* Consume the reset request (intended to be called by the encoder).
*
* @return {@code true} if a reset request was pending, {@code false} otherwise.
* Called once before the first capture starts.
*/
public boolean consumeReset() {
return resetCapture.getAndSet(false);
public final void init(CaptureListener listener) throws ConfigurationException, IOException {
this.listener = listener;
init();
}
/**
* Called once before the capture starts.
* Called once before the first capture starts.
*/
public abstract void init() throws IOException;
protected abstract void init() throws ConfigurationException, IOException;
/**
* Called after the capture ends (if and only if {@link #init()} has been called).
* Called after the last capture ends (if and only if {@link #init()} has been called).
*/
public abstract void release();
/**
* Called once before each capture starts, before {@link #getSize()}.
*/
public void prepare() throws ConfigurationException, IOException {
// empty by default
}
/**
* Start the capture to the target surface.
*
@ -70,4 +79,11 @@ public abstract class SurfaceCapture {
public boolean isClosed() {
return false;
}
/**
* Manually request to invalidate (typically a user request).
* <p>
* The capture implementation is free to ignore the request and do nothing.
*/
public abstract void requestInvalidate();
}

View File

@ -2,6 +2,7 @@ package com.genymobile.scrcpy.video;
import com.genymobile.scrcpy.AndroidVersions;
import com.genymobile.scrcpy.AsyncProcessor;
import com.genymobile.scrcpy.Options;
import com.genymobile.scrcpy.device.ConfigurationException;
import com.genymobile.scrcpy.device.Size;
import com.genymobile.scrcpy.device.Streamer;
@ -49,15 +50,16 @@ public class SurfaceEncoder implements AsyncProcessor {
private Thread thread;
private final AtomicBoolean stopped = new AtomicBoolean();
public SurfaceEncoder(SurfaceCapture capture, Streamer streamer, int videoBitRate, float maxFps, List<CodecOption> codecOptions,
String encoderName, boolean downsizeOnError) {
private final CaptureReset reset = new CaptureReset();
public SurfaceEncoder(SurfaceCapture capture, Streamer streamer, Options options) {
this.capture = capture;
this.streamer = streamer;
this.videoBitRate = videoBitRate;
this.maxFps = maxFps;
this.codecOptions = codecOptions;
this.encoderName = encoderName;
this.downsizeOnError = downsizeOnError;
this.videoBitRate = options.getVideoBitRate();
this.maxFps = options.getMaxFps();
this.codecOptions = options.getVideoCodecOptions();
this.encoderName = options.getVideoEncoder();
this.downsizeOnError = options.getDownsizeOnError();
}
private void streamCapture() throws IOException, ConfigurationException {
@ -65,15 +67,21 @@ public class SurfaceEncoder implements AsyncProcessor {
MediaCodec mediaCodec = createMediaCodec(codec, encoderName);
MediaFormat format = createFormat(codec.getMimeType(), videoBitRate, maxFps, codecOptions);
capture.init();
capture.init(reset);
try {
streamer.writeVideoHeader(capture.getSize());
boolean alive;
boolean headerWritten = false;
do {
reset.consumeReset(); // If a capture reset was requested, it is implicitly fulfilled
capture.prepare();
Size size = capture.getSize();
if (!headerWritten) {
streamer.writeVideoHeader(size);
headerWritten = true;
}
format.setInteger(MediaFormat.KEY_WIDTH, size.getWidth());
format.setInteger(MediaFormat.KEY_HEIGHT, size.getHeight());
@ -86,7 +94,21 @@ public class SurfaceEncoder implements AsyncProcessor {
mediaCodec.start();
alive = encode(mediaCodec, streamer);
// Set the MediaCodec instance to "interrupt" (by signaling an EOS) on reset
reset.setRunningMediaCodec(mediaCodec);
if (stopped.get()) {
alive = false;
} else {
boolean resetRequested = reset.consumeReset();
if (!resetRequested) {
// If a reset is requested during encode(), it will interrupt the encoding by an EOS
encode(mediaCodec, streamer);
}
// The capture might have been closed internally (for example if the camera is disconnected)
alive = !stopped.get() && !capture.isClosed();
}
// do not call stop() on exception, it would trigger an IllegalStateException
mediaCodec.stop();
} catch (IllegalStateException | IllegalArgumentException e) {
@ -97,6 +119,7 @@ public class SurfaceEncoder implements AsyncProcessor {
Ln.i("Retrying...");
alive = true;
} finally {
reset.setRunningMediaCodec(null);
mediaCodec.reset();
if (surface != null) {
surface.release();
@ -157,25 +180,16 @@ public class SurfaceEncoder implements AsyncProcessor {
return 0;
}
private boolean encode(MediaCodec codec, Streamer streamer) throws IOException {
boolean eof = false;
boolean alive = true;
private void encode(MediaCodec codec, Streamer streamer) throws IOException {
MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
while (!capture.consumeReset() && !eof) {
if (stopped.get()) {
alive = false;
break;
}
boolean eos;
do {
int outputBufferId = codec.dequeueOutputBuffer(bufferInfo, -1);
try {
if (capture.consumeReset()) {
// must restart encoding with new size
break;
}
eof = (bufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0;
if (outputBufferId >= 0) {
eos = (bufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0;
// On EOS, there might be data or not, depending on bufferInfo.size
if (outputBufferId >= 0 && bufferInfo.size > 0) {
ByteBuffer codecBuffer = codec.getOutputBuffer(outputBufferId);
boolean isConfig = (bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0;
@ -192,14 +206,7 @@ public class SurfaceEncoder implements AsyncProcessor {
codec.releaseOutputBuffer(outputBufferId, false);
}
}
}
if (capture.isClosed()) {
// The capture might have been closed internally (for example if the camera is disconnected)
alive = false;
}
return !eof && alive;
} while (!eos);
}
private static MediaCodec createMediaCodec(Codec codec, String encoderName) throws IOException, ConfigurationException {
@ -292,6 +299,7 @@ public class SurfaceEncoder implements AsyncProcessor {
public void stop() {
if (thread != null) {
stopped.set(true);
reset.reset();
}
}

View File

@ -0,0 +1,7 @@
package com.genymobile.scrcpy.video;
import com.genymobile.scrcpy.control.PositionMapper;
public interface VirtualDisplayListener {
void onNewVirtualDisplay(int displayId, PositionMapper positionMapper);
}

View File

@ -6,6 +6,7 @@ import com.genymobile.scrcpy.util.Ln;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.content.IContentProvider;
import android.content.Intent;
import android.os.Binder;
import android.os.Bundle;
@ -64,7 +65,7 @@ public final class ActivityManager {
}
@TargetApi(AndroidVersions.API_29_ANDROID_10)
private ContentProvider getContentProviderExternal(String name, IBinder token) {
public IContentProvider getContentProviderExternal(String name, IBinder token) {
try {
Method method = getGetContentProviderExternalMethod();
Object[] args;
@ -83,11 +84,7 @@ public final class ActivityManager {
// IContentProvider provider = providerHolder.provider;
Field providerField = providerHolder.getClass().getDeclaredField("provider");
providerField.setAccessible(true);
Object provider = providerField.get(providerHolder);
if (provider == null) {
return null;
}
return new ContentProvider(this, provider, name, token);
return (IContentProvider) providerField.get(providerHolder);
} catch (ReflectiveOperationException e) {
Ln.e("Could not invoke method", e);
return null;
@ -104,7 +101,12 @@ public final class ActivityManager {
}
public ContentProvider createSettingsProvider() {
return getContentProviderExternal("settings", new Binder());
IBinder token = new Binder();
IContentProvider provider = getContentProviderExternal("settings", token);
if (provider == null) {
return null;
}
return new ContentProvider(this, provider, "settings", token);
}
private Method getStartActivityAsUserMethod() throws NoSuchMethodException, ClassNotFoundException {
@ -118,8 +120,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 +139,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

@ -1,24 +1,53 @@
package com.genymobile.scrcpy.wrappers;
import com.genymobile.scrcpy.AndroidVersions;
import com.genymobile.scrcpy.FakeContext;
import com.genymobile.scrcpy.device.DisplayInfo;
import com.genymobile.scrcpy.device.Size;
import com.genymobile.scrcpy.util.Command;
import com.genymobile.scrcpy.util.Ln;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.content.Context;
import android.hardware.display.VirtualDisplay;
import android.os.Handler;
import android.view.Display;
import android.view.Surface;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@SuppressLint("PrivateApi,DiscouragedPrivateApi")
public final class DisplayManager {
// android.hardware.display.DisplayManager.EVENT_FLAG_DISPLAY_CHANGED
public static final long EVENT_FLAG_DISPLAY_CHANGED = 1L << 2;
public interface DisplayListener {
/**
* Called whenever the properties of a logical {@link android.view.Display},
* such as size and density, have changed.
*
* @param displayId The id of the logical display that changed.
*/
void onDisplayChanged(int displayId);
}
public static final class DisplayListenerHandle {
private final Object displayListenerProxy;
private DisplayListenerHandle(Object displayListenerProxy) {
this.displayListenerProxy = displayListenerProxy;
}
}
private final Object manager; // instance of hidden class android.hardware.display.DisplayManagerGlobal
private Method createVirtualDisplayMethod;
private Method requestDisplayPowerMethod;
static DisplayManager create() {
try {
@ -39,7 +68,7 @@ public final class DisplayManager {
public static DisplayInfo parseDisplayInfo(String dumpsysDisplayOutput, int displayId) {
Pattern regex = Pattern.compile(
"^ mOverrideDisplayInfo=DisplayInfo\\{\".*?, displayId " + displayId + ".*?(, FLAG_.*)?, real ([0-9]+) x ([0-9]+).*?, "
+ "rotation ([0-9]+).*?, layerStack ([0-9]+)",
+ "rotation ([0-9]+).*?, density ([0-9]+).*?, layerStack ([0-9]+)",
Pattern.MULTILINE);
Matcher m = regex.matcher(dumpsysDisplayOutput);
if (!m.find()) {
@ -49,9 +78,10 @@ public final class DisplayManager {
int width = Integer.parseInt(m.group(2));
int height = Integer.parseInt(m.group(3));
int rotation = Integer.parseInt(m.group(4));
int layerStack = Integer.parseInt(m.group(5));
int density = Integer.parseInt(m.group(5));
int layerStack = Integer.parseInt(m.group(6));
return new DisplayInfo(displayId, new Size(width, height), rotation, layerStack, flags);
return new DisplayInfo(displayId, new Size(width, height), rotation, layerStack, flags, density);
}
private static DisplayInfo getDisplayInfoFromDumpsysDisplay(int displayId) {
@ -98,7 +128,8 @@ public final class DisplayManager {
int rotation = cls.getDeclaredField("rotation").getInt(displayInfo);
int layerStack = cls.getDeclaredField("layerStack").getInt(displayInfo);
int flags = cls.getDeclaredField("flags").getInt(displayInfo);
return new DisplayInfo(displayId, new Size(width, height), rotation, layerStack, flags);
int dpi = cls.getDeclaredField("logicalDensityDpi").getInt(displayInfo);
return new DisplayInfo(displayId, new Size(width, height), rotation, layerStack, flags, dpi);
} catch (ReflectiveOperationException e) {
throw new AssertionError(e);
}
@ -124,4 +155,76 @@ public final class DisplayManager {
Method method = getCreateVirtualDisplayMethod();
return (VirtualDisplay) method.invoke(null, name, width, height, displayIdToMirror, surface);
}
public VirtualDisplay createNewVirtualDisplay(String name, int width, int height, int dpi, Surface surface, int flags) throws Exception {
Constructor<android.hardware.display.DisplayManager> ctor = android.hardware.display.DisplayManager.class.getDeclaredConstructor(
Context.class);
ctor.setAccessible(true);
android.hardware.display.DisplayManager dm = ctor.newInstance(FakeContext.get());
return dm.createVirtualDisplay(name, width, height, dpi, surface, flags);
}
private Method getRequestDisplayPowerMethod() throws NoSuchMethodException {
if (requestDisplayPowerMethod == null) {
requestDisplayPowerMethod = manager.getClass().getMethod("requestDisplayPower", int.class, boolean.class);
}
return requestDisplayPowerMethod;
}
@TargetApi(AndroidVersions.API_35_ANDROID_15)
public boolean requestDisplayPower(int displayId, boolean on) {
try {
Method method = getRequestDisplayPowerMethod();
return (boolean) method.invoke(manager, displayId, on);
} catch (ReflectiveOperationException e) {
Ln.e("Could not invoke method", e);
return false;
}
}
public DisplayListenerHandle registerDisplayListener(DisplayListener listener, Handler handler) {
try {
Class<?> displayListenerClass = Class.forName("android.hardware.display.DisplayManager$DisplayListener");
Object displayListenerProxy = Proxy.newProxyInstance(
ClassLoader.getSystemClassLoader(),
new Class[] {displayListenerClass},
(proxy, method, args) -> {
if ("onDisplayChanged".equals(method.getName())) {
listener.onDisplayChanged((int) args[0]);
}
return null;
});
try {
manager.getClass()
.getMethod("registerDisplayListener", displayListenerClass, Handler.class, long.class, String.class)
.invoke(manager, displayListenerProxy, handler, EVENT_FLAG_DISPLAY_CHANGED, FakeContext.PACKAGE_NAME);
} catch (NoSuchMethodException e) {
try {
manager.getClass()
.getMethod("registerDisplayListener", displayListenerClass, Handler.class, long.class)
.invoke(manager, displayListenerProxy, handler, EVENT_FLAG_DISPLAY_CHANGED);
} catch (NoSuchMethodException e2) {
manager.getClass()
.getMethod("registerDisplayListener", displayListenerClass, Handler.class)
.invoke(manager, displayListenerProxy, handler);
}
}
return new DisplayListenerHandle(displayListenerProxy);
} catch (Exception e) {
// Rotation and screen size won't be updated, not a fatal error
Ln.e("Could not register display listener", e);
}
return null;
}
public void unregisterDisplayListener(DisplayListenerHandle listener) {
try {
Class<?> displayListenerClass = Class.forName("android.hardware.display.DisplayManager$DisplayListener");
manager.getClass().getMethod("unregisterDisplayListener", displayListenerClass).invoke(manager, listener.displayListenerProxy);
} catch (Exception e) {
Ln.e("Could not unregister display listener", e);
}
}
}

View File

@ -1,7 +1,9 @@
package com.genymobile.scrcpy.wrappers;
import com.genymobile.scrcpy.AndroidVersions;
import com.genymobile.scrcpy.util.Ln;
import android.os.Build;
import android.os.IInterface;
import java.lang.reflect.Method;
@ -21,14 +23,22 @@ public final class PowerManager {
private Method getIsScreenOnMethod() throws NoSuchMethodException {
if (isScreenOnMethod == null) {
isScreenOnMethod = manager.getClass().getMethod("isInteractive");
if (Build.VERSION.SDK_INT >= AndroidVersions.API_34_ANDROID_14) {
isScreenOnMethod = manager.getClass().getMethod("isDisplayInteractive", int.class);
} else {
isScreenOnMethod = manager.getClass().getMethod("isInteractive");
}
}
return isScreenOnMethod;
}
public boolean isScreenOn() {
public boolean isScreenOn(int displayId) {
try {
Method method = getIsScreenOnMethod();
if (Build.VERSION.SDK_INT >= AndroidVersions.API_34_ANDROID_14) {
return (boolean) method.invoke(manager, displayId);
}
return (boolean) method.invoke(manager);
} catch (ReflectiveOperationException e) {
Ln.e("Could not invoke method", e);

View File

@ -1,5 +1,6 @@
package com.genymobile.scrcpy.wrappers;
import com.genymobile.scrcpy.AndroidVersions;
import com.genymobile.scrcpy.util.Ln;
import android.annotation.TargetApi;
@ -200,13 +201,29 @@ public final class WindowManager {
}
}
@TargetApi(29)
public void unregisterRotationWatcher(IRotationWatcher rotationWatcher) {
try {
manager.getClass().getMethod("removeRotationWatcher", IRotationWatcher.class).invoke(manager, rotationWatcher);
} catch (Exception e) {
Ln.e("Could not unregister rotation watcher", e);
}
}
@TargetApi(AndroidVersions.API_29_ANDROID_10)
public void registerDisplayFoldListener(IDisplayFoldListener foldListener) {
try {
Class<?> cls = manager.getClass();
cls.getMethod("registerDisplayFoldListener", IDisplayFoldListener.class).invoke(manager, foldListener);
manager.getClass().getMethod("registerDisplayFoldListener", IDisplayFoldListener.class).invoke(manager, foldListener);
} catch (Exception e) {
Ln.e("Could not register display fold listener", e);
}
}
@TargetApi(AndroidVersions.API_29_ANDROID_10)
public void unregisterDisplayFoldListener(IDisplayFoldListener foldListener) {
try {
manager.getClass().getMethod("unregisterDisplayFoldListener", IDisplayFoldListener.class).invoke(manager, foldListener);
} catch (Exception e) {
Ln.e("Could not unregister display fold listener", e);
}
}
}

View File

@ -1,7 +1,5 @@
package com.genymobile.scrcpy.control;
import com.genymobile.scrcpy.device.Device;
import android.view.KeyEvent;
import android.view.MotionEvent;
import org.junit.Assert;
@ -285,19 +283,19 @@ public class ControlMessageReaderTest {
}
@Test
public void testParseSetScreenPowerMode() throws IOException {
public void testParseSetDisplayPower() throws IOException {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(bos);
dos.writeByte(ControlMessage.TYPE_SET_SCREEN_POWER_MODE);
dos.writeByte(Device.POWER_MODE_NORMAL);
dos.writeByte(ControlMessage.TYPE_SET_DISPLAY_POWER);
dos.writeBoolean(true);
byte[] packet = bos.toByteArray();
ByteArrayInputStream bis = new ByteArrayInputStream(packet);
ControlMessageReader reader = new ControlMessageReader(bis);
ControlMessage event = reader.read();
Assert.assertEquals(ControlMessage.TYPE_SET_SCREEN_POWER_MODE, event.getType());
Assert.assertEquals(Device.POWER_MODE_NORMAL, event.getAction());
Assert.assertEquals(ControlMessage.TYPE_SET_DISPLAY_POWER, event.getType());
Assert.assertTrue(event.getOn());
Assert.assertEquals(-1, bis.read()); // EOS
}
@ -399,6 +397,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();