diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..b567129a --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +github: [rom1v] +liberapay: rom1v +custom: ["https://paypal.me/rom2v"] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 1c04da7f..576d4666 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -7,17 +7,25 @@ assignees: '' --- - - [ ] I have read the [FAQ](https://github.com/Genymobile/scrcpy/blob/master/FAQ.md). - - [ ] I have searched in existing [issues](https://github.com/Genymobile/scrcpy/issues). +_Please read the [prerequisites] to run scrcpy._ -**Environment** - - OS: [e.g. Debian, Windows, macOS...] - - scrcpy version: [e.g. 1.12.1] - - installation method: [e.g. manual build, apt, snap, brew, Windows release...] - - device model: - - Android version: [e.g. 10] +[prerequisites]: https://github.com/Genymobile/scrcpy#prerequisites + +_Also read the [FAQ] and check if your [issue][issues] already exists._ + +[FAQ]: https://github.com/Genymobile/scrcpy/blob/master/FAQ.md +[issues]: https://github.com/Genymobile/scrcpy/issues + +## Environment + + - **OS:** [e.g. Debian, Windows, macOS...] + - **Scrcpy version:** [e.g. 2.5] + - **Installation method:** [e.g. manual build, apt, snap, brew, Windows release...] + - **Device model:** + - **Android version:** [e.g. 14] + +## Describe the bug -**Describe the bug** A clear and concise description of what the bug is. On errors, please provide the output of the console (and `adb logcat` if relevant). diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 00000000..14dc373a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,8 @@ +--- +name: Question +about: Ask a question about scrcpy +title: '' +labels: '' +assignees: '' + +--- diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..49402a6e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,514 @@ +name: Build + +on: + workflow_dispatch: + inputs: + name: + description: 'Version name (default is ref name)' + +env: + # $VERSION is used by release scripts + VERSION: ${{ github.event.inputs.name || github.ref_name }} + +jobs: + test-scrcpy-server: + runs-on: ubuntu-latest + env: + GRADLE: gradle # use native gradle instead of ./gradlew in scripts + 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: release/test_server.sh + + build-scrcpy-server: + runs-on: ubuntu-latest + env: + GRADLE: gradle # use native gradle instead of ./gradlew in scripts + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup JDK + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + + - name: Build + run: release/build_server.sh + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: scrcpy-server + path: release/work/build-server/server/scrcpy-server + + test-build-scrcpy-server-without-gradle: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup JDK + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + + - name: Build without gradle + run: server/build_without_gradle.sh + + 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 \ + libv4l-dev + + - name: Test + run: release/test_client.sh + + build-linux-x86_64: + runs-on: ubuntu-22.04 + steps: + - name: Check architecture + run: | + arch=$(uname -m) + if [[ "$arch" != x86_64 ]] + then + echo "Unexpected architecture: $arch" >&2 + exit 1 + fi + + - 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 \ + libv4l-dev + + - name: Build + run: release/build_linux.sh x86_64 + + # upload-artifact does not preserve permissions + - name: Tar + run: | + cd release/work/build-linux-x86_64 + mkdir dist-tar + cd dist-tar + tar -C .. -cvf dist.tar.gz dist/ + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: build-linux-x86_64-intermediate + path: release/work/build-linux-x86_64/dist-tar/ + + 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: Build + run: release/build_windows.sh 32 + + # upload-artifact does not preserve permissions + - name: Tar + run: | + cd release/work/build-win32 + mkdir dist-tar + cd dist-tar + tar -C .. -cvf dist.tar.gz dist/ + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: build-win32-intermediate + path: release/work/build-win32/dist-tar/ + + 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: Build + run: release/build_windows.sh 64 + + # upload-artifact does not preserve permissions + - name: Tar + run: | + cd release/work/build-win64 + mkdir dist-tar + cd dist-tar + tar -C .. -cvf dist.tar.gz dist/ + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: build-win64-intermediate + path: release/work/build-win64/dist-tar/ + + build-macos-aarch64: + runs-on: macos-latest + steps: + - name: Check architecture + run: | + arch=$(uname -m) + if [[ "$arch" != arm64 ]] + then + echo "Unexpected architecture: $arch" >&2 + exit 1 + fi + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + brew install meson ninja nasm libiconv zlib automake autoconf \ + libtool + + - name: Build + env: + # the default Xcode (and macOS SDK) version can be found at + # + # + # then the minimal supported deployment target of that macOS SDK can be found at + # + MACOSX_DEPLOYMENT_TARGET: 10.13 + run: release/build_macos.sh aarch64 + + # upload-artifact does not preserve permissions + - name: Tar + run: | + cd release/work/build-macos-aarch64 + mkdir dist-tar + cd dist-tar + tar -C .. -cvf dist.tar.gz dist/ + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: build-macos-aarch64-intermediate + path: release/work/build-macos-aarch64/dist-tar/ + + build-macos-x86_64: + runs-on: macos-13 + steps: + - name: Check architecture + run: | + arch=$(uname -m) + if [[ "$arch" != x86_64 ]] + then + echo "Unexpected architecture: $arch" >&2 + exit 1 + fi + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install dependencies + run: brew install meson ninja nasm libiconv zlib automake + # autoconf and libtool are already installed on macos-13 + + - name: Build + env: + # the default Xcode (and macOS SDK) version can be found at + # + # + # then the minimal supported deployment target of that macOS SDK can be found at + # + MACOSX_DEPLOYMENT_TARGET: 10.13 + run: release/build_macos.sh x86_64 + + # upload-artifact does not preserve permissions + - name: Tar + run: | + cd release/work/build-macos-x86_64 + mkdir dist-tar + cd dist-tar + tar -C .. -cvf dist.tar.gz dist/ + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: build-macos-x86_64-intermediate + path: release/work/build-macos-x86_64/dist-tar/ + + package-linux-x86_64: + needs: + - build-scrcpy-server + - build-linux-x86_64 + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download scrcpy-server + uses: actions/download-artifact@v4 + with: + name: scrcpy-server + path: release/work/build-server/server/ + + - name: Download build-linux-x86_64 + uses: actions/download-artifact@v4 + with: + name: build-linux-x86_64-intermediate + path: release/work/build-linux-x86_64/dist-tar/ + + # upload-artifact does not preserve permissions + - name: Detar + run: | + cd release/work/build-linux-x86_64 + tar xf dist-tar/dist.tar.gz + + - name: Package + run: release/package_client.sh linux-x86_64 tar.gz + + - name: Upload release + uses: actions/upload-artifact@v4 + with: + name: release-linux-x86_64 + path: release/output/ + + package-win32: + needs: + - build-scrcpy-server + - build-win32 + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download scrcpy-server + uses: actions/download-artifact@v4 + with: + name: scrcpy-server + path: release/work/build-server/server/ + + - name: Download build-win32 + uses: actions/download-artifact@v4 + with: + name: build-win32-intermediate + path: release/work/build-win32/dist-tar/ + + # upload-artifact does not preserve permissions + - name: Detar + run: | + cd release/work/build-win32 + tar xf dist-tar/dist.tar.gz + + - name: Package + run: release/package_client.sh win32 zip + + - name: Upload release + uses: actions/upload-artifact@v4 + with: + name: release-win32 + path: release/output/ + + package-win64: + needs: + - build-scrcpy-server + - build-win64 + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download scrcpy-server + uses: actions/download-artifact@v4 + with: + name: scrcpy-server + path: release/work/build-server/server/ + + - name: Download build-win64 + uses: actions/download-artifact@v4 + with: + name: build-win64-intermediate + path: release/work/build-win64/dist-tar/ + + # upload-artifact does not preserve permissions + - name: Detar + run: | + cd release/work/build-win64 + tar xf dist-tar/dist.tar.gz + + - name: Package + run: release/package_client.sh win64 zip + + - name: Upload release + uses: actions/upload-artifact@v4 + with: + name: release-win64 + path: release/output + + package-macos-aarch64: + needs: + - build-scrcpy-server + - build-macos-aarch64 + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download scrcpy-server + uses: actions/download-artifact@v4 + with: + name: scrcpy-server + path: release/work/build-server/server/ + + - name: Download build-macos-aarch64 + uses: actions/download-artifact@v4 + with: + name: build-macos-aarch64-intermediate + path: release/work/build-macos-aarch64/dist-tar/ + + # upload-artifact does not preserve permissions + - name: Detar + run: | + cd release/work/build-macos-aarch64 + tar xf dist-tar/dist.tar.gz + + - name: Package + run: release/package_client.sh macos-aarch64 tar.gz + + - name: Upload release + uses: actions/upload-artifact@v4 + with: + name: release-macos-aarch64 + path: release/output/ + + package-macos-x86_64: + needs: + - build-scrcpy-server + - build-macos-x86_64 + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download scrcpy-server + uses: actions/download-artifact@v4 + with: + name: scrcpy-server + path: release/work/build-server/server/ + + - name: Download build-macos + uses: actions/download-artifact@v4 + with: + name: build-macos-x86_64-intermediate + path: release/work/build-macos-x86_64/dist-tar/ + + # upload-artifact does not preserve permissions + - name: Detar + run: | + cd release/work/build-macos-x86_64 + tar xf dist-tar/dist.tar.gz + + - name: Package + run: release/package_client.sh macos-x86_64 tar.gz + + - name: Upload release + uses: actions/upload-artifact@v4 + with: + name: release-macos-x86_64 + path: release/output/ + + release: + needs: + - build-scrcpy-server + - package-linux-x86_64 + - package-win32 + - package-win64 + - package-macos-aarch64 + - package-macos-x86_64 + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download scrcpy-server + uses: actions/download-artifact@v4 + with: + name: scrcpy-server + path: release/work/build-server/server/ + + - name: Download release-linux-x86_64 + uses: actions/download-artifact@v4 + with: + name: release-linux-x86_64 + path: release/output/ + + - name: Download release-win32 + uses: actions/download-artifact@v4 + with: + name: release-win32 + path: release/output/ + + - name: Download release-win64 + uses: actions/download-artifact@v4 + with: + name: release-win64 + path: release/output/ + + - name: Download release-macos-aarch64 + uses: actions/download-artifact@v4 + with: + name: release-macos-aarch64 + path: release/output/ + + - name: Download release-macos-x86_64 + uses: actions/download-artifact@v4 + with: + name: release-macos-x86_64 + path: release/output/ + + - name: Package server + run: release/package_server.sh + + - name: Generate checksums + run: release/generate_checksums.sh + + - name: Upload release artifact + uses: actions/upload-artifact@v4 + with: + name: scrcpy-release-${{ env.VERSION }} + path: release/output diff --git a/FAQ.md b/FAQ.md index a6eaeefa..24722c74 100644 --- a/FAQ.md +++ b/FAQ.md @@ -133,9 +133,9 @@ Try with another USB cable or plug it into another USB port. See [#281] and [#283]: https://github.com/Genymobile/scrcpy/issues/283 -## HID/OTG issues on Windows +## OTG issues on Windows -On Windows, if `scrcpy --otg` (or `--hid-keyboard`/`--hid-mouse`) results in: +On Windows, if `scrcpy --otg` (or `--keyboard=aoa`/`--mouse=aoa`) results in: > ERROR: Could not find any USB device @@ -166,16 +166,16 @@ Rebooting the device is necessary once this option is set. ### Special characters do not work -The default text injection method is [limited to ASCII characters][text-input]. -A trick allows to also inject some [accented characters][accented-characters], +The default text injection method is limited to ASCII characters. A trick allows +to also inject some [accented characters][accented-characters], but that's all. See [#37]. -It is also possible to simulate a [physical keyboard][hid] (HID). +To avoid the problem, [change the keyboard mode to simulate a physical +keyboard][hid]. -[text-input]: https://github.com/Genymobile/scrcpy/issues?q=is%3Aopen+is%3Aissue+label%3Aunicode [accented-characters]: https://blog.rom1v.com/2018/03/introducing-scrcpy/#handle-accented-characters [#37]: https://github.com/Genymobile/scrcpy/issues/37 -[hid]: doc/hid-otg.md +[hid]: doc/keyboard.md#physical-keyboard-simulation ## Client issues @@ -222,7 +222,7 @@ java.lang.IllegalStateException at android.media.MediaCodec.native_dequeueOutputBuffer(Native Method) ``` -then try with another [encoder](doc/video.md#codec). +then try with another [encoder](doc/video.md#encoder). ## Translations diff --git a/LICENSE b/LICENSE index 55f96811..1196b3da 100644 --- a/LICENSE +++ b/LICENSE @@ -188,7 +188,7 @@ identification within third-party archives. Copyright (C) 2018 Genymobile - Copyright (C) 2018-2023 Romain Vimont + Copyright (C) 2018-2025 Romain Vimont Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index b8ef9df8..d886d23c 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,17 @@ -# scrcpy (v2.2) +**This GitHub repo () is the only official +source for the project. Do not download releases from random websites, even if +their name contains `scrcpy`.** + +# scrcpy (v3.3.1) scrcpy _pronounced "**scr**een **c**o**py**"_ -This application mirrors Android devices (video and audio) connected via -USB or [over TCP/IP](doc/connection.md#tcpip-wireless), and allows to control the -device with the keyboard and the mouse of the computer. It does not require any -_root_ access. It works on _Linux_, _Windows_ and _macOS_. +This application mirrors Android devices (video and audio) connected via USB or +[TCP/IP](doc/connection.md#tcpip-wireless) and allows control using the +computer's keyboard and mouse. It does not require _root_ access or an app +installed on the device. It works on _Linux_, _Windows_, and _macOS_. ![screenshot](assets/screenshot-debian-600.jpg) @@ -27,15 +31,20 @@ 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) - [camera mirroring](doc/camera.md) (Android 12+) - [mirroring as a webcam (V4L2)](doc/v4l2.md) (Linux-only) - - [physical keyboard/mouse simulation (HID)](doc/hid-otg.md) - - [OTG mode](doc/hid-otg.md#otg) + - physical [keyboard][hid-keyboard] and [mouse][hid-mouse] simulation (HID) + - [gamepad](doc/gamepad.md) support + - [OTG mode](doc/otg.md) - and more… +[hid-keyboard]: doc/keyboard.md#physical-keyboard-simulation +[hid-mouse]: doc/mouse.md#physical-mouse-simulation + ## Prerequisites The Android device requires at least API 21 (Android 5.0). @@ -46,24 +55,87 @@ Make sure you [enabled USB debugging][enable-adb] on your device(s). [enable-adb]: https://developer.android.com/studio/debug/dev-options#enable -On some devices, you also need to enable [an additional option][control] `USB -debugging (Security Settings)` (this is an item different from `USB debugging`) -to control it using a keyboard and mouse. Rebooting the device is necessary once -this option is set. +On some devices (especially Xiaomi), you might get the following error: + +``` +Injecting input events requires the caller (or the source of the instrumentation, if any) to have the INJECT_EVENTS permission. +``` + +In that case, you need to enable [an additional option][control] `USB debugging +(Security Settings)` (this is an item different from `USB debugging`) to control +it using a keyboard and mouse. Rebooting the device is necessary once this +option is set. [control]: https://github.com/Genymobile/scrcpy/issues/70#issuecomment-373286323 -Note that USB debugging is not required to run scrcpy in [OTG -mode](doc/hid-otg.md#otg). +Note that USB debugging is not required to run scrcpy in [OTG mode](doc/otg.md). ## Get the app - [Linux](doc/linux.md) - - [Windows](doc/windows.md) + - [Windows](doc/windows.md) (read [how to run](doc/windows.md#run)) - [macOS](doc/macos.md) +## Must-know tips + + - [Reducing resolution](doc/video.md#size) may greatly improve performance + (`scrcpy -m1024`) + - [_Right-click_](doc/mouse.md#mouse-bindings) triggers `BACK` + - [_Middle-click_](doc/mouse.md#mouse-bindings) triggers `HOME` + - Alt+f toggles [fullscreen](doc/window.md#fullscreen) + - There are many other [shortcuts](doc/shortcuts.md) + + +## Usage examples + +There are a lot of options, [documented](#user-documentation) in separate pages. +Here are just some common examples. + + - Capture the screen in H.265 (better quality), limit the size to 1920, limit + the frame rate to 60fps, disable audio, and control the device by simulating + a physical keyboard: + + ```bash + scrcpy --video-codec=h265 --max-size=1920 --max-fps=60 --no-audio --keyboard=uhid + 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: + + ```bash + scrcpy --video-source=camera --video-codec=h265 --camera-size=1920x1080 --record=file.mp4 + ``` + + - Capture the device front camera and expose it as a webcam on the computer (on + Linux): + + ```bash + scrcpy --video-source=camera --camera-size=1920x1080 --camera-facing=front --v4l2-sink=/dev/video2 --no-playback + ``` + + - Control the device without mirroring by simulating a physical keyboard and + mouse (USB debugging not required): + + ```bash + scrcpy --otg + ``` + + - Control the device using gamepad controllers plugged into the computer: + + ```bash + scrcpy --gamepad=uhid + scrcpy -G # short version + ``` + ## User documentation The application provides a lot of features and configuration options. They are @@ -73,11 +145,15 @@ documented in the following pages: - [Video](doc/video.md) - [Audio](doc/audio.md) - [Control](doc/control.md) + - [Keyboard](doc/keyboard.md) + - [Mouse](doc/mouse.md) + - [Gamepad](doc/gamepad.md) - [Device](doc/device.md) - [Window](doc/window.md) - [Recording](doc/recording.md) + - [Virtual display](doc/virtual_display.md) - [Tunnels](doc/tunnels.md) - - [HID/OTG](doc/hid-otg.md) + - [OTG](doc/otg.md) - [Camera](doc/camera.md) - [Video4Linux](doc/v4l2.md) - [Shortcuts](doc/shortcuts.md) @@ -105,13 +181,17 @@ documented in the following pages: ## Contact -If you encounter a bug, please read the [FAQ](FAQ.md) first, then open an [issue]. +You can open an [issue] for bug reports, feature requests or general questions. + +For bug reports, please read the [FAQ](FAQ.md) first, you might find a solution +to your problem immediately. [issue]: https://github.com/Genymobile/scrcpy/issues -For general questions or discussions, you can also use: +You can also use: - Reddit: [`r/scrcpy`](https://www.reddit.com/r/scrcpy) + - BlueSky: [`@scrcpy.bsky.social`](https://bsky.app/profile/scrcpy.bsky.social) - Twitter: [`@scrcpy_app`](https://twitter.com/scrcpy_app) @@ -127,10 +207,10 @@ work][donate]: [donate]: https://blog.rom1v.com/about/#support-my-open-source-work -## Licence +## License Copyright (C) 2018 Genymobile - Copyright (C) 2018-2023 Romain Vimont + Copyright (C) 2018-2025 Romain Vimont Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/app/data/bash-completion/scrcpy b/app/data/bash-completion/scrcpy index 0c854310..a49da8ca 100644 --- a/app/data/bash-completion/scrcpy +++ b/app/data/bash-completion/scrcpy @@ -2,10 +2,12 @@ _scrcpy() { local cur prev words cword local opts=" --always-on-top + --angle --audio-bit-rate= --audio-buffer= --audio-codec= --audio-codec-options= + --audio-dup --audio-encoder= --audio-source= --audio-output-buffer= @@ -16,31 +18,37 @@ _scrcpy() { --camera-fps= --camera-high-speed --camera-size= + --capture-orientation= --crop= -d --select-usb --disable-screensaver - --display-buffer= --display-id= + --display-ime-policy= --display-orientation= -e --select-tcpip -f --fullscreen --force-adb-forward - --forward-all-clicks + -G + --gamepad= -h --help + -K + --keyboard= --kill-adb-on-close - -K --hid-keyboard --legacy-paste + --list-apps --list-camera-sizes --list-cameras --list-displays --list-encoders - --lock-video-orientation - --lock-video-orientation= -m --max-size= - -M --hid-mouse + -M --max-fps= + --mouse= + --mouse-bind= -n --no-control -N --no-playback + --new-display + --new-display= --no-audio --no-audio-playback --no-cleanup @@ -48,7 +56,10 @@ _scrcpy() { --no-downsize-on-error --no-key-repeat --no-mipmaps + --no-mouse-hover --no-power-on + --no-vd-destroy-content + --no-vd-system-decorations --no-video --no-video-playback --orientation= @@ -69,7 +80,9 @@ _scrcpy() { --rotation= -s --serial= -S --turn-screen-off + --screen-off-timeout= --shortcut-mod= + --start-app= -t --show-touches --tcpip --tcpip= @@ -80,6 +93,7 @@ _scrcpy() { --v4l2-sink= -v --version -V --verbosity= + --video-buffer= --video-codec= --video-codec-options= --video-encoder= @@ -108,24 +122,39 @@ _scrcpy() { return ;; --audio-source) - COMPREPLY=($(compgen -W 'output mic' -- "$cur")) + COMPREPLY=($(compgen -W 'output playback mic mic-unprocessed mic-camcorder mic-voice-recognition mic-voice-communication voice-call voice-call-uplink voice-call-downlink voice-performance' -- "$cur")) return ;; --camera-facing) COMPREPLY=($(compgen -W 'front back external' -- "$cur")) return ;; - --orientation - --display-orientation) - COMPREPLY=($(compgen -> '0 90 180 270 flip0 flip90 flip180 flip270' -- "$cur")) + --keyboard) + COMPREPLY=($(compgen -W 'disabled sdk uhid aoa' -- "$cur")) + return + ;; + --mouse) + COMPREPLY=($(compgen -W 'disabled sdk uhid aoa' -- "$cur")) + return + ;; + --gamepad) + COMPREPLY=($(compgen -W 'disabled uhid aoa' -- "$cur")) + return + ;; + --capture-orientation) + COMPREPLY=($(compgen -W '0 90 180 270 flip0 flip90 flip180 flip270 @0 @90 @180 @270 @flip0 @flip90 @flip180 @flip270' -- "$cur")) + return + ;; + --orientation|--display-orientation) + COMPREPLY=($(compgen -W '0 90 180 270 flip0 flip90 flip180 flip270' -- "$cur")) + return + ;; + --display-ime-policy) + COMPREPLY=($(compgen -W 'local fallback hide' -- "$cur")) return ;; --record-orientation) - COMPREPLY=($(compgen -> '0 90 180 270' -- "$cur")) - return - ;; - --lock-video-orientation) - COMPREPLY=($(compgen -W 'unlocked initial 0 90 180 270' -- "$cur")) + COMPREPLY=($(compgen -W '0 90 180 270' -- "$cur")) return ;; --pause-on-exit) @@ -170,16 +199,18 @@ _scrcpy() { |--camera-size \ |--crop \ |--display-id \ - |--display-buffer \ |--max-fps \ |-m|--max-size \ + |--new-display \ |-p|--port \ |--push-target \ |--rotation \ + |--screen-off-timeout \ |--tunnel-host \ |--tunnel-port \ |--v4l2-buffer \ |--v4l2-sink \ + |--video-buffer \ |--video-codec-options \ |--video-encoder \ |--tcpip \ diff --git a/app/data/zsh-completion/_scrcpy b/app/data/zsh-completion/_scrcpy index 3c7ca217..04ffb8f1 100644 --- a/app/data/zsh-completion/_scrcpy +++ b/app/data/zsh-completion/_scrcpy @@ -1,4 +1,4 @@ -#compdef -N scrcpy -N scrcpy.exe +#compdef scrcpy scrcpy.exe # # name: scrcpy # auth: hltdev [hltdev8642@gmail.com] @@ -9,12 +9,14 @@ local arguments arguments=( '--always-on-top[Make scrcpy window always on top \(above other windows\)]' + '--angle=[Rotate the video content by a custom angle, in degrees]' '--audio-bit-rate=[Encode the audio at the given bit-rate]' - '--audio-buffer=[Configure the audio buffering delay (in milliseconds)]' + '--audio-buffer=[Configure the audio buffering delay \(in milliseconds\)]' '--audio-codec=[Select the audio codec]:codec:(opus aac flac raw)' '--audio-codec-options=[Set a list of comma-separated key\:type=value options for the device audio encoder]' + '--audio-dup=[Duplicate audio]' '--audio-encoder=[Use a specific MediaCodec audio encoder]' - '--audio-source=[Select the audio source]:source:(output mic)' + '--audio-source=[Select the audio source]:source:(output playback mic mic-unprocessed mic-camcorder mic-voice-recognition mic-voice-communication voice-call voice-call-uplink voice-call-downlink voice-performance)' '--audio-output-buffer=[Configure the size of the SDL audio output buffer (in milliseconds)]' {-b,--video-bit-rate=}'[Encode the video at the given bit-rate]' '--camera-ar=[Select the camera size by its aspect ratio]' @@ -23,30 +25,36 @@ arguments=( '--camera-facing=[Select the device camera by its facing direction]:facing:(front back external)' '--camera-fps=[Specify the camera capture frame rate]' '--camera-size=[Specify an explicit camera capture size]' + '--capture-orientation=[Set the capture video orientation]:orientation:(0 90 180 270 flip0 flip90 flip180 flip270 @0 @90 @180 @270 @flip0 @flip90 @flip180 @flip270)' '--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-ime-policy[Set the policy for selecting where the IME should be displayed]' '--display-orientation=[Set the initial display orientation]:orientation values:(0 90 180 270 flip0 flip90 flip180 flip270)' {-e,--select-tcpip}'[Use TCP/IP device]' {-f,--fullscreen}'[Start in fullscreen]' '--force-adb-forward[Do not attempt to use \"adb reverse\" to connect to the device]' - '--forward-all-clicks[Forward clicks to device]' + '-G[Use UHID/AOA gamepad \(same as --gamepad=uhid or --gamepad=aoa, depending on OTG mode\)]' + '--gamepad=[Set the gamepad input mode]:mode:(disabled uhid aoa)' {-h,--help}'[Print the help]' + '-K[Use UHID/AOA keyboard \(same as --keyboard=uhid or --keyboard=aoa, depending on OTG mode\)]' + '--keyboard=[Set the keyboard input mode]:mode:(disabled sdk uhid aoa)' '--kill-adb-on-close[Kill adb when scrcpy terminates]' - {-K,--hid-keyboard}'[Simulate a physical keyboard by using HID over AOAv2]' '--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]' '--list-encoders[List video and audio encoders available on the device]' - '--lock-video-orientation=[Lock video orientation]:orientation:(unlocked initial 0 90 180 270)' {-m,--max-size=}'[Limit both the width and height of the video to value]' - {-M,--hid-mouse}'[Simulate a physical mouse by using HID over AOAv2]' + '-M[Use UHID/AOA mouse \(same as --mouse=uhid or --mouse=aoa, depending on OTG mode\)]' '--max-fps=[Limit the frame rate of screen capture]' + '--mouse=[Set the mouse input mode]:mode:(disabled sdk uhid aoa)' + '--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]' @@ -54,7 +62,10 @@ arguments=( '--no-downsize-on-error[Disable lowering definition on MediaCodec error]' '--no-key-repeat[Do not forward repeated key events when a key is held down]' '--no-mipmaps[Disable the generation of mipmaps]' + '--no-mouse-hover[Do not forward mouse hover events]' '--no-power-on[Do not power on the device on start]' + '--no-vd-destroy-content[Disable virtual display "destroy content on removal" flag]' + '--no-vd-system-decorations[Disable virtual display system decorations flag]' '--no-video[Disable video forwarding]' '--no-video-playback[Disable video playback]' '--orientation=[Set the video orientation]:orientation values:(0 90 180 270 flip0 flip90 flip180 flip270)' @@ -73,7 +84,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]' @@ -83,6 +96,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]' diff --git a/app/deps/.gitignore b/app/deps/.gitignore new file mode 100644 index 00000000..ccf6a49e --- /dev/null +++ b/app/deps/.gitignore @@ -0,0 +1 @@ +/work diff --git a/app/deps/README b/app/deps/README new file mode 100644 index 00000000..9cfb5c06 --- /dev/null +++ b/app/deps/README @@ -0,0 +1,27 @@ +This directory (app/deps/) contains: + +*.sh : shell scripts to download and build dependencies + +patches/ : patches to fix dependencies (used by scripts) + +work/sources/ : downloaded tarballs and extracted folders + ffmpeg-6.1.1.tar.xz + ffmpeg-6.1.1/ + libusb-1.0.27.tar.gz + libusb-1.0.27/ + ... +work/build/ : build dirs for each dependency/version/architecture + ffmpeg-6.1.1/win32/ + ffmpeg-6.1.1/win64/ + libusb-1.0.27/win32/ + libusb-1.0.27/win64/ + ... +work/install/ : install dirs for each architexture + win32/bin/ + win32/include/ + win32/lib/ + win32/share/ + win64/bin/ + win64/include/ + win64/lib/ + win64/share/ diff --git a/app/deps/adb_linux.sh b/app/deps/adb_linux.sh new file mode 100755 index 00000000..a3e339ec --- /dev/null +++ b/app/deps/adb_linux.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -ex +DEPS_DIR=$(dirname ${BASH_SOURCE[0]}) +cd "$DEPS_DIR" +. common + +VERSION=36.0.0 +FILENAME=platform-tools_r$VERSION-linux.zip +PROJECT_DIR=platform-tools-$VERSION-linux +SHA256SUM=0ead642c943ffe79701fccca8f5f1c69c4ce4f43df2eefee553f6ccb27cbfbe8 + +cd "$SOURCES_DIR" + +if [[ -d "$PROJECT_DIR" ]] +then + echo "$PWD/$PROJECT_DIR" found +else + get_file "https://dl.google.com/android/repository/$FILENAME" "$FILENAME" "$SHA256SUM" + mkdir -p "$PROJECT_DIR" + cd "$PROJECT_DIR" + ZIP_PREFIX=platform-tools + unzip "../$FILENAME" "$ZIP_PREFIX"/adb + mv "$ZIP_PREFIX"/* . + rmdir "$ZIP_PREFIX" +fi + +mkdir -p "$INSTALL_DIR/adb-linux" +cd "$INSTALL_DIR/adb-linux" +cp -r "$SOURCES_DIR/$PROJECT_DIR"/. "$INSTALL_DIR/adb-linux/" diff --git a/app/deps/adb_macos.sh b/app/deps/adb_macos.sh new file mode 100755 index 00000000..36f5df89 --- /dev/null +++ b/app/deps/adb_macos.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -ex +DEPS_DIR=$(dirname ${BASH_SOURCE[0]}) +cd "$DEPS_DIR" +. common + +VERSION=36.0.0 +FILENAME=platform-tools_r$VERSION-darwin.zip +PROJECT_DIR=platform-tools-$VERSION-darwin +SHA256SUM=b241878e6ec20650b041bf715ea05f7d5dc73bd24529464bd9cf68946e3132bd + +cd "$SOURCES_DIR" + +if [[ -d "$PROJECT_DIR" ]] +then + echo "$PWD/$PROJECT_DIR" found +else + get_file "https://dl.google.com/android/repository/$FILENAME" "$FILENAME" "$SHA256SUM" + mkdir -p "$PROJECT_DIR" + cd "$PROJECT_DIR" + ZIP_PREFIX=platform-tools + unzip "../$FILENAME" "$ZIP_PREFIX"/adb + mv "$ZIP_PREFIX"/* . + rmdir "$ZIP_PREFIX" +fi + +mkdir -p "$INSTALL_DIR/adb-macos" +cd "$INSTALL_DIR/adb-macos" +cp -r "$SOURCES_DIR/$PROJECT_DIR"/. "$INSTALL_DIR/adb-macos/" diff --git a/app/deps/adb_windows.sh b/app/deps/adb_windows.sh new file mode 100755 index 00000000..de37162c --- /dev/null +++ b/app/deps/adb_windows.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -ex +DEPS_DIR=$(dirname ${BASH_SOURCE[0]}) +cd "$DEPS_DIR" +. common + +VERSION=36.0.0 +FILENAME=platform-tools_r$VERSION-win.zip +PROJECT_DIR=platform-tools-$VERSION-windows +SHA256SUM=24bd8bebbbb58b9870db202b5c6775c4a49992632021c60750d9d8ec8179d5f0 + +cd "$SOURCES_DIR" + +if [[ -d "$PROJECT_DIR" ]] +then + echo "$PWD/$PROJECT_DIR" found +else + get_file "https://dl.google.com/android/repository/$FILENAME" "$FILENAME" "$SHA256SUM" + mkdir -p "$PROJECT_DIR" + cd "$PROJECT_DIR" + ZIP_PREFIX=platform-tools + unzip "../$FILENAME" \ + "$ZIP_PREFIX"/AdbWinApi.dll \ + "$ZIP_PREFIX"/AdbWinUsbApi.dll \ + "$ZIP_PREFIX"/adb.exe + mv "$ZIP_PREFIX"/* . + rmdir "$ZIP_PREFIX" +fi + +mkdir -p "$INSTALL_DIR/adb-windows" +cd "$INSTALL_DIR/adb-windows" +cp -r "$SOURCES_DIR/$PROJECT_DIR"/. "$INSTALL_DIR/adb-windows/" diff --git a/app/deps/common b/app/deps/common new file mode 100644 index 00000000..daaa96c0 --- /dev/null +++ b/app/deps/common @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# This file is intended to be sourced by other scripts, not executed + +process_args() { + if [[ $# != 3 ]] + then + # : win32 or win64 + # : native or cross + # : static or shared + echo "Syntax: $0 " >&2 + exit 1 + fi + + HOST="$1" + BUILD_TYPE="$2" # native or cross + LINK_TYPE="$3" # static or shared + DIRNAME="$HOST-$BUILD_TYPE-$LINK_TYPE" + + if [[ "$BUILD_TYPE" != native && "$BUILD_TYPE" != cross ]] + then + echo "Unsupported build type (expected native or cross): $BUILD_TYPE" >&2 + exit 1 + fi + + if [[ "$LINK_TYPE" != static && "$LINK_TYPE" != shared ]] + then + echo "Unsupported link type (expected static or shared): $LINK_TYPE" >&2 + exit 1 + fi + + if [[ "$BUILD_TYPE" == cross ]] + then + if [[ "$HOST" = win32 ]] + then + HOST_TRIPLET=i686-w64-mingw32 + elif [[ "$HOST" = win64 ]] + then + HOST_TRIPLET=x86_64-w64-mingw32 + else + echo "Unsupported cross-build to host: $HOST" >&2 + exit 1 + fi + fi +} + +DEPS_DIR=$(dirname ${BASH_SOURCE[0]}) +cd "$DEPS_DIR" + +PATCHES_DIR="$PWD/patches" + +WORK_DIR="$PWD/work" +SOURCES_DIR="$WORK_DIR/sources" +BUILD_DIR="$WORK_DIR/build" +INSTALL_DIR="$WORK_DIR/install" + +mkdir -p "$INSTALL_DIR" "$SOURCES_DIR" "$WORK_DIR" + +checksum() { + local file="$1" + local sum="$2" + echo "$file: verifying checksum..." + echo "$sum $file" | shasum -a256 -c +} + +get_file() { + local url="$1" + local file="$2" + local sum="$3" + if [[ -f "$file" ]] + then + echo "$file: found" + else + echo "$file: not found, downloading..." + wget "$url" -O "$file" + fi + checksum "$file" "$sum" +} diff --git a/app/deps/dav1d.sh b/app/deps/dav1d.sh new file mode 100755 index 00000000..3069b6fe --- /dev/null +++ b/app/deps/dav1d.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -ex +DEPS_DIR=$(dirname ${BASH_SOURCE[0]}) +cd "$DEPS_DIR" +. common +process_args "$@" + +VERSION=1.5.0 +FILENAME=dav1d-$VERSION.tar.gz +PROJECT_DIR=dav1d-$VERSION +SHA256SUM=78b15d9954b513ea92d27f39362535ded2243e1b0924fde39f37a31ebed5f76b + +cd "$SOURCES_DIR" + +if [[ -d "$PROJECT_DIR" ]] +then + echo "$PWD/$PROJECT_DIR" found +else + get_file "https://code.videolan.org/videolan/dav1d/-/archive/$VERSION/$FILENAME" "$FILENAME" "$SHA256SUM" + tar xf "$FILENAME" # First level directory is "$PROJECT_DIR" +fi + +mkdir -p "$BUILD_DIR/$PROJECT_DIR" +cd "$BUILD_DIR/$PROJECT_DIR" + +if [[ -d "$DIRNAME" ]] +then + echo "'$PWD/$DIRNAME' already exists, not reconfigured" + cd "$DIRNAME" +else + mkdir "$DIRNAME" + cd "$DIRNAME" + + conf=( + --prefix="$INSTALL_DIR/$DIRNAME" + --libdir=lib + -Denable_tests=false + -Denable_tools=false + # Always build dav1d statically + --default-library=static + ) + + if [[ "$BUILD_TYPE" == cross ]] + then + case "$HOST" in + win32) + conf+=( + --cross-file="$SOURCES_DIR/$PROJECT_DIR/package/crossfiles/i686-w64-mingw32.meson" + ) + ;; + + win64) + conf+=( + --cross-file="$SOURCES_DIR/$PROJECT_DIR/package/crossfiles/x86_64-w64-mingw32.meson" + ) + ;; + + *) + echo "Unsupported host: $HOST" >&2 + exit 1 + esac + fi + + meson setup . "$SOURCES_DIR/$PROJECT_DIR" "${conf[@]}" +fi + +ninja +ninja install diff --git a/app/deps/ffmpeg.sh b/app/deps/ffmpeg.sh new file mode 100755 index 00000000..fb8b9a25 --- /dev/null +++ b/app/deps/ffmpeg.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +set -ex +DEPS_DIR=$(dirname ${BASH_SOURCE[0]}) +cd "$DEPS_DIR" +. common +process_args "$@" + +VERSION=7.1.1 +FILENAME=ffmpeg-$VERSION.tar.xz +PROJECT_DIR=ffmpeg-$VERSION +SHA256SUM=733984395e0dbbe5c046abda2dc49a5544e7e0e1e2366bba849222ae9e3a03b1 + +cd "$SOURCES_DIR" + +if [[ -d "$PROJECT_DIR" ]] +then + echo "$PWD/$PROJECT_DIR" found +else + get_file "https://ffmpeg.org/releases/$FILENAME" "$FILENAME" "$SHA256SUM" + tar xf "$FILENAME" # First level directory is "$PROJECT_DIR" +fi + +mkdir -p "$BUILD_DIR/$PROJECT_DIR" +cd "$BUILD_DIR/$PROJECT_DIR" + +if [[ -d "$DIRNAME" ]] +then + echo "'$PWD/$DIRNAME' already exists, not reconfigured" + cd "$DIRNAME" +else + mkdir "$DIRNAME" + cd "$DIRNAME" + + if [[ "$HOST" == win* ]] + then + # -static-libgcc to avoid missing libgcc_s_dw2-1.dll + # -static to avoid dynamic dependency to zlib + export CFLAGS='-static-libgcc -static' + export CXXFLAGS="$CFLAGS" + export LDFLAGS='-static-libgcc -static' + elif [[ "$HOST" == "macos" ]] + then + export PKG_CONFIG_PATH="/opt/homebrew/opt/zlib/lib/pkgconfig" + fi + + export PKG_CONFIG_PATH="$INSTALL_DIR/$DIRNAME/lib/pkgconfig:$PKG_CONFIG_PATH" + + conf=( + --prefix="$INSTALL_DIR/$DIRNAME" + --pkg-config-flags="--static" + --extra-cflags="-O2 -fPIC" + --disable-programs + --disable-doc + --disable-swscale + --disable-postproc + --disable-avfilter + --disable-network + --disable-everything + --disable-vulkan + --disable-vaapi + --disable-vdpau + --enable-swresample + --enable-libdav1d + --enable-decoder=h264 + --enable-decoder=hevc + --enable-decoder=av1 + --enable-decoder=libdav1d + --enable-decoder=pcm_s16le + --enable-decoder=opus + --enable-decoder=aac + --enable-decoder=flac + --enable-decoder=png + --enable-protocol=file + --enable-demuxer=image2 + --enable-parser=png + --enable-zlib + --enable-muxer=matroska + --enable-muxer=mp4 + --enable-muxer=opus + --enable-muxer=flac + --enable-muxer=wav + ) + + if [[ "$HOST" == linux ]] + then + conf+=( + --enable-libv4l2 + --enable-outdev=v4l2 + --enable-encoder=rawvideo + ) + else + # libavdevice is only used for V4L2 on Linux + conf+=( + --disable-avdevice + ) + fi + + if [[ "$LINK_TYPE" == static ]] + then + conf+=( + --enable-static + --disable-shared + ) + else + conf+=( + --disable-static + --enable-shared + ) + fi + + if [[ "$BUILD_TYPE" == cross ]] + then + conf+=( + --enable-cross-compile + --cross-prefix="${HOST_TRIPLET}-" + --cc="${HOST_TRIPLET}-gcc" + ) + + case "$HOST" in + win32) + conf+=( + --target-os=mingw32 + --arch=x86 + ) + ;; + + win64) + conf+=( + --target-os=mingw32 + --arch=x86_64 + ) + ;; + + *) + echo "Unsupported host: $HOST" >&2 + exit 1 + esac + fi + + "$SOURCES_DIR/$PROJECT_DIR"/configure "${conf[@]}" +fi + +make -j +make install diff --git a/app/deps/libusb.sh b/app/deps/libusb.sh new file mode 100755 index 00000000..887a2a77 --- /dev/null +++ b/app/deps/libusb.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -ex +DEPS_DIR=$(dirname ${BASH_SOURCE[0]}) +cd "$DEPS_DIR" +. common +process_args "$@" + +VERSION=1.0.29 +FILENAME=libusb-$VERSION.tar.gz +PROJECT_DIR=libusb-$VERSION +SHA256SUM=7c2dd39c0b2589236e48c93247c986ae272e27570942b4163cb00a060fcf1b74 + +cd "$SOURCES_DIR" + +if [[ -d "$PROJECT_DIR" ]] +then + echo "$PWD/$PROJECT_DIR" found +else + get_file "https://github.com/libusb/libusb/archive/refs/tags/v$VERSION.tar.gz" "$FILENAME" "$SHA256SUM" + tar xf "$FILENAME" # First level directory is "$PROJECT_DIR" +fi + +mkdir -p "$BUILD_DIR/$PROJECT_DIR" +cd "$BUILD_DIR/$PROJECT_DIR" + +export CFLAGS='-O2' +export CXXFLAGS="$CFLAGS" + +if [[ -d "$DIRNAME" ]] +then + echo "'$PWD/$DIRNAME' already exists, not reconfigured" + cd "$DIRNAME" +else + mkdir "$DIRNAME" + cd "$DIRNAME" + + conf=( + --prefix="$INSTALL_DIR/$DIRNAME" + ) + + if [[ "$LINK_TYPE" == static ]] + then + conf+=( + --enable-static + --disable-shared + ) + else + conf+=( + --disable-static + --enable-shared + ) + fi + + if [[ "$BUILD_TYPE" == cross ]] + then + conf+=( + --host="$HOST_TRIPLET" + ) + fi + + "$SOURCES_DIR/$PROJECT_DIR"/bootstrap.sh + "$SOURCES_DIR/$PROJECT_DIR"/configure "${conf[@]}" +fi + +make -j +make install-strip diff --git a/app/deps/sdl.sh b/app/deps/sdl.sh new file mode 100755 index 00000000..54fee12b --- /dev/null +++ b/app/deps/sdl.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -ex +DEPS_DIR=$(dirname ${BASH_SOURCE[0]}) +cd "$DEPS_DIR" +. common +process_args "$@" + +VERSION=2.32.8 +FILENAME=SDL-$VERSION.tar.gz +PROJECT_DIR=SDL-release-$VERSION +SHA256SUM=dd35e05644ae527848d02433bec24dd0ea65db59faecf1a0e5d1880c533dac2c + +cd "$SOURCES_DIR" + +if [[ -d "$PROJECT_DIR" ]] +then + echo "$PWD/$PROJECT_DIR" found +else + get_file "https://github.com/libsdl-org/SDL/archive/refs/tags/release-$VERSION.tar.gz" "$FILENAME" "$SHA256SUM" + tar xf "$FILENAME" # First level directory is "$PROJECT_DIR" +fi + +mkdir -p "$BUILD_DIR/$PROJECT_DIR" +cd "$BUILD_DIR/$PROJECT_DIR" + +export CFLAGS='-O2' +export CXXFLAGS="$CFLAGS" + +if [[ -d "$DIRNAME" ]] +then + echo "'$PWD/$HDIRNAME' already exists, not reconfigured" + cd "$DIRNAME" +else + mkdir "$DIRNAME" + cd "$DIRNAME" + + conf=( + --prefix="$INSTALL_DIR/$DIRNAME" + ) + + if [[ "$HOST" == linux ]] + then + conf+=( + --enable-video-wayland + --enable-video-x11 + ) + fi + + if [[ "$LINK_TYPE" == static ]] + then + conf+=( + --enable-static + --disable-shared + ) + else + conf+=( + --disable-static + --enable-shared + ) + fi + + if [[ "$BUILD_TYPE" == cross ]] + then + conf+=( + --host="$HOST_TRIPLET" + ) + fi + + "$SOURCES_DIR/$PROJECT_DIR"/configure "${conf[@]}" +fi + +make -j +# There is no "make install-strip" +make install +# Strip manually +if [[ "$LINK_TYPE" == shared && "$HOST" == win* ]] +then + ${HOST_TRIPLET}-strip "$INSTALL_DIR/$DIRNAME/bin/SDL2.dll" +fi diff --git a/app/meson.build b/app/meson.build index 88e2df9a..f7df69eb 100644 --- a/app/meson.build +++ b/app/meson.build @@ -5,6 +5,7 @@ src = [ 'src/adb/adb_parser.c', 'src/adb/adb_tunnel.c', 'src/audio_player.c', + 'src/audio_regulator.c', 'src/cli.c', 'src/clock.c', 'src/compat.c', @@ -15,13 +16,15 @@ src = [ 'src/demuxer.c', 'src/device_msg.c', 'src/display.c', + 'src/events.c', 'src/icon.c', 'src/file_pusher.c', 'src/fps_counter.c', 'src/frame_buffer.c', 'src/input_manager.c', - 'src/keyboard_inject.c', - 'src/mouse_inject.c', + 'src/keyboard_sdk.c', + 'src/mouse_capture.c', + 'src/mouse_sdk.c', 'src/opengl.c', 'src/options.c', 'src/packet_merger.c', @@ -31,11 +34,19 @@ src = [ 'src/screen.c', 'src/server.c', 'src/version.c', + 'src/hid/hid_gamepad.c', + 'src/hid/hid_keyboard.c', + 'src/hid/hid_mouse.c', 'src/trait/frame_source.c', 'src/trait/packet_source.c', + 'src/uhid/gamepad_uhid.c', + 'src/uhid/keyboard_uhid.c', + 'src/uhid/mouse_uhid.c', + 'src/uhid/uhid_output.c', 'src/util/acksync.c', + 'src/util/audiobuf.c', 'src/util/average.c', - 'src/util/bytebuf.c', + 'src/util/env.c', 'src/util/file.c', 'src/util/intmap.c', 'src/util/intr.c', @@ -88,8 +99,9 @@ usb_support = get_option('usb') if usb_support src += [ 'src/usb/aoa_hid.c', - 'src/usb/hid_keyboard.c', - 'src/usb/hid_mouse.c', + 'src/usb/gamepad_aoa.c', + 'src/usb/keyboard_aoa.c', + 'src/usb/mouse_aoa.c', 'src/usb/scrcpy_otg.c', 'src/usb/screen_otg.c', 'src/usb/usb.c', @@ -98,20 +110,22 @@ endif cc = meson.get_compiler('c') +static = get_option('static') + dependencies = [ - dependency('libavformat', version: '>= 57.33'), - dependency('libavcodec', version: '>= 57.37'), - dependency('libavutil'), - dependency('libswresample'), - dependency('sdl2', version: '>= 2.0.5'), + dependency('libavformat', version: '>= 57.33', static: static), + dependency('libavcodec', version: '>= 57.37', static: static), + dependency('libavutil', static: static), + dependency('libswresample', static: static), + dependency('sdl2', version: '>= 2.0.5', static: static), ] if v4l2_support - dependencies += dependency('libavdevice') + dependencies += dependency('libavdevice', static: static) endif if usb_support - dependencies += dependency('libusb-1.0') + dependencies += dependency('libusb-1.0', static: static) endif if host_machine.system() == 'windows' @@ -156,9 +170,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) @@ -181,19 +192,19 @@ datadir = get_option('datadir') # by default 'share' install_man('scrcpy.1') install_data('data/icon.png', rename: 'scrcpy.png', - install_dir: join_paths(datadir, 'icons/hicolor/256x256/apps')) + install_dir: datadir / 'icons/hicolor/256x256/apps') install_data('data/zsh-completion/_scrcpy', - install_dir: join_paths(datadir, 'zsh/site-functions')) + install_dir: datadir / 'zsh/site-functions') install_data('data/bash-completion/scrcpy', - install_dir: join_paths(datadir, 'bash-completion/completions')) + install_dir: datadir / 'bash-completion/completions') # Desktop entry file for application launchers if host_machine.system() == 'linux' # Install a launcher (ex: /usr/local/share/applications/scrcpy.desktop) install_data('data/scrcpy.desktop', - install_dir: join_paths(datadir, 'applications')) + install_dir: datadir / 'applications') install_data('data/scrcpy-console.desktop', - install_dir: join_paths(datadir, 'applications')) + install_dir: datadir / 'applications') endif @@ -212,9 +223,10 @@ if get_option('buildtype') == 'debug' ['test_binary', [ 'tests/test_binary.c', ]], - ['test_bytebuf', [ - 'tests/test_bytebuf.c', - 'src/util/bytebuf.c', + ['test_audiobuf', [ + 'tests/test_audiobuf.c', + 'src/util/audiobuf.c', + 'src/util/memory.c', ]], ['test_cli', [ 'tests/test_cli.c', @@ -267,3 +279,9 @@ if get_option('buildtype') == 'debug' test(t[0], exe) endforeach endif + +if meson.version().version_compare('>= 0.58.0') + devenv = environment() + devenv.set('SCRCPY_ICON_PATH', meson.current_source_dir() / 'data/icon.png') + meson.add_devenv(devenv) +endif diff --git a/app/prebuilt-deps/.gitignore b/app/prebuilt-deps/.gitignore deleted file mode 100644 index 3af0ccb6..00000000 --- a/app/prebuilt-deps/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/data diff --git a/app/prebuilt-deps/common b/app/prebuilt-deps/common deleted file mode 100755 index c97f7de4..00000000 --- a/app/prebuilt-deps/common +++ /dev/null @@ -1,22 +0,0 @@ -PREBUILT_DATA_DIR=data - -checksum() { - local file="$1" - local sum="$2" - echo "$file: verifying checksum..." - echo "$sum $file" | sha256sum -c -} - -get_file() { - local url="$1" - local file="$2" - local sum="$3" - if [[ -f "$file" ]] - then - echo "$file: found" - else - echo "$file: not found, downloading..." - wget "$url" -O "$file" - fi - checksum "$file" "$sum" -} diff --git a/app/prebuilt-deps/prepare-adb.sh b/app/prebuilt-deps/prepare-adb.sh deleted file mode 100755 index 4fb6fd7d..00000000 --- a/app/prebuilt-deps/prepare-adb.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env bash -set -e -DIR=$(dirname ${BASH_SOURCE[0]}) -cd "$DIR" -. common -mkdir -p "$PREBUILT_DATA_DIR" -cd "$PREBUILT_DATA_DIR" - -DEP_DIR=platform-tools-34.0.5 - -FILENAME=platform-tools_r34.0.5-windows.zip -SHA256SUM=3f8320152704377de150418a3c4c9d07d16d80a6c0d0d8f7289c22c499e33571 - -if [[ -d "$DEP_DIR" ]] -then - echo "$DEP_DIR" found - exit 0 -fi - -get_file "https://dl.google.com/android/repository/$FILENAME" \ - "$FILENAME" "$SHA256SUM" - -mkdir "$DEP_DIR" -cd "$DEP_DIR" - -ZIP_PREFIX=platform-tools -unzip "../$FILENAME" \ - "$ZIP_PREFIX"/AdbWinApi.dll \ - "$ZIP_PREFIX"/AdbWinUsbApi.dll \ - "$ZIP_PREFIX"/adb.exe -mv "$ZIP_PREFIX"/* . -rmdir "$ZIP_PREFIX" diff --git a/app/prebuilt-deps/prepare-ffmpeg.sh b/app/prebuilt-deps/prepare-ffmpeg.sh deleted file mode 100755 index 19840afb..00000000 --- a/app/prebuilt-deps/prepare-ffmpeg.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash -set -e -DIR=$(dirname ${BASH_SOURCE[0]}) -cd "$DIR" -. common -mkdir -p "$PREBUILT_DATA_DIR" -cd "$PREBUILT_DATA_DIR" - -VERSION=6.1-scrcpy-3 -DEP_DIR="ffmpeg-$VERSION" - -FILENAME="$DEP_DIR".7z -SHA256SUM=b646d18a3d543a4e4c46881568213499f22e4454a464e1552f03f2ac9cc3a05a - -if [[ -d "$DEP_DIR" ]] -then - echo "$DEP_DIR" found - exit 0 -fi - -get_file "https://github.com/rom1v/scrcpy-deps/releases/download/$VERSION/$FILENAME" \ - "$FILENAME" "$SHA256SUM" - -mkdir "$DEP_DIR" -cd "$DEP_DIR" - -ZIP_PREFIX=ffmpeg -7z x "../$FILENAME" -mv "$ZIP_PREFIX"/* . -rmdir "$ZIP_PREFIX" diff --git a/app/prebuilt-deps/prepare-libusb.sh b/app/prebuilt-deps/prepare-libusb.sh deleted file mode 100755 index 228a5bfa..00000000 --- a/app/prebuilt-deps/prepare-libusb.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash -set -e -DIR=$(dirname ${BASH_SOURCE[0]}) -cd "$DIR" -. common -mkdir -p "$PREBUILT_DATA_DIR" -cd "$PREBUILT_DATA_DIR" - -VERSION=1.0.26 -DEP_DIR="libusb-$VERSION" - -FILENAME="libusb-$VERSION-binaries.7z" -SHA256SUM=9c242696342dbde9cdc47239391f71833939bf9f7aa2bbb28cdaabe890465ec5 - -if [[ -d "$DEP_DIR" ]] -then - echo "$DEP_DIR" found - exit 0 -fi - -get_file "https://github.com/libusb/libusb/releases/download/v$VERSION/$FILENAME" \ - "$FILENAME" "$SHA256SUM" - -mkdir "$DEP_DIR" -cd "$DEP_DIR" - -7z x "../$FILENAME" \ - "libusb-$VERSION-binaries/libusb-MinGW-Win32/" \ - "libusb-$VERSION-binaries/libusb-MinGW-Win32/" \ - "libusb-$VERSION-binaries/libusb-MinGW-x64/" \ - "libusb-$VERSION-binaries/libusb-MinGW-x64/" - -mv "libusb-$VERSION-binaries/libusb-MinGW-Win32" . -mv "libusb-$VERSION-binaries/libusb-MinGW-x64" . -rm -rf "libusb-$VERSION-binaries" - -# Rename the dll to get the same library name on all platforms -mv libusb-MinGW-Win32/bin/msys-usb-1.0.dll libusb-MinGW-Win32/bin/libusb-1.0.dll -mv libusb-MinGW-x64/bin/msys-usb-1.0.dll libusb-MinGW-x64/bin/libusb-1.0.dll diff --git a/app/prebuilt-deps/prepare-sdl.sh b/app/prebuilt-deps/prepare-sdl.sh deleted file mode 100755 index 7569744f..00000000 --- a/app/prebuilt-deps/prepare-sdl.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -set -e -DIR=$(dirname ${BASH_SOURCE[0]}) -cd "$DIR" -. common -mkdir -p "$PREBUILT_DATA_DIR" -cd "$PREBUILT_DATA_DIR" - -VERSION=2.28.5 -DEP_DIR="SDL2-$VERSION" - -FILENAME="SDL2-devel-$VERSION-mingw.tar.gz" -SHA256SUM=3c0c655c2ebf67cad48fead72761d1601740ded30808952c3274ba223d226c21 - -if [[ -d "$DEP_DIR" ]] -then - echo "$DEP_DIR" found - exit 0 -fi - -get_file "https://github.com/libsdl-org/SDL/releases/download/release-$VERSION/$FILENAME" \ - "$FILENAME" "$SHA256SUM" - -mkdir "$DEP_DIR" -cd "$DEP_DIR" - -TAR_PREFIX="$DEP_DIR" # root directory inside the tar has the same name -tar xf "../$FILENAME" --strip-components=1 \ - "$TAR_PREFIX"/i686-w64-mingw32/bin/SDL2.dll \ - "$TAR_PREFIX"/i686-w64-mingw32/include/ \ - "$TAR_PREFIX"/i686-w64-mingw32/lib/ \ - "$TAR_PREFIX"/x86_64-w64-mingw32/bin/SDL2.dll \ - "$TAR_PREFIX"/x86_64-w64-mingw32/include/ \ - "$TAR_PREFIX"/x86_64-w64-mingw32/lib/ \ diff --git a/app/scrcpy-windows.rc b/app/scrcpy-windows.rc index 4540077c..9c5374ae 100644 --- a/app/scrcpy-windows.rc +++ b/app/scrcpy-windows.rc @@ -13,7 +13,7 @@ BEGIN VALUE "LegalCopyright", "Romain Vimont, Genymobile" VALUE "OriginalFilename", "scrcpy.exe" VALUE "ProductName", "scrcpy" - VALUE "ProductVersion", "2.3" + VALUE "ProductVersion", "3.3.1" END END BLOCK "VarFileInfo" diff --git a/app/scrcpy.1 b/app/scrcpy.1 index 0c34b4e2..d72fda13 100644 --- a/app/scrcpy.1 +++ b/app/scrcpy.1 @@ -19,6 +19,10 @@ provides display and control of Android devices connected on USB (or over TCP/IP .B \-\-always\-on\-top Make scrcpy window always on top (above other windows). +.TP +.BI "\-\-angle " degrees +Rotate the video content by a custom angle, in degrees (clockwise). + .TP .BI "\-\-audio\-bit\-rate " value Encode the audio at the given bit rate, expressed in bits/s. Unit suffixes are supported: '\fBK\fR' (x1000) and '\fBM\fR' (x1000000). @@ -29,7 +33,7 @@ Default is 128K (128000). .BI "\-\-audio\-buffer " ms Configure the audio buffering delay (in milliseconds). -Lower values decrease the latency, but increase the likelyhood of buffer underrun (causing audio glitches). +Lower values decrease the latency, but increase the likelihood of buffer underrun (causing audio glitches). Default is 50. @@ -49,6 +53,12 @@ The list of possible codec options is available in the Android documentation: +.TP +.B \-\-audio\-dup +Duplicate audio (capture and keep playing on the device). + +This feature is only available with --audio-source=playback. + .TP .BI "\-\-audio\-encoder " name Use a specific MediaCodec audio encoder (depending on the codec provided by \fB\-\-audio\-codec\fR). @@ -57,7 +67,19 @@ The available encoders can be listed by \fB\-\-list\-encoders\fR. .TP .BI "\-\-audio\-source " source -Select the audio source (output or mic). +Select the audio source. Possible values are: + + - "output": forwards the whole audio output, and disables playback on the device. + - "playback": captures the audio playback (Android apps can opt-out, so the whole output is not necessarily captured). + - "mic": captures the microphone. + - "mic-unprocessed": captures the microphone unprocessed (raw) sound. + - "mic-camcorder": captures the microphone tuned for video recording, with the same orientation as the camera if available. + - "mic-voice-recognition": captures the microphone tuned for voice recognition. + - "mic-voice-communication": captures the microphone tuned for voice communications (it will for instance take advantage of echo cancellation or automatic gain control if available). + - "voice-call": captures voice call. + - "voice-call-uplink": captures voice call uplink only. + - "voice-call-downlink": captures voice call downlink only. + - "voice-performance": captures audio meant to be processed for live performance (karaoke), includes both the microphone and the device playback. Default is output. @@ -81,18 +103,6 @@ Select the camera size by its aspect ratio (+/- 10%). Possible values are "sensor" (use the camera sensor aspect ratio), "\fInum\fR:\fIden\fR" (e.g. "4:3") and "\fIvalue\fR" (e.g. "1.6"). -.TP -.B \-\-camera\-high\-speed -Enable high-speed camera capture mode. - -This mode is restricted to specific resolutions and frame rates, listed by \fB\-\-list\-camera\-sizes\fR. - -.TP -.BI "\-\-camera\-id " id -Specify the device camera id to mirror. - -The available camera ids can be listed by \fB\-\-list\-cameras\fR. - .TP .BI "\-\-camera\-facing " facing Select the device camera by its facing direction. @@ -105,17 +115,39 @@ Specify the camera capture frame rate. If not specified, Android's default frame rate (30 fps) is used. +.TP +.B \-\-camera\-high\-speed +Enable high-speed camera capture mode. + +This mode is restricted to specific resolutions and frame rates, listed by \fB\-\-list\-camera\-sizes\fR. + +.TP +.BI "\-\-camera\-id " id +Specify the device camera id to mirror. + +The available camera ids can be listed by \fB\-\-list\-cameras\fR. + .TP .BI "\-\-camera\-size " width\fRx\fIheight Specify an explicit camera capture size. +.TP +.BI "\-\-capture\-orientation " value +Possible values are 0, 90, 180, 270, flip0, flip90, flip180 and flip270, possibly prefixed by '@'. + +The number represents the clockwise rotation in degrees; the "flip" keyword applies a horizontal flip before the rotation. + +If a leading '@' is passed (@90) for display capture, then the rotation is locked, and is relative to the natural device orientation. + +If '@' is passed alone, then the rotation is locked to the initial device orientation. + +Default is 0. + .TP .BI "\-\-crop " width\fR:\fIheight\fR:\fIx\fR:\fIy Crop the device screen on the server. -The values are expressed in the device natural orientation (typically, portrait for a phone, landscape for a tablet). Any -.B \-\-max\-size -value is computed on the cropped size. +The values are expressed in the device natural orientation (typically, portrait for a phone, landscape for a tablet). .TP .B \-d, \-\-select\-usb @@ -124,15 +156,9 @@ Use USB device (if there is exactly one, like adb -d). Also see \fB\-e\fR (\fB\-\-select\-tcpip\fR). .TP -.BI "\-\-disable-screensaver" +.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. @@ -141,6 +167,19 @@ The available display ids can be listed by \fB\-\-list\-displays\fR. Default is 0. +.TP +.BI "\-\-display\-ime\-policy " value +Set the policy for selecting where the IME should be displayed. + +Possible values are "local", "fallback" and "hide": + + - "local" means that the IME should appear on the local display. + - "fallback" means that the IME should appear on a fallback display (the default display). + - "hide" means that the IME should be hidden. + +By default, the IME policy is left unchanged. + + .TP .BI "\-\-display\-orientation " value Set the initial display orientation. @@ -164,39 +203,61 @@ Start in fullscreen. Do not attempt to use "adb reverse" to connect to the device. .TP -.B \-\-forward\-all\-clicks -By default, right-click triggers BACK (or POWER on) and middle-click triggers HOME. This option disables these shortcuts and forward the clicks to the device instead. +.B \-G +Same as \fB\-\-gamepad=uhid\fR, or \fB\-\-keyboard=aoa\fR if \fB\-\-otg\fR is set. +.TP +.BI "\-\-gamepad " mode +Select how to send gamepad inputs to the device. + +Possible values are "disabled", "uhid" and "aoa": + + - "disabled" does not send gamepad inputs to the device. + - "uhid" simulates physical HID gamepads using the Linux HID kernel module on the device. + - "aoa" simulates physical HID gamepads using the AOAv2 protocol. It may only work over USB. + +Also see \fB\-\-keyboard\f and R\fB\-\-mouse\fR. .TP .B \-h, \-\-help Print this help. +.TP +.B \-K +Same as \fB\-\-keyboard=uhid\fR, or \fB\-\-keyboard=aoa\fR if \fB\-\-otg\fR is set. + +.TP +.BI "\-\-keyboard " mode +Select how to send keyboard inputs to the device. + +Possible values are "disabled", "sdk", "uhid" and "aoa": + + - "disabled" does not send keyboard inputs to the device. + - "sdk" uses the Android system API to deliver keyboard events to applications. + - "uhid" simulates a physical HID keyboard using the Linux HID kernel module on the device. + - "aoa" simulates a physical HID keyboard using the AOAv2 protocol. It may only work over USB. + +For "uhid" and "aoa", the keyboard layout must be configured (once and for all) on the device, via Settings -> System -> Languages and input -> Physical keyboard. This settings page can be started directly using the shortcut MOD+k (except in OTG mode), or by executing: + + adb shell am start -a android.settings.HARD_KEYBOARD_SETTINGS + +This option is only available when the HID keyboard is enabled (or a physical keyboard is connected). + +Also see \fB\-\-mouse\fR and \fB\-\-gamepad\fR. + .TP .B \-\-kill\-adb\-on\-close Kill adb when scrcpy terminates. -.TP -.B \-K, \-\-hid\-keyboard -Simulate a physical keyboard by using HID over AOAv2. - -This provides a better experience for IME users, and allows to generate non-ASCII characters, contrary to the default injection method. - -It may only work over USB. - -The keyboard layout must be configured (once and for all) on the device, via Settings -> System -> Languages and input -> Physical keyboard. This settings page can be started directly: - - adb shell am start -a android.settings.HARD_KEYBOARD_SETTINGS - -However, the option is only available when the HID keyboard is enabled (or a physical keyboard is connected). - -Also see \fB\-\-hid\-mouse\fR. - .TP .B \-\-legacy\-paste Inject computer clipboard text as a sequence of key events on Ctrl+v (like MOD+Shift+v). 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. @@ -213,16 +274,6 @@ List video and audio encoders available on the device. .B \-\-list\-displays List displays available on the device. -.TP -\fB\-\-lock\-video\-orientation\fR[=\fIvalue\fR] -Lock capture video orientation to \fIvalue\fR. - -Possible values are "unlocked", "initial" (locked to the initial orientation), 0, 90, 180, and 270. The values represent the clockwise rotation from the natural device orientation, in degrees. - -Default is "unlocked". - -Passing the option without argument is equivalent to passing "initial". - .TP .BI "\-m, \-\-max\-size " value Limit both the width and height of the video to \fIvalue\fR. The other dimension is computed so that the device aspect\-ratio is preserved. @@ -230,21 +281,52 @@ Limit both the width and height of the video to \fIvalue\fR. The other dimension Default is 0 (unlimited). .TP -.B \-M, \-\-hid\-mouse -Simulate a physical mouse by using HID over AOAv2. - -In this mode, the computer mouse is captured to control the device directly (relative mouse mode). - -LAlt, LSuper or RSuper toggle the capture mode, to give control of the mouse back to the computer. - -It may only work over USB. - -Also see \fB\-\-hid\-keyboard\fR. +.B \-M +Same as \fB\-\-mouse=uhid\fR, or \fB\-\-mouse=aoa\fR if \fB\-\-otg\fR is set. .TP .BI "\-\-max\-fps " value Limit the framerate of screen capture (officially supported since Android 10, but may work on earlier versions). +.TP +.BI "\-\-mouse " mode +Select how to send mouse inputs to the device. + +Possible values are "disabled", "sdk", "uhid" and "aoa": + + - "disabled" does not send mouse inputs to the device. + - "sdk" uses the Android system API to deliver mouse events to applications. + - "uhid" simulates a physical HID mouse using the Linux HID kernel module on the device. + - "aoa" simulates a physical mouse using the AOAv2 protocol. It may only work over USB. + +In "uhid" and "aoa" modes, the computer mouse is captured to control the device directly (relative mouse mode). + +LAlt, LSuper or RSuper toggle the capture mode, to give control of the mouse back to the computer. + +Also see \fB\-\-keyboard\fR and \fB\-\-gamepad\fR. + +.TP +.BI "\-\-mouse\-bind " xxxx[:xxxx] +Configure bindings of secondary clicks. + +The argument must be one or two sequences (separated by ':') of exactly 4 characters, one for each secondary click (in order: right click, middle click, 4th click, 5th click). + +The first sequence defines the primary bindings, used when a mouse button is pressed alone. The second sequence defines the secondary bindings, used when a mouse button is pressed while the Shift key is held. + +If the second sequence of bindings is omitted, then it is the same as the first one. + +Each character must be one of the following: + + - '+': forward the click to the device + - '-': ignore the click + - 'b': trigger shortcut BACK (or turn screen on if off) + - 'h': trigger shortcut HOME + - 's': trigger shortcut APP_SWITCH + - 'n': trigger shortcut "expand notification panel" + +Default is 'bhsn:++++' for SDK mouse, and '++++:bhsn' for AOA and UHID. + + .TP .B \-n, \-\-no\-control Disable device control (mirror the device in read\-only). @@ -253,6 +335,17 @@ 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. + +Examples: + + \-\-new\-display=1920x1080 + \-\-new\-display=1920x1080/420 + \-\-new\-display # main display size and density + \-\-new\-display=/240 # main display size and 240 dpi + .TP .B \-\-no\-audio Disable audio forwarding. @@ -287,10 +380,24 @@ Do not forward repeated key events when a key is held down. .B \-\-no\-mipmaps If the renderer is OpenGL 3.0+ or OpenGL ES 2.0+, then mipmaps are automatically generated to improve downscaling quality. This option disables the generation of mipmaps. +.TP +.B \-\-no\-mouse\-hover +Do not forward mouse hover (mouse motion without any clicks) events. + .TP .B \-\-no\-power\-on Do not power on the device on start. +.TP +.B \-\-no\-vd\-destroy\-content +Disable virtual display "destroy content on removal" flag. + +With this option, when the virtual display is closed, the running apps are moved to the main display rather than being destroyed. + +.TP +.B \-\-no\-vd\-system\-decorations +Disable virtual display system decorations flag. + .TP .B \-\-no\-video Disable video forwarding. @@ -299,6 +406,10 @@ Disable video forwarding. .B \-\-no\-video\-playback Disable video playback on the computer. +.TP +.B \-\-no\-window +Disable scrcpy window. Implies --no-video-playback. + .TP .BI "\-\-orientation " value Same as --display-orientation=value --record-orientation=value. @@ -315,7 +426,7 @@ If any of \fB\-\-hid\-keyboard\fR or \fB\-\-hid\-mouse\fR is set, only enable ke It may only work over USB. -See \fB\-\-hid\-keyboard\fR and \fB\-\-hid\-mouse\fR. +See \fB\-\-keyboard\fR, \fB\-\-mouse\fR and \fB\-\-gamepad\fR. .TP .BI "\-p, \-\-port " port\fR[:\fIport\fR] @@ -325,7 +436,7 @@ Default is 27183:27199. .TP \fB\-\-pause\-on\-exit\fR[=\fImode\fR] -Configure pause on exit. Possible values are "true" (always pause on exit), "false" (never pause on exit) and "if-error" (pause only if an error occured). +Configure pause on exit. Possible values are "true" (always pause on exit), "false" (never pause on exit) and "if-error" (pause only if an error occurred). This is useful to prevent the terminal window from automatically closing, so that error messages can be read. @@ -399,16 +510,36 @@ The device serial number. Mandatory only if several devices are connected to adb .B \-S, \-\-turn\-screen\-off Turn the device screen off immediately. +.TP +.B "\-\-screen\-off\-timeout " seconds +Set the screen off timeout while scrcpy is running (restore the initial value on exit). + .TP .BI "\-\-shortcut\-mod " key\fR[+...]][,...] Specify the modifiers to use for scrcpy shortcuts. Possible keys are "lctrl", "rctrl", "lalt", "ralt", "lsuper" and "rsuper". -A shortcut can consist in several keys, separated by '+'. Several shortcuts can be specified, separated by ','. +Several shortcut modifiers can be specified, separated by ','. -For example, to use either LCtrl+LAlt or LSuper for scrcpy shortcuts, pass "lctrl+lalt,lsuper". +For example, to use either LCtrl or LSuper for scrcpy shortcuts, pass "lctrl,lsuper". 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. @@ -416,13 +547,15 @@ Enable "show touches" on start, restore the initial value on exit. It only shows physical touches (not clicks from scrcpy). .TP -.BI "\-\-tcpip\fR[=\fIip\fR[:\fIport\fR]] -Configure and reconnect the device over TCP/IP. +.BI "\-\-tcpip\fR[=[+]\fIip\fR[:\fIport\fR]] +Configure and connect the device over TCP/IP. If a destination address is provided, then scrcpy connects to this address before starting. The device must listen on the given TCP port (default is 5555). If no destination address is provided, then scrcpy attempts to find the IP address and adb port of the current device (typically connected over USB), enables TCP/IP mode if necessary, then connects to this address before starting. +Prefix the address with a '+' to force a reconnection. + .TP .BI "\-\-time\-limit " seconds Set the maximum mirroring time, in seconds. @@ -453,13 +586,19 @@ Default is "info" for release builds, "debug" for debug builds. .BI "\-\-v4l2-sink " /dev/videoN Output to v4l2loopback device. -It requires to lock the video orientation (see \fB\-\-lock\-video\-orientation\fR). - .TP .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). @@ -560,6 +699,18 @@ Flip display horizontally .B MOD+Shift+Up, MOD+Shift+Down Flip display vertically +.TP +.B MOD+z +Pause or re-pause display + +.TP +.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) @@ -636,13 +787,25 @@ Copy computer clipboard to device, then paste (inject PASTE keycode, Android >= .B MOD+Shift+v Inject computer clipboard text as a sequence of key events +.TP +.B MOD+k +Open keyboard settings on the device (for HID keyboard only) + .TP .B MOD+i Enable/disable FPS counter (print frames/second in logs) .TP .B Ctrl+click-and-move -Pinch-to-zoom from the center of the screen +Pinch-to-zoom and rotate from the center of the screen + +.TP +.B Shift+click-and-move +Tilt vertically (slide with 2 fingers) + +.TP +.B Ctrl+Shift+click-and-move +Tilt horizontally (slide with 2 fingers) .TP .B Drag & drop APK file @@ -689,7 +852,7 @@ Report bugs to . .SH COPYRIGHT Copyright \(co 2018 Genymobile -Copyright \(co 2018\-2023 Romain Vimont +Copyright \(co 2018\-2025 Romain Vimont Licensed under the Apache License, Version 2.0. diff --git a/app/src/adb/adb.c b/app/src/adb/adb.c index 54375451..9e9cfd6b 100644 --- a/app/src/adb/adb.c +++ b/app/src/adb/adb.c @@ -4,9 +4,11 @@ #include #include #include +#include -#include "adb_device.h" -#include "adb_parser.h" +#include "adb/adb_device.h" +#include "adb/adb_parser.h" +#include "util/env.h" #include "util/file.h" #include "util/log.h" #include "util/process_intr.h" @@ -24,15 +26,45 @@ */ #define SC_ADB_COMMAND(...) { sc_adb_get_executable(), __VA_ARGS__, NULL } -static const char *adb_executable; +static char *adb_executable; + +bool +sc_adb_init(void) { + adb_executable = sc_get_env("ADB"); + if (adb_executable) { + LOGD("Using adb: %s", adb_executable); + return true; + } + +#if !defined(PORTABLE) || defined(_WIN32) + adb_executable = strdup("adb"); + if (!adb_executable) { + LOG_OOM(); + return false; + } +#else + // For portable builds, use the absolute path to the adb executable + // in the same directory as scrcpy (except on Windows, where "adb" + // is sufficient) + adb_executable = sc_file_get_local_path("adb"); + if (!adb_executable) { + // Error already logged + return false; + } + + LOGD("Using adb (portable): %s", adb_executable); +#endif + + return true; +} + +void +sc_adb_destroy(void) { + free(adb_executable); +} const char * sc_adb_get_executable(void) { - if (!adb_executable) { - adb_executable = getenv("ADB"); - if (!adb_executable) - adb_executable = "adb"; - } return adb_executable; } @@ -78,7 +110,7 @@ show_adb_installation_msg(void) { } pkg_managers[] = { {"apt", "apt install adb"}, {"apt-get", "apt-get install adb"}, - {"brew", "brew cask install android-platform-tools"}, + {"brew", "brew install --cask android-platform-tools"}, {"dnf", "dnf install android-tools"}, {"emerge", "emerge dev-util/android-tools"}, {"pacman", "pacman -S android-tools"}, @@ -381,7 +413,7 @@ sc_adb_connect(struct sc_intr *intr, const char *ip_port, unsigned flags) { // "adb connect" always returns successfully (with exit code 0), even in // case of failure. As a workaround, check if its output starts with - // "connected". + // "connected" or "already connected". char buf[128]; ssize_t r = sc_pipe_read_all_intr(intr, pid, pout, buf, sizeof(buf) - 1); sc_pipe_close(pout); @@ -398,7 +430,8 @@ sc_adb_connect(struct sc_intr *intr, const char *ip_port, unsigned flags) { assert((size_t) r < sizeof(buf)); buf[r] = '\0'; - ok = !strncmp("connected", buf, sizeof("connected") - 1); + ok = !strncmp("connected", buf, sizeof("connected") - 1) + || !strncmp("already connected", buf, sizeof("already connected") - 1); if (!ok && !(flags & SC_ADB_NO_STDERR)) { // "adb connect" also prints errors to stdout. Since we capture it, // re-print the error to stderr. @@ -458,6 +491,7 @@ sc_adb_list_devices(struct sc_intr *intr, unsigned flags, // in the buffer in a single pass LOGW("Result of \"adb devices -l\" does not fit in 64Kb. " "Please report an issue."); + free(buf); return false; } @@ -738,3 +772,21 @@ sc_adb_get_device_ip(struct sc_intr *intr, const char *serial, unsigned flags) { return sc_adb_parse_device_ip(buf); } + +uint16_t +sc_adb_get_device_sdk_version(struct sc_intr *intr, const char *serial) { + char *sdk_version = + sc_adb_getprop(intr, 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; +} diff --git a/app/src/adb/adb.h b/app/src/adb/adb.h index ffd532ea..e4903902 100644 --- a/app/src/adb/adb.h +++ b/app/src/adb/adb.h @@ -6,7 +6,7 @@ #include #include -#include "adb_device.h" +#include "adb/adb_device.h" #include "util/intr.h" #define SC_ADB_NO_STDOUT (1 << 0) @@ -15,6 +15,12 @@ #define SC_ADB_SILENT (SC_ADB_NO_STDOUT | SC_ADB_NO_STDERR | SC_ADB_NO_LOGERR) +bool +sc_adb_init(void); + +void +sc_adb_destroy(void); + const char * sc_adb_get_executable(void); @@ -114,4 +120,10 @@ sc_adb_getprop(struct sc_intr *intr, const char *serial, const char *prop, char * sc_adb_get_device_ip(struct sc_intr *intr, const char *serial, unsigned flags); +/** + * Return the device SDK version. + */ +uint16_t +sc_adb_get_device_sdk_version(struct sc_intr *intr, const char *serial); + #endif diff --git a/app/src/adb/adb_device.h b/app/src/adb/adb_device.h index 56393bcf..308663ef 100644 --- a/app/src/adb/adb_device.h +++ b/app/src/adb/adb_device.h @@ -4,7 +4,6 @@ #include "common.h" #include -#include #include "util/vector.h" diff --git a/app/src/adb/adb_parser.c b/app/src/adb/adb_parser.c index 66bb1854..90a1b30b 100644 --- a/app/src/adb/adb_parser.c +++ b/app/src/adb/adb_parser.c @@ -3,6 +3,7 @@ #include #include #include +#include #include "util/log.h" #include "util/str.h" diff --git a/app/src/adb/adb_parser.h b/app/src/adb/adb_parser.h index f20349f6..b8738a35 100644 --- a/app/src/adb/adb_parser.h +++ b/app/src/adb/adb_parser.h @@ -3,9 +3,9 @@ #include "common.h" -#include +#include -#include "adb_device.h" +#include "adb/adb_device.h" /** * Parse the available devices from the output of `adb devices` diff --git a/app/src/adb/adb_tunnel.c b/app/src/adb/adb_tunnel.c index fa936e4b..43e80e13 100644 --- a/app/src/adb/adb_tunnel.c +++ b/app/src/adb/adb_tunnel.c @@ -1,11 +1,11 @@ #include "adb_tunnel.h" #include +#include -#include "adb.h" +#include "adb/adb.h" #include "util/log.h" #include "util/net_intr.h" -#include "util/process_intr.h" static bool listen_on_port(struct sc_intr *intr, sc_socket socket, uint16_t port) { diff --git a/app/src/android/keycodes.h b/app/src/android/keycodes.h index 60465a18..03ebb9c8 100644 --- a/app/src/android/keycodes.h +++ b/app/src/android/keycodes.h @@ -633,7 +633,7 @@ enum android_keycode { * Toggles between BS and CS digital satellite services. */ AKEYCODE_TV_SATELLITE_SERVICE = 240, /** Toggle Network key. - * Toggles selecting broacast services. */ + * Toggles selecting broadcast services. */ AKEYCODE_TV_NETWORK = 241, /** Antenna/Cable key. * Toggles broadcast input source between antenna and cable. */ diff --git a/app/src/audio_player.c b/app/src/audio_player.c index 8f0ad7fb..9413c2ea 100644 --- a/app/src/audio_player.c +++ b/app/src/audio_player.c @@ -1,139 +1,23 @@ #include "audio_player.h" -#include -#include - #include "util/log.h" -#define SC_AUDIO_PLAYER_NDEBUG // comment to debug - -/** - * Real-time audio player with configurable latency - * - * As input, the player regularly receives AVFrames of decoded audio samples. - * As output, an SDL callback regularly requests audio samples to be played. - * In the middle, an audio buffer stores the samples produced but not consumed - * yet. - * - * The goal of the player is to feed the audio output with a latency as low as - * possible while avoiding buffer underrun (i.e. not being able to provide - * samples when requested). - * - * The player aims to feed the audio output with as little latency as possible - * while avoiding buffer underrun. To achieve this, it attempts to maintain the - * average buffering (the number of samples present in the buffer) around a - * target value. If this target buffering is too low, then buffer underrun will - * occur frequently. If it is too high, then latency will become unacceptable. - * This target value is configured using the scrcpy option --audio-buffer. - * - * The player cannot adjust the sample input rate (it receives samples produced - * in real-time) or the sample output rate (it must provide samples as - * requested by the audio output callback). Therefore, it may only apply - * compensation by resampling (converting _m_ input samples to _n_ output - * samples). - * - * The compensation itself is applied by libswresample (FFmpeg). It is - * configured using swr_set_compensation(). An important work for the player - * is to estimate the compensation value regularly and apply it. - * - * The estimated buffering level is the result of averaging the "natural" - * buffering (samples are produced and consumed by blocks, so it must be - * smoothed), and making instant adjustments resulting of its own actions - * (explicit compensation and silence insertion on underflow), which are not - * smoothed. - * - * Buffer underflow events can occur when packets arrive too late. In that case, - * the player inserts silence. Once the packets finally arrive (late), one - * strategy could be to drop the samples that were replaced by silence, in - * order to keep a minimal latency. However, dropping samples in case of buffer - * underflow is inadvisable, as it would temporarily increase the underflow - * even more and cause very noticeable audio glitches. - * - * Therefore, the player doesn't drop any sample on underflow. The compensation - * mechanism will absorb the delay introduced by the inserted silence. - */ - /** Downcast frame_sink to sc_audio_player */ #define DOWNCAST(SINK) container_of(SINK, struct sc_audio_player, frame_sink) -#define SC_AV_SAMPLE_FMT AV_SAMPLE_FMT_FLT #define SC_SDL_SAMPLE_FMT AUDIO_F32 -#define TO_BYTES(SAMPLES) sc_audiobuf_to_bytes(&ap->buf, (SAMPLES)) -#define TO_SAMPLES(BYTES) sc_audiobuf_to_samples(&ap->buf, (BYTES)) - static void SDLCALL sc_audio_player_sdl_callback(void *userdata, uint8_t *stream, int len_int) { struct sc_audio_player *ap = userdata; - // This callback is called with the lock used by SDL_AudioDeviceLock(), so - // the audiobuf is protected - assert(len_int > 0); size_t len = len_int; - uint32_t count = TO_SAMPLES(len); -#ifndef SC_AUDIO_PLAYER_NDEBUG - LOGD("[Audio] SDL callback requests %" PRIu32 " samples", count); -#endif + assert(len % ap->audioreg.sample_size == 0); + uint32_t out_samples = len / ap->audioreg.sample_size; - uint32_t buffered_samples = sc_audiobuf_can_read(&ap->buf); - if (!ap->played) { - // Part of the buffering is handled by inserting initial silence. The - // remaining (margin) last samples will be handled by compensation. - uint32_t margin = 30 * ap->sample_rate / 1000; // 30ms - if (buffered_samples + margin < ap->target_buffering) { - LOGV("[Audio] Inserting initial buffering silence: %" PRIu32 - " samples", count); - // Delay playback starting to reach the target buffering. Fill the - // whole buffer with silence (len is small compared to the - // arbitrary margin value). - memset(stream, 0, len); - return; - } - } - - uint32_t read = MIN(buffered_samples, count); - if (read) { - sc_audiobuf_read(&ap->buf, stream, read); - } - - if (read < count) { - uint32_t silence = count - read; - // Insert silence. In theory, the inserted silent samples replace the - // missing real samples, which will arrive later, so they should be - // dropped to keep the latency minimal. However, this would cause very - // audible glitches, so let the clock compensation restore the target - // latency. - LOGD("[Audio] Buffer underflow, inserting silence: %" PRIu32 " samples", - silence); - memset(stream + TO_BYTES(read), 0, TO_BYTES(silence)); - - if (ap->received) { - // Inserting additional samples immediately increases buffering - ap->underflow += silence; - } - } - - ap->played = true; -} - -static uint8_t * -sc_audio_player_get_swr_buf(struct sc_audio_player *ap, uint32_t min_samples) { - size_t min_buf_size = TO_BYTES(min_samples); - if (min_buf_size > ap->swr_buf_alloc_size) { - size_t new_size = min_buf_size + 4096; - uint8_t *buf = realloc(ap->swr_buf, new_size); - if (!buf) { - LOG_OOM(); - // Could not realloc to the requested size - return NULL; - } - ap->swr_buf = buf; - ap->swr_buf_alloc_size = new_size; - } - - return ap->swr_buf; + sc_audio_regulator_pull(&ap->audioreg, stream, out_samples); } static bool @@ -141,194 +25,21 @@ sc_audio_player_frame_sink_push(struct sc_frame_sink *sink, const AVFrame *frame) { struct sc_audio_player *ap = DOWNCAST(sink); - SwrContext *swr_ctx = ap->swr_ctx; - - int64_t swr_delay = swr_get_delay(swr_ctx, ap->sample_rate); - // No need to av_rescale_rnd(), input and output sample rates are the same. - // Add more space (256) for clock compensation. - int dst_nb_samples = swr_delay + frame->nb_samples + 256; - - uint8_t *swr_buf = sc_audio_player_get_swr_buf(ap, dst_nb_samples); - if (!swr_buf) { - return false; - } - - int ret = swr_convert(swr_ctx, &swr_buf, dst_nb_samples, - (const uint8_t **) frame->data, frame->nb_samples); - if (ret < 0) { - LOGE("Resampling failed: %d", ret); - return false; - } - - // swr_convert() returns the number of samples which would have been - // written if the buffer was big enough. - uint32_t samples_written = MIN(ret, dst_nb_samples); -#ifndef SC_AUDIO_PLAYER_NDEBUG - LOGD("[Audio] %" PRIu32 " samples written to buffer", samples_written); -#endif - - // Since this function is the only writer, the current available space is - // at least the previous available space. In practice, it should almost - // always be possible to write without lock. - bool lockless_write = samples_written <= ap->previous_can_write; - if (lockless_write) { - sc_audiobuf_prepare_write(&ap->buf, swr_buf, samples_written); - } - - SDL_LockAudioDevice(ap->device); - - uint32_t buffered_samples = sc_audiobuf_can_read(&ap->buf); - - if (lockless_write) { - sc_audiobuf_commit_write(&ap->buf, samples_written); - } else { - uint32_t can_write = sc_audiobuf_can_write(&ap->buf); - if (samples_written > can_write) { - // Entering this branch is very unlikely, the audio buffer is - // allocated with a size sufficient to store 1 second more than the - // target buffering. If this happens, though, we have to skip old - // samples. - uint32_t cap = sc_audiobuf_capacity(&ap->buf); - if (samples_written > cap) { - // Very very unlikely: a single resampled frame should never - // exceed the audio buffer size (or something is very wrong). - // Ignore the first bytes in swr_buf - swr_buf += TO_BYTES(samples_written - cap); - // This change in samples_written will impact the - // instant_compensation below - samples_written = cap; - } - - assert(samples_written >= can_write); - if (samples_written > can_write) { - uint32_t skip_samples = samples_written - can_write; - assert(buffered_samples >= skip_samples); - sc_audiobuf_skip(&ap->buf, skip_samples); - buffered_samples -= skip_samples; - if (ap->played) { - // Dropping input samples instantly decreases buffering - ap->avg_buffering.avg -= skip_samples; - } - } - - // It should remain exactly the expected size to write the new - // samples. - assert(sc_audiobuf_can_write(&ap->buf) == samples_written); - } - - sc_audiobuf_write(&ap->buf, swr_buf, samples_written); - } - - buffered_samples += samples_written; - assert(buffered_samples == sc_audiobuf_can_read(&ap->buf)); - - // Read with lock held, to be used after unlocking - bool played = ap->played; - uint32_t underflow = ap->underflow; - - if (played) { - uint32_t max_buffered_samples = ap->target_buffering - + 12 * ap->output_buffer - + ap->target_buffering / 10; - if (buffered_samples > max_buffered_samples) { - uint32_t skip_samples = buffered_samples - max_buffered_samples; - sc_audiobuf_skip(&ap->buf, skip_samples); - LOGD("[Audio] Buffering threshold exceeded, skipping %" PRIu32 - " samples", skip_samples); - } - - // reset (the current value was copied to a local variable) - ap->underflow = 0; - } else { - // SDL playback not started yet, do not accumulate more than - // max_initial_buffering samples, this would cause unnecessary delay - // (and glitches to compensate) on start. - uint32_t max_initial_buffering = ap->target_buffering - + 2 * ap->output_buffer; - if (buffered_samples > max_initial_buffering) { - uint32_t skip_samples = buffered_samples - max_initial_buffering; - sc_audiobuf_skip(&ap->buf, skip_samples); -#ifndef SC_AUDIO_PLAYER_NDEBUG - LOGD("[Audio] Playback not started, skipping %" PRIu32 " samples", - skip_samples); -#endif - } - } - - ap->previous_can_write = sc_audiobuf_can_write(&ap->buf); - ap->received = true; - - SDL_UnlockAudioDevice(ap->device); - - if (played) { - // Number of samples added (or removed, if negative) for compensation - int32_t instant_compensation = - (int32_t) samples_written - frame->nb_samples; - int32_t inserted_silence = (int32_t) underflow; - - // The compensation must apply instantly, it must not be smoothed - ap->avg_buffering.avg += instant_compensation + inserted_silence; - - - // However, the buffering level must be smoothed - sc_average_push(&ap->avg_buffering, buffered_samples); - -#ifndef SC_AUDIO_PLAYER_NDEBUG - LOGD("[Audio] buffered_samples=%" PRIu32 " avg_buffering=%f", - buffered_samples, sc_average_get(&ap->avg_buffering)); -#endif - - ap->samples_since_resync += samples_written; - if (ap->samples_since_resync >= ap->sample_rate) { - // Recompute compensation every second - ap->samples_since_resync = 0; - - float avg = sc_average_get(&ap->avg_buffering); - int diff = ap->target_buffering - avg; - if (abs(diff) < (int) ap->sample_rate / 1000) { - // Do not compensate for less than 1ms, the error is just noise - diff = 0; - } else if (diff < 0 && buffered_samples < ap->target_buffering) { - // Do not accelerate if the instant buffering level is below - // the average, this would increase underflow - diff = 0; - } - // Compensate the diff over 4 seconds (but will be recomputed after - // 1 second) - int distance = 4 * ap->sample_rate; - // Limit compensation rate to 2% - int abs_max_diff = distance / 50; - diff = CLAMP(diff, -abs_max_diff, abs_max_diff); - LOGV("[Audio] Buffering: target=%" PRIu32 " avg=%f cur=%" PRIu32 - " compensation=%d", ap->target_buffering, avg, - buffered_samples, diff); - - if (diff != ap->compensation) { - int ret = swr_set_compensation(swr_ctx, diff, distance); - if (ret < 0) { - LOGW("Resampling compensation failed: %d", ret); - // not fatal - } else { - ap->compensation = diff; - } - } - } - } - - return true; + return sc_audio_regulator_push(&ap->audioreg, frame); } static bool sc_audio_player_frame_sink_open(struct sc_frame_sink *sink, const AVCodecContext *ctx) { struct sc_audio_player *ap = DOWNCAST(sink); + #ifdef SCRCPY_LAVU_HAS_CHLAYOUT - assert(ctx->ch_layout.nb_channels > 0); - unsigned nb_channels = ctx->ch_layout.nb_channels; + assert(ctx->ch_layout.nb_channels > 0 && ctx->ch_layout.nb_channels < 256); + uint8_t nb_channels = ctx->ch_layout.nb_channels; #else int tmp = av_get_channel_layout_nb_channels(ctx->channel_layout); - assert(tmp > 0); - unsigned nb_channels = tmp; + assert(tmp > 0 && tmp < 256); + uint8_t nb_channels = tmp; #endif assert(ctx->sample_rate > 0); @@ -336,17 +47,19 @@ sc_audio_player_frame_sink_open(struct sc_frame_sink *sink, int out_bytes_per_sample = av_get_bytes_per_sample(SC_AV_SAMPLE_FMT); assert(out_bytes_per_sample > 0); - ap->sample_rate = ctx->sample_rate; - ap->nb_channels = nb_channels; - ap->out_bytes_per_sample = out_bytes_per_sample; + uint32_t target_buffering_samples = + ap->target_buffering_delay * ctx->sample_rate / SC_TICK_FREQ; - ap->target_buffering = ap->target_buffering_delay * ap->sample_rate - / SC_TICK_FREQ; + size_t sample_size = nb_channels * out_bytes_per_sample; + bool ok = sc_audio_regulator_init(&ap->audioreg, sample_size, ctx, + target_buffering_samples); + if (!ok) { + return false; + } - uint64_t aout_samples = ap->output_buffer_duration * ap->sample_rate + uint64_t aout_samples = ap->output_buffer_duration * ctx->sample_rate / SC_TICK_FREQ; assert(aout_samples <= 0xFFFF); - ap->output_buffer = (uint16_t) aout_samples; SDL_AudioSpec desired = { .freq = ctx->sample_rate, @@ -361,70 +74,10 @@ sc_audio_player_frame_sink_open(struct sc_frame_sink *sink, ap->device = SDL_OpenAudioDevice(NULL, 0, &desired, &obtained, 0); if (!ap->device) { LOGE("Could not open audio device: %s", SDL_GetError()); + sc_audio_regulator_destroy(&ap->audioreg); return false; } - SwrContext *swr_ctx = swr_alloc(); - if (!swr_ctx) { - LOG_OOM(); - goto error_close_audio_device; - } - ap->swr_ctx = swr_ctx; - -#ifdef SCRCPY_LAVU_HAS_CHLAYOUT - av_opt_set_chlayout(swr_ctx, "in_chlayout", &ctx->ch_layout, 0); - av_opt_set_chlayout(swr_ctx, "out_chlayout", &ctx->ch_layout, 0); -#else - av_opt_set_channel_layout(swr_ctx, "in_channel_layout", - ctx->channel_layout, 0); - av_opt_set_channel_layout(swr_ctx, "out_channel_layout", - ctx->channel_layout, 0); -#endif - - av_opt_set_int(swr_ctx, "in_sample_rate", ctx->sample_rate, 0); - av_opt_set_int(swr_ctx, "out_sample_rate", ctx->sample_rate, 0); - - av_opt_set_sample_fmt(swr_ctx, "in_sample_fmt", ctx->sample_fmt, 0); - av_opt_set_sample_fmt(swr_ctx, "out_sample_fmt", SC_AV_SAMPLE_FMT, 0); - - int ret = swr_init(swr_ctx); - if (ret) { - LOGE("Failed to initialize the resampling context"); - goto error_free_swr_ctx; - } - - // Use a ring-buffer of the target buffering size plus 1 second between the - // producer and the consumer. It's too big on purpose, to guarantee that - // the producer and the consumer will be able to access it in parallel - // without locking. - size_t audiobuf_samples = ap->target_buffering + ap->sample_rate; - - size_t sample_size = ap->nb_channels * ap->out_bytes_per_sample; - bool ok = sc_audiobuf_init(&ap->buf, sample_size, audiobuf_samples); - if (!ok) { - goto error_free_swr_ctx; - } - - size_t initial_swr_buf_size = TO_BYTES(4096); - ap->swr_buf = malloc(initial_swr_buf_size); - if (!ap->swr_buf) { - LOG_OOM(); - goto error_destroy_audiobuf; - } - ap->swr_buf_alloc_size = initial_swr_buf_size; - - ap->previous_can_write = sc_audiobuf_can_write(&ap->buf); - - // Samples are produced and consumed by blocks, so the buffering must be - // smoothed to get a relatively stable value. - sc_average_init(&ap->avg_buffering, 32); - ap->samples_since_resync = 0; - - ap->received = false; - ap->played = false; - ap->underflow = 0; - ap->compensation = 0; - // The thread calling open() is the thread calling push(), which fills the // audio buffer consumed by the SDL audio thread. ok = sc_thread_set_priority(SC_THREAD_PRIORITY_TIME_CRITICAL); @@ -436,15 +89,6 @@ sc_audio_player_frame_sink_open(struct sc_frame_sink *sink, SDL_PauseAudioDevice(ap->device, 0); return true; - -error_destroy_audiobuf: - sc_audiobuf_destroy(&ap->buf); -error_free_swr_ctx: - swr_free(&ap->swr_ctx); -error_close_audio_device: - SDL_CloseAudioDevice(ap->device); - - return false; } static void @@ -455,9 +99,7 @@ sc_audio_player_frame_sink_close(struct sc_frame_sink *sink) { SDL_PauseAudioDevice(ap->device, 1); SDL_CloseAudioDevice(ap->device); - free(ap->swr_buf); - sc_audiobuf_destroy(&ap->buf); - swr_free(&ap->swr_ctx); + sc_audio_regulator_destroy(&ap->audioreg); } void diff --git a/app/src/audio_player.h b/app/src/audio_player.h index a03e9e35..5a66d43b 100644 --- a/app/src/audio_player.h +++ b/app/src/audio_player.h @@ -3,84 +3,27 @@ #include "common.h" -#include -#include "trait/frame_sink.h" -#include -#include -#include -#include +#include -#include -#include -#include +#include "audio_regulator.h" +#include "trait/frame_sink.h" +#include "util/tick.h" struct sc_audio_player { struct sc_frame_sink frame_sink; - SDL_AudioDeviceID device; - // The target buffering between the producer and the consumer. This value // is directly use for compensation. // Since audio capture and/or encoding on the device typically produce // blocks of 960 samples (20ms) or 1024 samples (~21.3ms), this target // value should be higher. sc_tick target_buffering_delay; - uint32_t target_buffering; // in samples - // SDL audio output buffer size. + // SDL audio output buffer size sc_tick output_buffer_duration; - uint16_t output_buffer; - // Audio buffer to communicate between the receiver and the SDL audio - // callback (protected by SDL_AudioDeviceLock()) - struct sc_audiobuf buf; - - // The previous empty space in the buffer (only used by the receiver - // thread) - uint32_t previous_can_write; - - // Resampler (only used from the receiver thread) - struct SwrContext *swr_ctx; - - // The sample rate is the same for input and output - unsigned sample_rate; - // The number of channels is the same for input and output - unsigned nb_channels; - // The number of bytes per sample for a single channel - unsigned out_bytes_per_sample; - - // Target buffer for resampling (only used by the receiver thread) - uint8_t *swr_buf; - size_t swr_buf_alloc_size; - - // Number of buffered samples (may be negative on underflow) (only used by - // the receiver thread) - struct sc_average avg_buffering; - // Count the number of samples to trigger a compensation update regularly - // (only used by the receiver thread) - uint32_t samples_since_resync; - - // Number of silence samples inserted since the last received packet - // (protected by SDL_AudioDeviceLock()) - uint32_t underflow; - - // Current applied compensation value (only used by the receiver thread) - int compensation; - - // Set to true the first time a sample is received (protected by - // SDL_AudioDeviceLock()) - bool received; - - // Set to true the first time the SDL callback is called (protected by - // SDL_AudioDeviceLock()) - bool played; - - const struct sc_audio_player_callbacks *cbs; - void *cbs_userdata; -}; - -struct sc_audio_player_callbacks { - void (*on_ended)(struct sc_audio_player *ap, bool success, void *userdata); + SDL_AudioDeviceID device; + struct sc_audio_regulator audioreg; }; void diff --git a/app/src/audio_regulator.c b/app/src/audio_regulator.c new file mode 100644 index 00000000..16fdd08b --- /dev/null +++ b/app/src/audio_regulator.c @@ -0,0 +1,456 @@ +#include "audio_regulator.h" + +#include +#include +#include +#include +#include +#include + +#include "util/log.h" + +//#define SC_AUDIO_REGULATOR_DEBUG // uncomment to debug + +/** + * Real-time audio regulator with configurable latency + * + * As input, the regulator regularly receives AVFrames of decoded audio samples. + * As output, the audio player regularly requests audio samples to be played. + * In the middle, an audio buffer stores the samples produced but not consumed + * yet. + * + * The goal of the regulator is to feed the audio player with a latency as low + * as possible while avoiding buffer underrun (i.e. not being able to provide + * samples when requested). + * + * To achieve this, it attempts to maintain the average buffering (the number + * of samples present in the buffer) around a target value. If this target + * buffering is too low, then buffer underrun will occur frequently. If it is + * too high, then latency will become unacceptable. This target value is + * configured using the scrcpy option --audio-buffer. + * + * The regulator cannot adjust the sample input rate (it receives samples + * produced in real-time) or the sample output rate (it must provide samples as + * requested by the audio player). Therefore, it may only apply compensation by + * resampling (converting _m_ input samples to _n_ output samples). + * + * The compensation itself is applied by libswresample (FFmpeg). It is + * configured using swr_set_compensation(). An important work for the regulator + * is to estimate the compensation value regularly and apply it. + * + * The estimated buffering level is the result of averaging the "natural" + * buffering (samples are produced and consumed by blocks, so it must be + * smoothed), and making instant adjustments resulting of its own actions + * (explicit compensation and silence insertion on underflow), which are not + * smoothed. + * + * Buffer underflow events can occur when packets arrive too late. In that case, + * the regulator inserts silence. Once the packets finally arrive (late), one + * strategy could be to drop the samples that were replaced by silence, in + * order to keep a minimal latency. However, dropping samples in case of buffer + * underflow is inadvisable, as it would temporarily increase the underflow + * even more and cause very noticeable audio glitches. + * + * Therefore, the regulator doesn't drop any sample on underflow. The + * compensation mechanism will absorb the delay introduced by the inserted + * silence. + */ + +#define TO_BYTES(SAMPLES) sc_audiobuf_to_bytes(&ar->buf, (SAMPLES)) +#define TO_SAMPLES(BYTES) sc_audiobuf_to_samples(&ar->buf, (BYTES)) + +void +sc_audio_regulator_pull(struct sc_audio_regulator *ar, uint8_t *out, + uint32_t out_samples) { +#ifdef SC_AUDIO_REGULATOR_DEBUG + LOGD("[Audio] Audio regulator pulls %" PRIu32 " samples", out_samples); +#endif + + // A lock is necessary in the rare case where the producer needs to drop + // samples already pushed (when the buffer is full) + sc_mutex_lock(&ar->mutex); + + bool played = atomic_load_explicit(&ar->played, memory_order_relaxed); + if (!played) { + uint32_t buffered_samples = sc_audiobuf_can_read(&ar->buf); + // Wait until the buffer is filled up to at least target_buffering + // before playing + if (buffered_samples < ar->target_buffering) { +#ifdef SC_AUDIO_REGULATOR_DEBUG + LOGD("[Audio] Inserting initial buffering silence: %" PRIu32 + " samples", out_samples); +#endif + // Delay playback starting to reach the target buffering. Fill the + // whole buffer with silence (len is small compared to the + // arbitrary margin value). + memset(out, 0, out_samples * ar->sample_size); + sc_mutex_unlock(&ar->mutex); + return; + } + } + + uint32_t read = sc_audiobuf_read(&ar->buf, out, out_samples); + + sc_mutex_unlock(&ar->mutex); + + if (read < out_samples) { + uint32_t silence = out_samples - read; + // Insert silence. In theory, the inserted silent samples replace the + // missing real samples, which will arrive later, so they should be + // dropped to keep the latency minimal. However, this would cause very + // audible glitches, so let the clock compensation restore the target + // latency. +#ifdef SC_AUDIO_REGULATOR_DEBUG + LOGD("[Audio] Buffer underflow, inserting silence: %" PRIu32 " samples", + silence); +#endif + memset(out + TO_BYTES(read), 0, TO_BYTES(silence)); + + bool received = atomic_load_explicit(&ar->received, + memory_order_relaxed); + if (received) { + // Inserting additional samples immediately increases buffering + atomic_fetch_add_explicit(&ar->underflow, silence, + memory_order_relaxed); + } + } + + atomic_store_explicit(&ar->played, true, memory_order_relaxed); +} + +static uint8_t * +sc_audio_regulator_get_swr_buf(struct sc_audio_regulator *ar, + uint32_t min_samples) { + size_t min_buf_size = TO_BYTES(min_samples); + if (min_buf_size > ar->swr_buf_alloc_size) { + size_t new_size = min_buf_size + 4096; + uint8_t *buf = realloc(ar->swr_buf, new_size); + if (!buf) { + LOG_OOM(); + // Could not realloc to the requested size + return NULL; + } + ar->swr_buf = buf; + ar->swr_buf_alloc_size = new_size; + } + + return ar->swr_buf; +} + +bool +sc_audio_regulator_push(struct sc_audio_regulator *ar, const AVFrame *frame) { + SwrContext *swr_ctx = ar->swr_ctx; + + uint32_t input_samples = frame->nb_samples; + + assert(frame->pts >= 0); + int64_t pts = frame->pts; + if (ar->next_expected_pts && pts - ar->next_expected_pts > 100000) { + LOGV("[Audio] Discontinuity detected: %" PRIi64 "µs", + pts - ar->next_expected_pts); + // More than 100ms: consider it as a discontinuity + // (typically because silence packets were not captured) + uint32_t can_read = sc_audiobuf_can_read(&ar->buf); + if (input_samples + can_read < ar->target_buffering) { + // Adjust buffering to the target value directly + uint32_t silence = ar->target_buffering - can_read - input_samples; + sc_audiobuf_write_silence(&ar->buf, silence); + } + + // Reset state + ar->avg_buffering.avg = ar->target_buffering; + int ret = swr_set_compensation(swr_ctx, 0, 0); + (void) ret; + assert(!ret); // disabling compensation should never fail + ar->compensation_active = false; + ar->samples_since_resync = 0; + atomic_store_explicit(&ar->underflow, 0, memory_order_relaxed); + } + + int64_t packet_duration = input_samples * INT64_C(1000000) + / ar->sample_rate; + ar->next_expected_pts = pts + packet_duration; + + int64_t swr_delay = swr_get_delay(swr_ctx, ar->sample_rate); + // No need to av_rescale_rnd(), input and output sample rates are the same. + // Add more space (256) for clock compensation. + int dst_nb_samples = swr_delay + frame->nb_samples + 256; + + uint8_t *swr_buf = sc_audio_regulator_get_swr_buf(ar, dst_nb_samples); + if (!swr_buf) { + return false; + } + + int ret = swr_convert(swr_ctx, &swr_buf, dst_nb_samples, + (const uint8_t **) frame->data, frame->nb_samples); + if (ret < 0) { + LOGE("Resampling failed: %d", ret); + return false; + } + + // swr_convert() returns the number of samples which would have been + // written if the buffer was big enough. + uint32_t samples = MIN(ret, dst_nb_samples); +#ifdef SC_AUDIO_REGULATOR_DEBUG + LOGD("[Audio] %" PRIu32 " samples written to buffer", samples); +#endif + + uint32_t cap = sc_audiobuf_capacity(&ar->buf); + if (samples > cap) { + // Very very unlikely: a single resampled frame should never + // exceed the audio buffer size (or something is very wrong). + // Ignore the first bytes in swr_buf to avoid memory corruption anyway. + swr_buf += TO_BYTES(samples - cap); + samples = cap; + } + + uint32_t skipped_samples = 0; + + uint32_t written = sc_audiobuf_write(&ar->buf, swr_buf, samples); + if (written < samples) { + uint32_t remaining = samples - written; + + // All samples that could be written without locking have been written, + // now we need to lock to drop/consume old samples + sc_mutex_lock(&ar->mutex); + + // Retry with the lock + written += sc_audiobuf_write(&ar->buf, + swr_buf + TO_BYTES(written), + remaining); + if (written < samples) { + remaining = samples - written; + // Still insufficient, drop old samples to make space + skipped_samples = sc_audiobuf_read(&ar->buf, NULL, remaining); + assert(skipped_samples == remaining); + } + + sc_mutex_unlock(&ar->mutex); + + if (written < samples) { + // Now there is enough space + uint32_t w = sc_audiobuf_write(&ar->buf, + swr_buf + TO_BYTES(written), + remaining); + assert(w == remaining); + (void) w; + } + } + + uint32_t underflow = 0; + uint32_t max_buffered_samples; + bool played = atomic_load_explicit(&ar->played, memory_order_relaxed); + if (played) { + underflow = atomic_exchange_explicit(&ar->underflow, 0, + memory_order_relaxed); + ar->underflow_report += underflow; + + max_buffered_samples = ar->target_buffering * 11 / 10 + + 60 * ar->sample_rate / 1000 /* 60 ms */; + } else { + // Playback not started yet, do not accumulate more than + // max_initial_buffering samples, this would cause unnecessary delay + // (and glitches to compensate) on start. + max_buffered_samples = ar->target_buffering + + 10 * ar->sample_rate / 1000 /* 10 ms */; + } + + uint32_t can_read = sc_audiobuf_can_read(&ar->buf); + if (can_read > max_buffered_samples) { + uint32_t skip_samples = 0; + + sc_mutex_lock(&ar->mutex); + can_read = sc_audiobuf_can_read(&ar->buf); + if (can_read > max_buffered_samples) { + skip_samples = can_read - max_buffered_samples; + uint32_t r = sc_audiobuf_read(&ar->buf, NULL, skip_samples); + assert(r == skip_samples); + (void) r; + skipped_samples += skip_samples; + } + sc_mutex_unlock(&ar->mutex); + + if (skip_samples) { + if (played) { + LOGD("[Audio] Buffering threshold exceeded, skipping %" PRIu32 + " samples", skip_samples); +#ifdef SC_AUDIO_REGULATOR_DEBUG + } else { + LOGD("[Audio] Playback not started, skipping %" PRIu32 + " samples", skip_samples); +#endif + } + } + } + + atomic_store_explicit(&ar->received, true, memory_order_relaxed); + if (!played) { + // Nothing more to do + return true; + } + + // Number of samples added (or removed, if negative) for compensation + int32_t instant_compensation = (int32_t) written - input_samples; + // Inserting silence instantly increases buffering + int32_t inserted_silence = (int32_t) underflow; + // Dropping input samples instantly decreases buffering + int32_t dropped = (int32_t) skipped_samples; + + // The compensation must apply instantly, it must not be smoothed + ar->avg_buffering.avg += instant_compensation + inserted_silence - dropped; + if (ar->avg_buffering.avg < 0) { + // Since dropping samples instantly reduces buffering, the difference + // is applied immediately to the average value, assuming that the delay + // between the producer and the consumer will be caught up. + // + // However, when this assumption is not valid, the average buffering + // may decrease indefinitely. Prevent it to become negative to limit + // the consequences. + ar->avg_buffering.avg = 0; + } + + // However, the buffering level must be smoothed + sc_average_push(&ar->avg_buffering, can_read); + +#ifdef SC_AUDIO_REGULATOR_DEBUG + LOGD("[Audio] can_read=%" PRIu32 " avg_buffering=%f", + can_read, sc_average_get(&ar->avg_buffering)); +#endif + + ar->samples_since_resync += written; + if (ar->samples_since_resync >= ar->sample_rate) { + // Recompute compensation every second + ar->samples_since_resync = 0; + + float avg = sc_average_get(&ar->avg_buffering); + int diff = ar->target_buffering - avg; + + // Enable compensation when the difference exceeds +/- 4ms. + // Disable compensation when the difference is lower than +/- 1ms. + int threshold = ar->compensation_active + ? ar->sample_rate / 1000 /* 1ms */ + : ar->sample_rate * 4 / 1000; /* 4ms */ + + if (abs(diff) < threshold) { + // Do not compensate for small values, the error is just noise + diff = 0; + } else if (diff < 0 && can_read < ar->target_buffering) { + // Do not accelerate if the instant buffering level is below the + // target, this would increase underflow + diff = 0; + } + // Compensate the diff over 4 seconds (but will be recomputed after 1 + // second) + int distance = 4 * ar->sample_rate; + // Limit compensation rate to 2% + int abs_max_diff = distance / 50; + diff = CLAMP(diff, -abs_max_diff, abs_max_diff); + LOGV("[Audio] Buffering: target=%" PRIu32 " avg=%f cur=%" PRIu32 + " compensation=%d (underflow=%" PRIu32 ")", + ar->target_buffering, avg, can_read, diff, ar->underflow_report); + ar->underflow_report = 0; + + 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; + } + } + + return true; +} + +bool +sc_audio_regulator_init(struct sc_audio_regulator *ar, size_t sample_size, + const AVCodecContext *ctx, uint32_t target_buffering) { + SwrContext *swr_ctx = swr_alloc(); + if (!swr_ctx) { + LOG_OOM(); + return false; + } + ar->swr_ctx = swr_ctx; + +#ifdef SCRCPY_LAVU_HAS_CHLAYOUT + av_opt_set_chlayout(swr_ctx, "in_chlayout", &ctx->ch_layout, 0); + av_opt_set_chlayout(swr_ctx, "out_chlayout", &ctx->ch_layout, 0); +#else + av_opt_set_channel_layout(swr_ctx, "in_channel_layout", + ctx->channel_layout, 0); + av_opt_set_channel_layout(swr_ctx, "out_channel_layout", + ctx->channel_layout, 0); +#endif + + av_opt_set_int(swr_ctx, "in_sample_rate", ctx->sample_rate, 0); + av_opt_set_int(swr_ctx, "out_sample_rate", ctx->sample_rate, 0); + + av_opt_set_sample_fmt(swr_ctx, "in_sample_fmt", ctx->sample_fmt, 0); + av_opt_set_sample_fmt(swr_ctx, "out_sample_fmt", SC_AV_SAMPLE_FMT, 0); + + int ret = swr_init(swr_ctx); + if (ret) { + LOGE("Failed to initialize the resampling context"); + goto error_free_swr_ctx; + } + + bool ok = sc_mutex_init(&ar->mutex); + if (!ok) { + goto error_free_swr_ctx; + } + + ar->target_buffering = target_buffering; + ar->sample_size = sample_size; + ar->sample_rate = ctx->sample_rate; + + // Use a ring-buffer of the target buffering size plus 1 second between the + // producer and the consumer. It's too big on purpose, to guarantee that + // the producer and the consumer will be able to access it in parallel + // without locking. + uint32_t audiobuf_samples = target_buffering + ar->sample_rate; + + ok = sc_audiobuf_init(&ar->buf, sample_size, audiobuf_samples); + if (!ok) { + goto error_destroy_mutex; + } + + size_t initial_swr_buf_size = TO_BYTES(4096); + ar->swr_buf = malloc(initial_swr_buf_size); + if (!ar->swr_buf) { + LOG_OOM(); + goto error_destroy_audiobuf; + } + ar->swr_buf_alloc_size = initial_swr_buf_size; + + // Samples are produced and consumed by blocks, so the buffering must be + // smoothed to get a relatively stable value. + sc_average_init(&ar->avg_buffering, 128); + ar->samples_since_resync = 0; + + ar->received = false; + atomic_init(&ar->played, false); + atomic_init(&ar->received, false); + atomic_init(&ar->underflow, 0); + ar->underflow_report = 0; + ar->compensation_active = false; + ar->next_expected_pts = 0; + + return true; + +error_destroy_audiobuf: + sc_audiobuf_destroy(&ar->buf); +error_destroy_mutex: + sc_mutex_destroy(&ar->mutex); +error_free_swr_ctx: + swr_free(&ar->swr_ctx); + + return false; +} + +void +sc_audio_regulator_destroy(struct sc_audio_regulator *ar) { + free(ar->swr_buf); + sc_audiobuf_destroy(&ar->buf); + sc_mutex_destroy(&ar->mutex); + swr_free(&ar->swr_ctx); +} diff --git a/app/src/audio_regulator.h b/app/src/audio_regulator.h new file mode 100644 index 00000000..4e18fe08 --- /dev/null +++ b/app/src/audio_regulator.h @@ -0,0 +1,79 @@ +#ifndef SC_AUDIO_REGULATOR_H +#define SC_AUDIO_REGULATOR_H + +#include "common.h" + +#include +#include +#include +#include +#include +#include +#include "util/audiobuf.h" +#include "util/average.h" +#include "util/thread.h" + +#define SC_AV_SAMPLE_FMT AV_SAMPLE_FMT_FLT + +struct sc_audio_regulator { + sc_mutex mutex; + + // Target buffering between the producer and the consumer (in samples) + uint32_t target_buffering; + + // Audio buffer to communicate between the receiver and the player + struct sc_audiobuf buf; + + // Resampler (only used from the receiver thread) + struct SwrContext *swr_ctx; + + // The sample rate is the same for input and output + uint32_t sample_rate; + // The number of bytes per sample (for all channels) + size_t sample_size; + + // Target buffer for resampling (only used by the receiver thread) + uint8_t *swr_buf; + size_t swr_buf_alloc_size; + + // Number of buffered samples (may be negative on underflow) (only used by + // the receiver thread) + struct sc_average avg_buffering; + // Count the number of samples to trigger a compensation update regularly + // (only used by the receiver thread) + uint32_t samples_since_resync; + + // Number of silence samples inserted since the last received packet + atomic_uint_least32_t underflow; + + // Number of silence samples inserted since the last log + uint32_t underflow_report; + + // 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; + + // Set to true the first time samples are pulled by the player + atomic_bool played; + + // PTS of the next expected packet (useful to detect discontinuities) + int64_t next_expected_pts; +}; + +bool +sc_audio_regulator_init(struct sc_audio_regulator *ar, size_t sample_size, + const AVCodecContext *ctx, uint32_t target_buffering); + +void +sc_audio_regulator_destroy(struct sc_audio_regulator *ar); + +bool +sc_audio_regulator_push(struct sc_audio_regulator *ar, const AVFrame *frame); + +void +sc_audio_regulator_pull(struct sc_audio_regulator *ar, uint8_t *out, + uint32_t samples); + +#endif diff --git a/app/src/cli.c b/app/src/cli.c index f57b75ef..b2e3e30a 100644 --- a/app/src/cli.c +++ b/app/src/cli.c @@ -5,6 +5,7 @@ #include #include #include +#include #include #include "options.h" @@ -13,6 +14,7 @@ #include "util/str.h" #include "util/strbuf.h" #include "util/term.h" +#include "util/tick.h" #define STR_IMPL_(x) #x #define STR(x) STR_IMPL_(x) @@ -50,6 +52,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, @@ -93,6 +96,24 @@ enum { OPT_DISPLAY_ORIENTATION, OPT_RECORD_ORIENTATION, OPT_ORIENTATION, + OPT_KEYBOARD, + OPT_MOUSE, + OPT_HID_KEYBOARD_DEPRECATED, + OPT_HID_MOUSE_DEPRECATED, + OPT_NO_WINDOW, + OPT_MOUSE_BIND, + OPT_NO_MOUSE_HOVER, + OPT_AUDIO_DUP, + OPT_GAMEPAD, + OPT_NEW_DISPLAY, + OPT_LIST_APPS, + OPT_START_APP, + OPT_SCREEN_OFF_TIMEOUT, + OPT_CAPTURE_ORIENTATION, + OPT_ANGLE, + OPT_NO_VD_SYSTEM_DECORATIONS, + OPT_NO_VD_DESTROY_CONTENT, + OPT_DISPLAY_IME_POLICY, }; struct sc_option { @@ -134,6 +155,13 @@ static const struct sc_option options[] = { .longopt = "always-on-top", .text = "Make scrcpy window always on top (above other windows).", }, + { + .longopt_id = OPT_ANGLE, + .longopt = "angle", + .argdesc = "degrees", + .text = "Rotate the video content by a custom angle, in degrees " + "(clockwise).", + }, { .longopt_id = OPT_AUDIO_BIT_RATE, .longopt = "audio-bit-rate", @@ -148,7 +176,7 @@ static const struct sc_option options[] = { .argdesc = "ms", .text = "Configure the audio buffering delay (in milliseconds).\n" "Lower values decrease the latency, but increase the " - "likelyhood of buffer underrun (causing audio glitches).\n" + "likelihood of buffer underrun (causing audio glitches).\n" "Default is 50.", }, { @@ -170,6 +198,13 @@ static const struct sc_option options[] = { "Android documentation: " "", }, + { + .longopt_id = OPT_AUDIO_DUP, + .longopt = "audio-dup", + .text = "Duplicate audio (capture and keep playing on the device).\n" + "This feature is only available with --audio-source=playback." + + }, { .longopt_id = OPT_AUDIO_ENCODER, .longopt = "audio-encoder", @@ -182,7 +217,31 @@ static const struct sc_option options[] = { .longopt_id = OPT_AUDIO_SOURCE, .longopt = "audio-source", .argdesc = "source", - .text = "Select the audio source (output or mic).\n" + .text = "Select the audio source. Possible values are:\n" + " - \"output\": forwards the whole audio output, and disables " + "playback on the device.\n" + " - \"playback\": captures the audio playback (Android apps " + "can opt-out, so the whole output is not necessarily " + "captured).\n" + " - \"mic\": captures the microphone.\n" + " - \"mic-unprocessed\": captures the microphone unprocessed " + "(raw) sound.\n" + " - \"mic-camcorder\": captures the microphone tuned for video " + "recording, with the same orientation as the camera if " + "available.\n" + " - \"mic-voice-recognition\": captures the microphone tuned " + "for voice recognition.\n" + " - \"mic-voice-communication\": captures the microphone tuned " + "for voice communications (it will for instance take advantage " + "of echo cancellation or automatic gain control if " + "available).\n" + " - \"voice-call\": captures voice call.\n" + " - \"voice-call-uplink\": captures voice call uplink only.\n" + " - \"voice-call-downlink\": captures voice call downlink " + "only.\n" + " - \"voice-performance\": captures audio meant to be " + "processed for live performance (karaoke), includes both the " + "microphone and the device playback.\n" "Default is output.", }, { @@ -218,14 +277,6 @@ static const struct sc_option options[] = { "ratio), \":\" (e.g. \"4:3\") or \"\" (e.g. " "\"1.6\")." }, - { - .longopt_id = OPT_CAMERA_ID, - .longopt = "camera-id", - .argdesc = "id", - .text = "Specify the device camera id to mirror.\n" - "The available camera ids can be listed by:\n" - " scrcpy --list-cameras", - }, { .longopt_id = OPT_CAMERA_FACING, .longopt = "camera-facing", @@ -233,6 +284,14 @@ static const struct sc_option options[] = { .text = "Select the device camera by its facing direction.\n" "Possible values are \"front\", \"back\" and \"external\".", }, + { + .longopt_id = OPT_CAMERA_FPS, + .longopt = "camera-fps", + .argdesc = "value", + .text = "Specify the camera capture frame rate.\n" + "If not specified, Android's default frame rate (30 fps) is " + "used.", + }, { .longopt_id = OPT_CAMERA_HIGH_SPEED, .longopt = "camera-high-speed", @@ -240,6 +299,14 @@ static const struct sc_option options[] = { "This mode is restricted to specific resolutions and frame " "rates, listed by --list-camera-sizes.", }, + { + .longopt_id = OPT_CAMERA_ID, + .longopt = "camera-id", + .argdesc = "id", + .text = "Specify the device camera id to mirror.\n" + "The available camera ids can be listed by:\n" + " scrcpy --list-cameras", + }, { .longopt_id = OPT_CAMERA_SIZE, .longopt = "camera-size", @@ -247,12 +314,21 @@ static const struct sc_option options[] = { .text = "Specify an explicit camera capture size.", }, { - .longopt_id = OPT_CAMERA_FPS, - .longopt = "camera-fps", + .longopt_id = OPT_CAPTURE_ORIENTATION, + .longopt = "capture-orientation", .argdesc = "value", - .text = "Specify the camera capture frame rate.\n" - "If not specified, Android's default frame rate (30 fps) is " - "used.", + .text = "Set the capture video orientation.\n" + "Possible values are 0, 90, 180, 270, flip0, flip90, flip180 " + "and flip270, possibly prefixed by '@'.\n" + "The number represents the clockwise rotation in degrees; the " + "flip\" keyword applies a horizontal flip before the " + "rotation.\n" + "If a leading '@' is passed (@90) for display capture, then " + "the rotation is locked, and is relative to the natural device " + "orientation.\n" + "If '@' is passed alone, then the rotation is locked to the " + "initial device orientation.\n" + "Default is 0.", }, { // Not really deprecated (--codec has never been released), but without @@ -275,8 +351,7 @@ static const struct sc_option options[] = { .argdesc = "width:height:x:y", .text = "Crop the device screen on the server.\n" "The values are expressed in the device natural orientation " - "(typically, portrait for a phone, landscape for a tablet). " - "Any --max-size value is computed on the cropped size.", + "(typically, portrait for a phone, landscape for a tablet).", }, { .shortopt = 'd', @@ -296,12 +371,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, @@ -312,6 +385,19 @@ static const struct sc_option options[] = { " scrcpy --list-displays\n" "Default is 0.", }, + { + .longopt_id = OPT_DISPLAY_IME_POLICY, + .longopt = "display-ime-policy", + .argdesc = "value", + .text = "Set the policy for selecting where the IME should be " + "displayed.\n" + "Possible values are \"local\", \"fallback\" and \"hide\".\n" + "\"local\" means that the IME should appear on the local " + "display.\n" + "\"fallback\" means that the IME should appear on a fallback " + "display (the default display).\n" + "\"hide\" means that the IME should be hidden.", + }, { .longopt_id = OPT_DISPLAY_ORIENTATION, .longopt = "display-orientation", @@ -347,38 +433,70 @@ static const struct sc_option options[] = { "device.", }, { + // deprecated .longopt_id = OPT_FORWARD_ALL_CLICKS, .longopt = "forward-all-clicks", - .text = "By default, right-click triggers BACK (or POWER on) and " - "middle-click triggers HOME. This option disables these " - "shortcuts and forwards the clicks to the device instead.", + }, + { + .shortopt = 'G', + .text = "Same as --gamepad=uhid, or --gamepad=aoa if --otg is set.", + }, + { + .longopt_id = OPT_GAMEPAD, + .longopt = "gamepad", + .argdesc = "mode", + .text = "Select how to send gamepad inputs to the device.\n" + "Possible values are \"disabled\", \"uhid\" and \"aoa\".\n" + "\"disabled\" does not send gamepad inputs to the device.\n" + "\"uhid\" simulates physical HID gamepads using the Linux UHID " + "kernel module on the device.\n" + "\"aoa\" simulates physical gamepads using the AOAv2 protocol." + "It may only work over USB.\n" + "Also see --keyboard and --mouse.", }, { .shortopt = 'h', .longopt = "help", .text = "Print this help.", }, + { + .shortopt = 'K', + .text = "Same as --keyboard=uhid, or --keyboard=aoa if --otg is set.", + }, + { + .longopt_id = OPT_KEYBOARD, + .longopt = "keyboard", + .argdesc = "mode", + .text = "Select how to send keyboard inputs to the device.\n" + "Possible values are \"disabled\", \"sdk\", \"uhid\" and " + "\"aoa\".\n" + "\"disabled\" does not send keyboard inputs to the device.\n" + "\"sdk\" uses the Android system API to deliver keyboard " + "events to applications.\n" + "\"uhid\" simulates a physical HID keyboard using the Linux " + "UHID kernel module on the device.\n" + "\"aoa\" simulates a physical keyboard using the AOAv2 " + "protocol. It may only work over USB.\n" + "For \"uhid\" and \"aoa\", the keyboard layout must be " + "configured (once and for all) on the device, via Settings -> " + "System -> Languages and input -> Physical keyboard. This " + "settings page can be started directly using the shortcut " + "MOD+k (except in OTG mode) or by executing: `adb shell am " + "start -a android.settings.HARD_KEYBOARD_SETTINGS`.\n" + "This option is only available when a HID keyboard is enabled " + "(or a physical keyboard is connected).\n" + "Also see --mouse and --gamepad.", + }, { .longopt_id = OPT_KILL_ADB_ON_CLOSE, .longopt = "kill-adb-on-close", .text = "Kill adb when scrcpy terminates.", }, { - .shortopt = 'K', + // deprecated + //.shortopt = 'K', // old, reassigned + .longopt_id = OPT_HID_KEYBOARD_DEPRECATED, .longopt = "hid-keyboard", - .text = "Simulate a physical keyboard by using HID over AOAv2.\n" - "It provides a better experience for IME users, and allows to " - "generate non-ASCII characters, contrary to the default " - "injection method.\n" - "It may only work over USB.\n" - "The keyboard layout must be configured (once and for all) on " - "the device, via Settings -> System -> Languages and input -> " - "Physical keyboard. This settings page can be started " - "directly: `adb shell am start -a " - "android.settings.HARD_KEYBOARD_SETTINGS`.\n" - "However, the option is only available when the HID keyboard " - "is enabled (or a physical keyboard is connected).\n" - "Also see --hid-mouse.", }, { .longopt_id = OPT_LEGACY_PASTE, @@ -388,6 +506,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", @@ -409,18 +532,10 @@ static const struct sc_option options[] = { .text = "List video and audio encoders available on the device.", }, { + // deprecated .longopt_id = OPT_LOCK_VIDEO_ORIENTATION, .longopt = "lock-video-orientation", .argdesc = "value", - .optional_arg = true, - .text = "Lock capture video orientation to value.\n" - "Possible values are \"unlocked\", \"initial\" (locked to the " - "initial orientation), 0, 90, 180 and 270. The values " - "represent the clockwise rotation from the natural device " - "orientation, in degrees.\n" - "Default is \"unlocked\".\n" - "Passing the option without argument is equivalent to passing " - "\"initial\".", }, { .shortopt = 'm', @@ -432,15 +547,14 @@ static const struct sc_option options[] = { "Default is 0 (unlimited).", }, { - .shortopt = 'M', + // deprecated + //.shortopt = 'M', // old, reassigned + .longopt_id = OPT_HID_MOUSE_DEPRECATED, .longopt = "hid-mouse", - .text = "Simulate a physical mouse by using HID over AOAv2.\n" - "In this mode, the computer mouse is captured to control the " - "device directly (relative mouse mode).\n" - "LAlt, LSuper or RSuper toggle the capture mode, to give " - "control of the mouse back to the computer.\n" - "It may only work over USB.\n" - "Also see --hid-keyboard.", + }, + { + .shortopt = 'M', + .text = "Same as --mouse=uhid, or --mouse=aoa if --otg is set.", }, { .longopt_id = OPT_MAX_FPS, @@ -449,6 +563,50 @@ static const struct sc_option options[] = { .text = "Limit the frame rate of screen capture (officially supported " "since Android 10, but may work on earlier versions).", }, + { + .longopt_id = OPT_MOUSE, + .longopt = "mouse", + .argdesc = "mode", + .text = "Select how to send mouse inputs to the device.\n" + "Possible values are \"disabled\", \"sdk\", \"uhid\" and " + "\"aoa\".\n" + "\"disabled\" does not send mouse inputs to the device.\n" + "\"sdk\" uses the Android system API to deliver mouse events" + "to applications.\n" + "\"uhid\" simulates a physical HID mouse using the Linux UHID " + "kernel module on the device.\n" + "\"aoa\" simulates a physical mouse using the AOAv2 protocol. " + "It may only work over USB.\n" + "In \"uhid\" and \"aoa\" modes, the computer mouse is captured " + "to control the device directly (relative mouse mode).\n" + "LAlt, LSuper or RSuper toggle the capture mode, to give " + "control of the mouse back to the computer.\n" + "Also see --keyboard and --gamepad.", + }, + { + .longopt_id = OPT_MOUSE_BIND, + .longopt = "mouse-bind", + .argdesc = "xxxx[:xxxx]", + .text = "Configure bindings of secondary clicks.\n" + "The argument must be one or two sequences (separated by ':') " + "of exactly 4 characters, one for each secondary click (in " + "order: right click, middle click, 4th click, 5th click).\n" + "The first sequence defines the primary bindings, used when a " + "mouse button is pressed alone. The second sequence defines " + "the secondary bindings, used when a mouse button is pressed " + "while the Shift key is held.\n" + "If the second sequence of bindings is omitted, then it is the " + "same as the first one.\n" + "Each character must be one of the following:\n" + " '+': forward the click to the device\n" + " '-': ignore the click\n" + " 'b': trigger shortcut BACK (or turn screen on if off)\n" + " 'h': trigger shortcut HOME\n" + " 's': trigger shortcut APP_SWITCH\n" + " 'n': trigger shortcut \"expand notification panel\"\n" + "Default is 'bhsn:++++' for SDK mouse, and '++++:bhsn' for AOA " + "and UHID.", + }, { .shortopt = 'n', .longopt = "no-control", @@ -460,6 +618,20 @@ 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 = "[x][/]", + .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.\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=/240 # main display size and 240 dpi", + }, { .longopt_id = OPT_NO_AUDIO, .longopt = "no-audio", @@ -511,11 +683,31 @@ static const struct sc_option options[] = { "mipmaps are automatically generated to improve downscaling " "quality. This option disables the generation of mipmaps.", }, + { + .longopt_id = OPT_NO_MOUSE_HOVER, + .longopt = "no-mouse-hover", + .text = "Do not forward mouse hover (mouse motion without any clicks) " + "events.", + }, { .longopt_id = OPT_NO_POWER_ON, .longopt = "no-power-on", .text = "Do not power on the device on start.", }, + { + .longopt_id = OPT_NO_VD_DESTROY_CONTENT, + .longopt = "no-vd-destroy-content", + .text = "Disable virtual display \"destroy content on removal\" " + "flag.\n" + "With this option, when the virtual display is closed, the " + "running apps are moved to the main display rather than being " + "destroyed.", + }, + { + .longopt_id = OPT_NO_VD_SYSTEM_DECORATIONS, + .longopt = "no-vd-system-decorations", + .text = "Disable virtual display system decorations flag.", + }, { .longopt_id = OPT_NO_VIDEO, .longopt = "no-video", @@ -526,6 +718,11 @@ static const struct sc_option options[] = { .longopt = "no-video-playback", .text = "Disable video playback on the computer.", }, + { + .longopt_id = OPT_NO_WINDOW, + .longopt = "no-window", + .text = "Disable scrcpy window. Implies --no-video-playback.", + }, { .longopt_id = OPT_ORIENTATION, .longopt = "orientation", @@ -543,10 +740,10 @@ static const struct sc_option options[] = { "mirroring is disabled.\n" "LAlt, LSuper or RSuper toggle the mouse capture mode, to give " "control of the mouse back to the computer.\n" - "If any of --hid-keyboard or --hid-mouse is set, only enable " - "keyboard or mouse respectively, otherwise enable both.\n" + "Keyboard and mouse may be disabled separately using" + "--keyboard=disabled and --mouse=disabled.\n" "It may only work over USB.\n" - "See --hid-keyboard and --hid-mouse.", + "See --keyboard, --mouse and --gamepad.", }, { .shortopt = 'p', @@ -563,7 +760,7 @@ static const struct sc_option options[] = { .optional_arg = true, .text = "Configure pause on exit. Possible values are \"true\" (always " "pause on exit), \"false\" (never pause on exit) and " - "\"if-error\" (pause only if an error occured).\n" + "\"if-error\" (pause only if an error occurred).\n" "This is useful to prevent the terminal window from " "automatically closing, so that error messages can be read.\n" "Default is \"false\".\n" @@ -662,6 +859,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", @@ -669,12 +873,26 @@ static const struct sc_option options[] = { .text = "Specify the modifiers to use for scrcpy shortcuts.\n" "Possible keys are \"lctrl\", \"rctrl\", \"lalt\", \"ralt\", " "\"lsuper\" and \"rsuper\".\n" - "A shortcut can consist in several keys, separated by '+'. " - "Several shortcuts can be specified, separated by ','.\n" - "For example, to use either LCtrl+LAlt or LSuper for scrcpy " - "shortcuts, pass \"lctrl+lalt,lsuper\".\n" + "Several shortcut modifiers can be specified, separated by " + "','.\n" + "For example, to use either LCtrl or LSuper for scrcpy " + "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", @@ -685,16 +903,17 @@ static const struct sc_option options[] = { { .longopt_id = OPT_TCPIP, .longopt = "tcpip", - .argdesc = "ip[:port]", + .argdesc = "[+]ip[:port]", .optional_arg = true, - .text = "Configure and reconnect the device over TCP/IP.\n" + .text = "Configure and connect the device over TCP/IP.\n" "If a destination address is provided, then scrcpy connects to " "this address before starting. The device must listen on the " "given TCP port (default is 5555).\n" "If no destination address is provided, then scrcpy attempts " "to find the IP address of the current device (typically " "connected over USB), enables TCP/IP mode, then connects to " - "this address before starting.", + "this address before starting.\n" + "Prefix the address with a '+' to force a reconnection.", }, { .longopt_id = OPT_TIME_LIMIT, @@ -742,8 +961,6 @@ static const struct sc_option options[] = { .longopt = "v4l2-sink", .argdesc = "/dev/videoN", .text = "Output to v4l2loopback device.\n" - "It requires to lock the video orientation (see " - "--lock-video-orientation).\n" "This feature is only available on Linux.", }, { @@ -752,11 +969,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", @@ -860,6 +1086,18 @@ static const struct sc_shortcut shortcuts[] = { .shortcuts = { "MOD+Shift+Up", "MOD+Shift+Down" }, .text = "Flip display vertically", }, + { + .shortcuts = { "MOD+z" }, + .text = "Pause or re-pause display", + }, + { + .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)", @@ -941,13 +1179,25 @@ static const struct sc_shortcut shortcuts[] = { .shortcuts = { "MOD+Shift+v" }, .text = "Inject computer clipboard text as a sequence of key events", }, + { + .shortcuts = { "MOD+k" }, + .text = "Open keyboard settings on the device (for HID keyboard only)", + }, { .shortcuts = { "MOD+i" }, .text = "Enable/disable FPS counter (print frames/second in logs)", }, { .shortcuts = { "Ctrl+click-and-move" }, - .text = "Pinch-to-zoom from the center of the screen", + .text = "Pinch-to-zoom and rotate from the center of the screen", + }, + { + .shortcuts = { "Shift+click-and-move" }, + .text = "Tilt vertically (slide with 2 fingers)", + }, + { + .shortcuts = { "Ctrl+Shift+click-and-move" }, + .text = "Tilt horizontally (slide with 2 fingers)", }, { .shortcuts = { "Drag & drop APK file" }, @@ -1242,7 +1492,7 @@ print_exit_status(const struct sc_exit_status *status, unsigned cols) { return; } - assert(strlen(text) >= 9); // Contains at least the initial identation + assert(strlen(text) >= 9); // Contains at least the initial indentation // text + 9 to remove the initial indentation printf(" %3d %s\n", status->value, text + 9); @@ -1366,22 +1616,14 @@ parse_max_size(const char *s, uint16_t *max_size) { return true; } -static bool -parse_max_fps(const char *s, uint16_t *max_fps) { - long value; - bool ok = parse_integer_arg(s, &value, false, 0, 0xFFFF, "max fps"); - if (!ok) { - return false; - } - - *max_fps = (uint16_t) value; - return true; -} - static bool parse_buffering_time(const char *s, sc_tick *tick) { long value; - bool ok = parse_integer_arg(s, &value, false, 0, 0x7FFFFFFF, + // In practice, buffering time should not exceed a few seconds. + // Limit it to some arbitrary value (1 hour) to prevent 32-bit overflow + // when multiplied by the audio sample size and the number of samples per + // millisecond. + bool ok = parse_integer_arg(s, &value, false, 0, 60 * 60 * 1000, "buffering time"); if (!ok) { return false; @@ -1405,77 +1647,24 @@ parse_audio_output_buffer(const char *s, sc_tick *tick) { } static bool -parse_lock_video_orientation(const char *s, - enum sc_lock_video_orientation *lock_mode) { - if (!s || !strcmp(s, "initial")) { - // Without argument, lock the initial orientation - *lock_mode = SC_LOCK_VIDEO_ORIENTATION_INITIAL; +parse_display_ime_policy(const char *s, enum sc_display_ime_policy *policy) { + if (!strcmp(s, "local")) { + *policy = SC_DISPLAY_IME_POLICY_LOCAL; return true; } - - if (!strcmp(s, "unlocked")) { - *lock_mode = SC_LOCK_VIDEO_ORIENTATION_UNLOCKED; + if (!strcmp(s, "fallback")) { + *policy = SC_DISPLAY_IME_POLICY_FALLBACK; return true; } - - if (!strcmp(s, "0")) { - *lock_mode = SC_LOCK_VIDEO_ORIENTATION_0; + if (!strcmp(s, "hide")) { + *policy = SC_DISPLAY_IME_POLICY_HIDE; return true; } - - if (!strcmp(s, "90")) { - *lock_mode = SC_LOCK_VIDEO_ORIENTATION_90; - return true; - } - - if (!strcmp(s, "180")) { - *lock_mode = SC_LOCK_VIDEO_ORIENTATION_180; - return true; - } - - if (!strcmp(s, "270")) { - *lock_mode = SC_LOCK_VIDEO_ORIENTATION_270; - return true; - } - - if (!strcmp(s, "1")) { - LOGW("--lock-video-orientation=1 is deprecated, use " - "--lock-video-orientation=270 instead."); - *lock_mode = SC_LOCK_VIDEO_ORIENTATION_270; - return true; - } - - if (!strcmp(s, "2")) { - LOGW("--lock-video-orientation=2 is deprecated, use " - "--lock-video-orientation=180 instead."); - *lock_mode = SC_LOCK_VIDEO_ORIENTATION_180; - return true; - } - - if (!strcmp(s, "3")) { - LOGW("--lock-video-orientation=3 is deprecated, use " - "--lock-video-orientation=90 instead."); - *lock_mode = SC_LOCK_VIDEO_ORIENTATION_90; - return true; - } - - LOGE("Unsupported --lock-video-orientation value: %s (expected initial, " - "unlocked, 0, 90, 180 or 270).", s); + LOGE("Unsupported display IME policy: %s (expected local, fallback or " + "hide)", s); return false; } -static bool -parse_rotation(const char *s, uint8_t *rotation) { - long value; - bool ok = parse_integer_arg(s, &value, false, 0, 3, "rotation"); - if (!ok) { - return false; - } - - *rotation = (uint8_t) value; - return true; -} - static bool parse_orientation(const char *s, enum sc_orientation *orientation) { if (!strcmp(s, "0")) { @@ -1515,6 +1704,32 @@ parse_orientation(const char *s, enum sc_orientation *orientation) { return false; } +static bool +parse_capture_orientation(const char *s, enum sc_orientation *orientation, + enum sc_orientation_lock *lock) { + if (*s == '\0') { + LOGE("Capture orientation may not be empty (expected 0, 90, 180, 270, " + "flip0, flip90, flip180 or flip270, possibly prefixed by '@')"); + return false; + } + + // Lock the orientation by a leading '@' + if (s[0] == '@') { + // Consume '@' + ++s; + if (*s == '\0') { + // Only '@': lock to the initial orientation (orientation is unused) + *lock = SC_ORIENTATION_LOCKED_INITIAL; + return true; + } + *lock = SC_ORIENTATION_LOCKED_VALUE; + } else { + *lock = SC_ORIENTATION_UNLOCKED; + } + + return parse_orientation(s, orientation); +} + static bool parse_window_position(const char *s, int16_t *position) { // special value for "auto" @@ -1620,82 +1835,62 @@ parse_log_level(const char *s, enum sc_log_level *log_level) { return false; } -// item is a list of mod keys separated by '+' (e.g. "lctrl+lalt") -// returns a bitwise-or of SC_SHORTCUT_MOD_* constants (or 0 on error) -static unsigned +static enum sc_shortcut_mod parse_shortcut_mods_item(const char *item, size_t len) { - unsigned mod = 0; - - for (;;) { - char *plus = strchr(item, '+'); - // strchr() does not consider the "len" parameter, to it could find an - // occurrence too far in the string (there is no strnchr()) - bool has_plus = plus && plus < item + len; - - assert(!has_plus || plus > item); - size_t key_len = has_plus ? (size_t) (plus - item) : len; - #define STREQ(literal, s, len) \ ((sizeof(literal)-1 == len) && !memcmp(literal, s, len)) - if (STREQ("lctrl", item, key_len)) { - mod |= SC_SHORTCUT_MOD_LCTRL; - } else if (STREQ("rctrl", item, key_len)) { - mod |= SC_SHORTCUT_MOD_RCTRL; - } else if (STREQ("lalt", item, key_len)) { - mod |= SC_SHORTCUT_MOD_LALT; - } else if (STREQ("ralt", item, key_len)) { - mod |= SC_SHORTCUT_MOD_RALT; - } else if (STREQ("lsuper", item, key_len)) { - mod |= SC_SHORTCUT_MOD_LSUPER; - } else if (STREQ("rsuper", item, key_len)) { - mod |= SC_SHORTCUT_MOD_RSUPER; - } else { - LOGE("Unknown modifier key: %.*s " - "(must be one of: lctrl, rctrl, lalt, ralt, lsuper, rsuper)", - (int) key_len, item); - return 0; - } + if (STREQ("lctrl", item, len)) { + return SC_SHORTCUT_MOD_LCTRL; + } + if (STREQ("rctrl", item, len)) { + return SC_SHORTCUT_MOD_RCTRL; + } + if (STREQ("lalt", item, len)) { + return SC_SHORTCUT_MOD_LALT; + } + if (STREQ("ralt", item, len)) { + return SC_SHORTCUT_MOD_RALT; + } + if (STREQ("lsuper", item, len)) { + return SC_SHORTCUT_MOD_LSUPER; + } + if (STREQ("rsuper", item, len)) { + return SC_SHORTCUT_MOD_RSUPER; + } #undef STREQ - if (!has_plus) { - break; - } - - item = plus + 1; - assert(len >= key_len + 1); - len -= key_len + 1; + bool has_plus = strchr(item, '+'); + if (has_plus) { + LOGE("Shortcut mod combination with '+' is not supported anymore: " + "'%.*s' (see #4741)", (int) len, item); + return 0; } - return mod; + LOGE("Unknown modifier key: %.*s " + "(must be one of: lctrl, rctrl, lalt, ralt, lsuper, rsuper)", + (int) len, item); + + return 0; } static bool -parse_shortcut_mods(const char *s, struct sc_shortcut_mods *mods) { - unsigned count = 0; - unsigned current = 0; +parse_shortcut_mods(const char *s, uint8_t *shortcut_mods) { + uint8_t mods = 0; - // LCtrl+LAlt or RCtrl or LCtrl+RSuper: "lctrl+lalt,rctrl,lctrl+rsuper" + // A list of shortcut modifiers, for example "lctrl,rctrl,rsuper" for (;;) { char *comma = strchr(s, ','); - if (comma && count == SC_MAX_SHORTCUT_MODS - 1) { - assert(count < SC_MAX_SHORTCUT_MODS); - LOGW("Too many shortcut modifiers alternatives"); - return false; - } - assert(!comma || comma > s); size_t limit = comma ? (size_t) (comma - s) : strlen(s); - unsigned mod = parse_shortcut_mods_item(s, limit); + enum sc_shortcut_mod mod = parse_shortcut_mods_item(s, limit); if (!mod) { - LOGE("Invalid modifier keys: %.*s", (int) limit, s); return false; } - mods->data[current++] = mod; - ++count; + mods |= mod; if (!comma) { break; @@ -1704,7 +1899,7 @@ parse_shortcut_mods(const char *s, struct sc_shortcut_mods *mods) { s = comma + 1; } - mods->count = count; + *shortcut_mods = mods; return true; } @@ -1712,7 +1907,7 @@ parse_shortcut_mods(const char *s, struct sc_shortcut_mods *mods) { #ifdef SC_TEST // expose the function to unit-tests bool -sc_parse_shortcut_mods(const char *s, struct sc_shortcut_mods *mods) { +sc_parse_shortcut_mods(const char *s, uint8_t *mods) { return parse_shortcut_mods(s, mods); } #endif @@ -1854,7 +2049,55 @@ parse_audio_source(const char *optarg, enum sc_audio_source *source) { return true; } - LOGE("Unsupported audio source: %s (expected output or mic)", optarg); + if (!strcmp(optarg, "playback")) { + *source = SC_AUDIO_SOURCE_PLAYBACK; + return true; + } + + if (!strcmp(optarg, "mic-unprocessed")) { + *source = SC_AUDIO_SOURCE_MIC_UNPROCESSED; + return true; + } + + if (!strcmp(optarg, "mic-camcorder")) { + *source = SC_AUDIO_SOURCE_MIC_CAMCORDER; + return true; + } + + if (!strcmp(optarg, "mic-voice-recognition")) { + *source = SC_AUDIO_SOURCE_MIC_VOICE_RECOGNITION; + return true; + } + + if (!strcmp(optarg, "mic-voice-communication")) { + *source = SC_AUDIO_SOURCE_MIC_VOICE_COMMUNICATION; + return true; + } + + if (!strcmp(optarg, "voice-call")) { + *source = SC_AUDIO_SOURCE_VOICE_CALL; + return true; + } + + if (!strcmp(optarg, "voice-call-uplink")) { + *source = SC_AUDIO_SOURCE_VOICE_CALL_UPLINK; + return true; + } + + if (!strcmp(optarg, "voice-call-downlink")) { + *source = SC_AUDIO_SOURCE_VOICE_CALL_DOWNLINK; + return true; + } + + if (!strcmp(optarg, "voice-performance")) { + *source = SC_AUDIO_SOURCE_VOICE_PERFORMANCE; + return true; + } + + LOGE("Unsupported audio source: %s (expected output, mic, playback, " + "mic-unprocessed, mic-camcorder, mic-voice-recognition, " + "mic-voice-communication, voice-call, voice-call-uplink, " + "voice-call-downlink, voice-performance)", optarg); return false; } @@ -1898,6 +2141,95 @@ parse_camera_fps(const char *s, uint16_t *camera_fps) { return true; } +static bool +parse_keyboard(const char *optarg, enum sc_keyboard_input_mode *mode) { + if (!strcmp(optarg, "disabled")) { + *mode = SC_KEYBOARD_INPUT_MODE_DISABLED; + return true; + } + + if (!strcmp(optarg, "sdk")) { + *mode = SC_KEYBOARD_INPUT_MODE_SDK; + return true; + } + + if (!strcmp(optarg, "uhid")) { + *mode = SC_KEYBOARD_INPUT_MODE_UHID; + return true; + } + + if (!strcmp(optarg, "aoa")) { +#ifdef HAVE_USB + *mode = SC_KEYBOARD_INPUT_MODE_AOA; + return true; +#else + LOGE("--keyboard=aoa is disabled."); + return false; +#endif + } + + LOGE("Unsupported keyboard: %s (expected disabled, sdk, uhid and aoa)", + optarg); + return false; +} + +static bool +parse_mouse(const char *optarg, enum sc_mouse_input_mode *mode) { + if (!strcmp(optarg, "disabled")) { + *mode = SC_MOUSE_INPUT_MODE_DISABLED; + return true; + } + + if (!strcmp(optarg, "sdk")) { + *mode = SC_MOUSE_INPUT_MODE_SDK; + return true; + } + + if (!strcmp(optarg, "uhid")) { + *mode = SC_MOUSE_INPUT_MODE_UHID; + return true; + } + + if (!strcmp(optarg, "aoa")) { +#ifdef HAVE_USB + *mode = SC_MOUSE_INPUT_MODE_AOA; + return true; +#else + LOGE("--mouse=aoa is disabled."); + return false; +#endif + } + + LOGE("Unsupported mouse: %s (expected disabled, sdk, uhid or aoa)", optarg); + return false; +} + +static bool +parse_gamepad(const char *optarg, enum sc_gamepad_input_mode *mode) { + if (!strcmp(optarg, "disabled")) { + *mode = SC_GAMEPAD_INPUT_MODE_DISABLED; + return true; + } + + if (!strcmp(optarg, "uhid")) { + *mode = SC_GAMEPAD_INPUT_MODE_UHID; + return true; + } + + if (!strcmp(optarg, "aoa")) { +#ifdef HAVE_USB + *mode = SC_GAMEPAD_INPUT_MODE_AOA; + return true; +#else + LOGE("--gamepad=aoa is disabled."); + return false; +#endif + } + + LOGE("Unsupported gamepad: %s (expected disabled or aoa)", optarg); + return false; +} + static bool parse_time_limit(const char *s, sc_tick *tick) { long value; @@ -1910,6 +2242,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")) { @@ -1928,11 +2274,85 @@ parse_pause_on_exit(const char *s, enum sc_pause_on_exit *pause_on_exit) { } LOGE("Unsupported pause on exit mode: %s " - "(expected true, false or if-error)", optarg); + "(expected true, false or if-error)", s); return false; } +static bool +parse_mouse_binding(char c, enum sc_mouse_binding *b) { + switch (c) { + case '+': + *b = SC_MOUSE_BINDING_CLICK; + return true; + case '-': + *b = SC_MOUSE_BINDING_DISABLED; + return true; + case 'b': + *b = SC_MOUSE_BINDING_BACK; + return true; + case 'h': + *b = SC_MOUSE_BINDING_HOME; + return true; + case 's': + *b = SC_MOUSE_BINDING_APP_SWITCH; + return true; + case 'n': + *b = SC_MOUSE_BINDING_EXPAND_NOTIFICATION_PANEL; + return true; + default: + LOGE("Invalid mouse binding: '%c' " + "(expected '+', '-', 'b', 'h', 's' or 'n')", c); + return false; + } +} + +static bool +parse_mouse_binding_set(const char *s, struct sc_mouse_binding_set *mbs) { + assert(strlen(s) >= 4); + + if (!parse_mouse_binding(s[0], &mbs->right_click)) { + return false; + } + if (!parse_mouse_binding(s[1], &mbs->middle_click)) { + return false; + } + if (!parse_mouse_binding(s[2], &mbs->click4)) { + return false; + } + if (!parse_mouse_binding(s[3], &mbs->click5)) { + return false; + } + + return true; +} + +static bool +parse_mouse_bindings(const char *s, struct sc_mouse_bindings *mb) { + size_t len = strlen(s); + // either "xxxx" or "xxxx:xxxx" + if (len != 4 && (len != 9 || s[4] != ':')) { + LOGE("Invalid mouse bindings: '%s' (expected 'xxxx' or 'xxxx:xxxx', " + "with each 'x' being in {'+', '-', 'b', 'h', 's', 'n'})", s); + return false; + } + + if (!parse_mouse_binding_set(s, &mb->pri)) { + return false; + } + + if (len == 9) { + if (!parse_mouse_binding_set(s + 5, &mb->sec)) { + return false; + } + } else { + // use the same bindings for Shift+click + mb->sec = mb->pri; + } + + return true; +} + static bool parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[], const char *optstring, const struct option *longopts) { @@ -1961,8 +2381,8 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[], opts->crop = optarg; break; case OPT_DISPLAY: - LOGW("--display is deprecated, use --display-id instead."); - // fall through + LOGE("--display has been removed, use --display-id instead."); + return false; case OPT_DISPLAY_ID: if (!parse_display_id(optarg, &opts->display_id)) { return false; @@ -1986,34 +2406,53 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[], args->help = true; break; case 'K': -#ifdef HAVE_USB - opts->keyboard_input_mode = SC_KEYBOARD_INPUT_MODE_HID; + opts->keyboard_input_mode = SC_KEYBOARD_INPUT_MODE_UHID_OR_AOA; break; -#else - LOGE("HID over AOA (-K/--hid-keyboard) is disabled."); - return false; -#endif - case OPT_MAX_FPS: - if (!parse_max_fps(optarg, &opts->max_fps)) { + case OPT_KEYBOARD: + if (!parse_keyboard(optarg, &opts->keyboard_input_mode)) { return false; } break; + case OPT_HID_KEYBOARD_DEPRECATED: + LOGE("--hid-keyboard has been removed, use --keyboard=aoa or " + "--keyboard=uhid instead."); + return false; + case OPT_MAX_FPS: + opts->max_fps = optarg; + break; case 'm': if (!parse_max_size(optarg, &opts->max_size)) { return false; } break; case 'M': -#ifdef HAVE_USB - opts->mouse_input_mode = SC_MOUSE_INPUT_MODE_HID; + opts->mouse_input_mode = SC_MOUSE_INPUT_MODE_UHID_OR_AOA; break; -#else - LOGE("HID over AOA (-M/--hid-mouse) is disabled."); + case OPT_MOUSE: + if (!parse_mouse(optarg, &opts->mouse_input_mode)) { + return false; + } + break; + case OPT_MOUSE_BIND: + if (!parse_mouse_bindings(optarg, &opts->mouse_bindings)) { + return false; + } + break; + case OPT_NO_MOUSE_HOVER: + opts->mouse_hover = false; + break; + case OPT_HID_MOUSE_DEPRECATED: + LOGE("--hid-mouse has been removed, use --mouse=aoa or " + "--mouse=uhid instead."); return false; -#endif case OPT_LOCK_VIDEO_ORIENTATION: - if (!parse_lock_video_orientation(optarg, - &opts->lock_video_orientation)) { + LOGE("--lock-video-orientation has been removed, use " + "--capture-orientation instead."); + return false; + case OPT_CAPTURE_ORIENTATION: + if (!parse_capture_orientation(optarg, + &opts->capture_orientation, + &opts->capture_orientation_lock)) { return false; } break; @@ -2031,8 +2470,9 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[], opts->control = false; break; case OPT_NO_DISPLAY: - LOGW("--no-display is deprecated, use --no-playback instead."); - // fall through + LOGE("--no-display has been removed, use --no-playback " + "instead."); + return false; case 'N': opts->video_playback = false; opts->audio_playback = false; @@ -2118,32 +2558,9 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[], opts->key_inject_mode = SC_KEY_INJECT_MODE_RAW; break; case OPT_ROTATION: - LOGW("--rotation is deprecated, use --display-orientation " - "instead."); - uint8_t rotation; - if (!parse_rotation(optarg, &rotation)) { - return false; - } - assert(rotation <= 3); - switch (rotation) { - case 0: - opts->display_orientation = SC_ORIENTATION_0; - break; - case 1: - // rotation 1 was 90° counterclockwise, but orientation - // is expressed clockwise - opts->display_orientation = SC_ORIENTATION_270; - break; - case 2: - opts->display_orientation = SC_ORIENTATION_180; - break; - case 3: - // rotation 3 was 270° counterclockwise, but orientation - // is expressed clockwise - opts->display_orientation = SC_ORIENTATION_90; - break; - } - break; + LOGE("--rotation has been removed, use --orientation or " + "--capture-orientation instead."); + return false; case OPT_DISPLAY_ORIENTATION: if (!parse_orientation(optarg, &opts->display_orientation)) { return false; @@ -2154,7 +2571,7 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[], return false; } break; - case OPT_ORIENTATION: + case OPT_ORIENTATION: { enum sc_orientation orientation; if (!parse_orientation(optarg, &orientation)) { return false; @@ -2162,6 +2579,7 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[], opts->display_orientation = orientation; opts->record_orientation = orientation; break; + } case OPT_RENDER_DRIVER: opts->render_driver = optarg; break; @@ -2203,8 +2621,9 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[], } break; case OPT_FORWARD_ALL_CLICKS: - opts->forward_all_clicks = true; - break; + LOGE("--forward-all-clicks has been removed, " + "use --mouse-bind=++++ instead."); + return false; case OPT_LEGACY_PASTE: opts->legacy_paste = true; break; @@ -2212,7 +2631,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)) { + LOGE("--display-buffer has been removed, use --video-buffer " + "instead."); + return false; + case OPT_VIDEO_BUFFER: + if (!parse_buffering_time(optarg, &opts->video_buffer)) { return false; } break; @@ -2295,6 +2718,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; @@ -2354,6 +2780,47 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[], case OPT_CAMERA_HIGH_SPEED: opts->camera_high_speed = true; break; + case OPT_NO_WINDOW: + opts->window = false; + break; + case OPT_AUDIO_DUP: + opts->audio_dup = true; + break; + case 'G': + opts->gamepad_input_mode = SC_GAMEPAD_INPUT_MODE_UHID_OR_AOA; + break; + case OPT_GAMEPAD: + if (!parse_gamepad(optarg, &opts->gamepad_input_mode)) { + 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; + case OPT_ANGLE: + opts->angle = optarg; + break; + case OPT_NO_VD_DESTROY_CONTENT: + opts->vd_destroy_content = false; + break; + case OPT_NO_VD_SYSTEM_DECORATIONS: + opts->vd_system_decorations = false; + break; + case OPT_DISPLAY_IME_POLICY: + if (!parse_display_ime_policy(optarg, + &opts->display_ime_policy)) { + return false; + } + break; default: // getopt prints the error message on stderr return false; @@ -2391,8 +2858,17 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[], v4l2 = !!opts->v4l2_device; #endif + if (!opts->window) { + // Without window, there cannot be any video playback + opts->video_playback = false; + // Controls are still possible, allowing for options like + // --turn-screen-off + } + if (!opts->video) { opts->video_playback = false; + // Do not power on the device on start if video capture is disabled + opts->power_on = false; } if (!opts->audio) { @@ -2410,8 +2886,8 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[], opts->audio = false; } - if (!opts->video && !opts->audio && !otg) { - LOGE("No video, no audio, no OTG: nothing to do"); + if (!opts->video && !opts->audio && !opts->control && !otg) { + LOGE("No video, no audio, no control, no OTG: nothing to do"); return false; } @@ -2422,9 +2898,9 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[], if (opts->audio_playback && opts->audio_buffer == -1) { if (opts->audio_codec == SC_CODEC_FLAC) { - // Use 50 ms audio buffer by default, but use a higher value for FLAC, - // which is not low latency (the default encoder produces blocks of - // 4096 samples, which represent ~85.333ms). + // Use 50 ms audio buffer by default, but use a higher value for + // FLAC, which is not low latency (the default encoder produces + // blocks of 4096 samples, which represent ~85.333ms). LOGI("FLAC audio: audio buffer increased to 120 ms (use " "--audio-buffer to set a custom value)"); opts->audio_buffer = SC_TICK_FROM_MS(120); @@ -2435,11 +2911,9 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[], #ifdef HAVE_V4L2 if (v4l2) { - if (opts->lock_video_orientation == - 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; + if (!opts->video) { + LOGE("V4L2 sink requires video capture, but --no-video was set."); + return false; } // V4L2 could not handle size change. @@ -2449,11 +2923,148 @@ 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 + if (opts->control) { + if (opts->keyboard_input_mode == SC_KEYBOARD_INPUT_MODE_AUTO) { + opts->keyboard_input_mode = otg ? SC_KEYBOARD_INPUT_MODE_AOA + : SC_KEYBOARD_INPUT_MODE_SDK; + } else if (opts->keyboard_input_mode + == SC_KEYBOARD_INPUT_MODE_UHID_OR_AOA) { + opts->keyboard_input_mode = otg ? SC_KEYBOARD_INPUT_MODE_AOA + : SC_KEYBOARD_INPUT_MODE_UHID; + } + + if (opts->mouse_input_mode == SC_MOUSE_INPUT_MODE_AUTO) { + if (otg) { + opts->mouse_input_mode = SC_MOUSE_INPUT_MODE_AOA; + } else if (!opts->video_playback) { + 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; + } + } else if (opts->mouse_input_mode == SC_MOUSE_INPUT_MODE_UHID_OR_AOA) { + opts->mouse_input_mode = otg ? SC_MOUSE_INPUT_MODE_AOA + : SC_MOUSE_INPUT_MODE_UHID; + } else if (opts->mouse_input_mode == SC_MOUSE_INPUT_MODE_SDK + && !opts->video_playback) { + LOGE("SDK mouse mode requires video playback. Try --mouse=uhid."); + return false; + } + if (opts->gamepad_input_mode == SC_GAMEPAD_INPUT_MODE_UHID_OR_AOA) { + opts->gamepad_input_mode = otg ? SC_GAMEPAD_INPUT_MODE_AOA + : SC_GAMEPAD_INPUT_MODE_UHID; + } + } + + // If mouse bindings are not explicitly set, configure default bindings + if (opts->mouse_bindings.pri.right_click == SC_MOUSE_BINDING_AUTO) { + assert(opts->mouse_bindings.pri.middle_click == SC_MOUSE_BINDING_AUTO); + assert(opts->mouse_bindings.pri.click4 == SC_MOUSE_BINDING_AUTO); + assert(opts->mouse_bindings.pri.click5 == SC_MOUSE_BINDING_AUTO); + assert(opts->mouse_bindings.sec.right_click == SC_MOUSE_BINDING_AUTO); + assert(opts->mouse_bindings.sec.middle_click == SC_MOUSE_BINDING_AUTO); + assert(opts->mouse_bindings.sec.click4 == SC_MOUSE_BINDING_AUTO); + assert(opts->mouse_bindings.sec.click5 == SC_MOUSE_BINDING_AUTO); + + static struct sc_mouse_binding_set default_shortcuts = { + .right_click = SC_MOUSE_BINDING_BACK, + .middle_click = SC_MOUSE_BINDING_HOME, + .click4 = SC_MOUSE_BINDING_APP_SWITCH, + .click5 = SC_MOUSE_BINDING_EXPAND_NOTIFICATION_PANEL, + }; + + static struct sc_mouse_binding_set forward = { + .right_click = SC_MOUSE_BINDING_CLICK, + .middle_click = SC_MOUSE_BINDING_CLICK, + .click4 = SC_MOUSE_BINDING_CLICK, + .click5 = SC_MOUSE_BINDING_CLICK, + }; + + // By default, forward all clicks only for UHID and AOA + if (opts->mouse_input_mode == SC_MOUSE_INPUT_MODE_SDK) { + opts->mouse_bindings.pri = default_shortcuts; + opts->mouse_bindings.sec = forward; + } else { + opts->mouse_bindings.pri = forward; + opts->mouse_bindings.sec = default_shortcuts; + } + } + + 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 (otg) { + if (!opts->control) { + LOGE("--no-control is not allowed in OTG mode"); + return false; + } + + enum sc_keyboard_input_mode kmode = opts->keyboard_input_mode; + if (kmode != SC_KEYBOARD_INPUT_MODE_AOA + && kmode != SC_KEYBOARD_INPUT_MODE_DISABLED) { + LOGE("In OTG mode, --keyboard only supports aoa or disabled."); + return false; + } + + enum sc_mouse_input_mode mmode = opts->mouse_input_mode; + if (mmode != SC_MOUSE_INPUT_MODE_AOA + && mmode != SC_MOUSE_INPUT_MODE_DISABLED) { + LOGE("In OTG mode, --mouse only supports aoa or disabled."); + return false; + } + + enum sc_gamepad_input_mode gmode = opts->gamepad_input_mode; + if (gmode != SC_GAMEPAD_INPUT_MODE_AOA + && gmode != SC_GAMEPAD_INPUT_MODE_DISABLED) { + LOGE("In OTG mode, --gamepad only supports aoa or disabled."); + return false; + } + + if (kmode == SC_KEYBOARD_INPUT_MODE_DISABLED + && mmode == SC_MOUSE_INPUT_MODE_DISABLED + && gmode == SC_GAMEPAD_INPUT_MODE_DISABLED) { + LOGE("Cannot not disable all inputs in OTG mode."); + return false; + } + } + + if (opts->keyboard_input_mode != SC_KEYBOARD_INPUT_MODE_SDK) { + if (opts->key_inject_mode == SC_KEY_INJECT_MODE_TEXT) { + LOGE("--prefer-text is specific to --keyboard=sdk"); + return false; + } + + if (opts->key_inject_mode == SC_KEY_INJECT_MODE_RAW) { + LOGE("--raw-key-events is specific to --keyboard=sdk"); + return false; + } + + if (!opts->forward_key_repeat) { + LOGE("--no-key-repeat is specific to --keyboard=sdk"); + return false; + } + } + + if (opts->mouse_input_mode != SC_MOUSE_INPUT_MODE_SDK + && !opts->mouse_hover) { + LOGE("--no-mouse-over is specific to --mouse=sdk"); + return false; + } + if ((opts->tunnel_host || opts->tunnel_port) && !opts->force_adb_forward) { LOGI("Tunnel host/port is set, " "--force-adb-forward automatically enabled."); @@ -2466,19 +3077,25 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[], return false; } + if (opts->display_ime_policy != SC_DISPLAY_IME_POLICY_UNDEFINED) { + LOGE("--display-ime-policy is only available with " + "--video-source=display"); + return false; + } + if (opts->camera_id && opts->camera_facing != SC_CAMERA_FACING_ANY) { - LOGE("Could not specify both --camera-id and --camera-facing"); + LOGE("Cannot specify both --camera-id and --camera-facing"); return false; } if (opts->camera_size) { if (opts->max_size) { - LOGE("Could not specify both --camera-size and -m/--max-size"); + LOGE("Cannot specify both --camera-size and -m/--max-size"); return false; } if (opts->camera_ar) { - LOGE("Could not specify both --camera-size and --camera-ar"); + LOGE("Cannot specify both --camera-size and --camera-ar"); return false; } } @@ -2502,22 +3119,56 @@ 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->display_ime_policy != SC_DISPLAY_IME_POLICY_UNDEFINED + && opts->display_id == 0 && !opts->new_display) { + LOGE("--display-ime-policy is only supported on a secondary 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) { - opts->audio_source = SC_AUDIO_SOURCE_OUTPUT; + if (opts->audio_dup) { + LOGI("Audio duplication enabled: audio source switched to " + "\"playback\""); + opts->audio_source = SC_AUDIO_SOURCE_PLAYBACK; + } else { + opts->audio_source = SC_AUDIO_SOURCE_OUTPUT; + } } else { opts->audio_source = SC_AUDIO_SOURCE_MIC; LOGI("Camera video source: microphone audio source selected"); } } + if (opts->audio_dup) { + if (!opts->audio) { + LOGE("--audio-dup not supported if audio is disabled"); + return false; + } + + if (opts->audio_source != SC_AUDIO_SOURCE_PLAYBACK) { + LOGE("--audio-dup is specific to --audio-source=playback"); + return false; + } + } + if (opts->record_format && !opts->record_filename) { LOGE("Record format specified without recording"); return false; } if (opts->record_filename) { + if (!opts->video && !opts->audio) { + LOGE("Video and audio disabled, nothing to record"); + return false; + } + if (!opts->record_format) { opts->record_format = guess_record_format(opts->record_filename); if (!opts->record_format) { @@ -2596,39 +3247,48 @@ parse_args_with_getopt(struct scrcpy_cli_args *args, int argc, char *argv[], if (!opts->control) { if (opts->turn_screen_off) { - LOGE("Could not request to turn screen off if control is disabled"); + LOGE("Cannot request to turn screen off if control is disabled"); return false; } if (opts->stay_awake) { - LOGE("Could not request to stay awake if control is disabled"); + LOGE("Cannot request to stay awake if control is disabled"); return false; } if (opts->show_touches) { - LOGE("Could not request to show touches if control is disabled"); + LOGE("Cannot request to show touches if control is disabled"); return false; } if (opts->power_off_on_close) { - LOGE("Could not request power off on close if control is disabled"); + 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 - if (!otg && (opts->keyboard_input_mode == SC_KEYBOARD_INPUT_MODE_HID - || opts->mouse_input_mode == SC_MOUSE_INPUT_MODE_HID)) { + if (!otg && (opts->keyboard_input_mode == SC_KEYBOARD_INPUT_MODE_AOA + || opts->mouse_input_mode == SC_MOUSE_INPUT_MODE_AOA)) { LOGE("On Windows, it is not possible to open a USB device already open " "by another process (like adb)."); - LOGE("Therefore, -K/--hid-keyboard and -M/--hid-mouse may only work in " - "OTG mode (--otg)."); + LOGE("Therefore, --keyboard=aoa and --mouse=aoa may only work in OTG" + "mode (--otg)."); return false; } # endif + if (opts->start_fps_counter && !opts->video_playback) { + LOGW("--print-fps has no effect without video playback"); + opts->start_fps_counter = false; + } + if (otg) { // OTG mode is compatible with only very few options. // Only report obvious errors. if (opts->record_filename) { - LOGE("OTG mode: could not record"); + LOGE("OTG mode: cannot record"); return false; } if (opts->turn_screen_off) { @@ -2683,7 +3343,7 @@ sc_get_pause_on_exit(int argc, char *argv[]) { if (!strcmp(value, "if-error")) { return SC_PAUSE_ON_EXIT_IF_ERROR; } - // Set to false, inclusing when the value is invalid + // Set to false, including when the value is invalid return SC_PAUSE_ON_EXIT_FALSE; } } diff --git a/app/src/cli.h b/app/src/cli.h index 23d34fcd..6fd579a4 100644 --- a/app/src/cli.h +++ b/app/src/cli.h @@ -28,7 +28,7 @@ scrcpy_parse_args(struct scrcpy_cli_args *args, int argc, char *argv[]); #ifdef SC_TEST bool -sc_parse_shortcut_mods(const char *s, struct sc_shortcut_mods *mods); +sc_parse_shortcut_mods(const char *s, uint8_t *shortcut_mods); #endif #endif diff --git a/app/src/clock.c b/app/src/clock.c index 92989bfe..8a77e514 100644 --- a/app/src/clock.c +++ b/app/src/clock.c @@ -4,7 +4,7 @@ #include "util/log.h" -#define SC_CLOCK_NDEBUG // comment to debug +//#define SC_CLOCK_DEBUG // uncomment to debug #define SC_CLOCK_RANGE 32 @@ -21,10 +21,12 @@ sc_clock_update(struct sc_clock *clock, sc_tick system, sc_tick stream) { } sc_tick offset = system - stream; - clock->offset = ((clock->range - 1) * clock->offset + offset) - / clock->range; + unsigned clock_weight = clock->range - 1; + unsigned value_weight = SC_CLOCK_RANGE - clock->range + 1; + clock->offset = (clock->offset * clock_weight + offset * value_weight) + / SC_CLOCK_RANGE; -#ifndef SC_CLOCK_NDEBUG +#ifdef SC_CLOCK_DEBUG LOGD("Clock estimation: pts + %" PRItick, clock->offset); #endif } diff --git a/app/src/compat.h b/app/src/compat.h index fd610c02..296d1a9f 100644 --- a/app/src/compat.h +++ b/app/src/compat.h @@ -8,7 +8,7 @@ #include #include -#ifndef __WIN32 +#ifndef _WIN32 # define PRIu64_ PRIu64 # define SC_PRIsizet "zu" #else @@ -75,6 +75,14 @@ # define SCRCPY_SDL_HAS_THREAD_PRIORITY_TIME_CRITICAL #endif +#if SDL_VERSION_ATLEAST(2, 0, 18) +# define SCRCPY_SDL_HAS_HINT_APP_NAME +#endif + +#if SDL_VERSION_ATLEAST(2, 0, 14) +# define SCRCPY_SDL_HAS_HINT_AUDIO_DEVICE_APP_NAME +#endif + #ifndef HAVE_STRDUP char *strdup(const char *s); #endif diff --git a/app/src/control_msg.c b/app/src/control_msg.c index d4d6c62a..e46c6165 100644 --- a/app/src/control_msg.c +++ b/app/src/control_msg.c @@ -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", @@ -64,13 +53,11 @@ static const char *const copy_key_labels[] = { static inline const char * get_well_known_pointer_id_name(uint64_t pointer_id) { switch (pointer_id) { - case POINTER_ID_MOUSE: + case SC_POINTER_ID_MOUSE: return "mouse"; - case POINTER_ID_GENERIC_FINGER: + case SC_POINTER_ID_GENERIC_FINGER: return "finger"; - case POINTER_ID_VIRTUAL_MOUSE: - return "vmouse"; - case POINTER_ID_VIRTUAL_FINGER: + case SC_POINTER_ID_VIRTUAL_FINGER: return "vfinger"; default: return NULL; @@ -85,17 +72,36 @@ write_position(uint8_t *buf, const struct sc_position *position) { sc_write16be(&buf[10], position->screen_size.height); } -// write length (4 bytes) + string (non null-terminated) +// Write truncated string, and return the size static size_t -write_string(const char *utf8, size_t max_len, unsigned char *buf) { +write_string_payload(uint8_t *payload, const char *utf8, size_t max_len) { + if (!utf8) { + return 0; + } size_t len = sc_str_utf8_truncation_index(utf8, max_len); + memcpy(payload, utf8, len); + return len; +} + +// Write length (4 bytes) + string (non null-terminated) +static size_t +write_string(uint8_t *buf, const char *utf8, size_t max_len) { + size_t len = write_string_payload(buf + 4, utf8, max_len); sc_write32be(buf, len); - memcpy(&buf[4], utf8, len); return 4 + len; } +// Write length (1 byte) + string (non null-terminated) +static size_t +write_string_tiny(uint8_t *buf, const char *utf8, size_t max_len) { + assert(max_len <= 0xFF); + size_t len = write_string_payload(buf + 1, utf8, max_len); + buf[0] = len; + return 1 + len; +} + size_t -sc_control_msg_serialize(const struct sc_control_msg *msg, unsigned char *buf) { +sc_control_msg_serialize(const struct sc_control_msg *msg, uint8_t *buf) { buf[0] = msg->type; switch (msg->type) { case SC_CONTROL_MSG_TYPE_INJECT_KEYCODE: @@ -105,9 +111,8 @@ sc_control_msg_serialize(const struct sc_control_msg *msg, unsigned char *buf) { sc_write32be(&buf[10], msg->inject_keycode.metastate); return 14; case SC_CONTROL_MSG_TYPE_INJECT_TEXT: { - size_t len = - write_string(msg->inject_text.text, - SC_CONTROL_MSG_INJECT_TEXT_MAX_LENGTH, &buf[1]); + size_t len = write_string(&buf[1], msg->inject_text.text, + SC_CONTROL_MSG_INJECT_TEXT_MAX_LENGTH); return 1 + len; } case SC_CONTROL_MSG_TYPE_INJECT_TOUCH_EVENT: @@ -122,10 +127,14 @@ sc_control_msg_serialize(const struct sc_control_msg *msg, unsigned char *buf) { return 32; case SC_CONTROL_MSG_TYPE_INJECT_SCROLL_EVENT: write_position(&buf[1], &msg->inject_scroll_event.position); - int16_t hscroll = - sc_float_to_i16fp(msg->inject_scroll_event.hscroll); - int16_t vscroll = - sc_float_to_i16fp(msg->inject_scroll_event.vscroll); + // Accept values in the range [-16, 16]. + // Normalize to [-1, 1] in order to use sc_float_to_i16fp(). + float hscroll_norm = msg->inject_scroll_event.hscroll / 16; + hscroll_norm = CLAMP(hscroll_norm, -1, 1); + float vscroll_norm = msg->inject_scroll_event.vscroll / 16; + vscroll_norm = CLAMP(vscroll_norm, -1, 1); + int16_t hscroll = sc_float_to_i16fp(hscroll_norm); + int16_t vscroll = sc_float_to_i16fp(vscroll_norm); sc_write16be(&buf[13], (uint16_t) hscroll); sc_write16be(&buf[15], (uint16_t) vscroll); sc_write32be(&buf[17], msg->inject_scroll_event.buttons); @@ -139,17 +148,46 @@ sc_control_msg_serialize(const struct sc_control_msg *msg, unsigned char *buf) { case SC_CONTROL_MSG_TYPE_SET_CLIPBOARD: sc_write64be(&buf[1], msg->set_clipboard.sequence); buf[9] = !!msg->set_clipboard.paste; - size_t len = write_string(msg->set_clipboard.text, - SC_CONTROL_MSG_CLIPBOARD_TEXT_MAX_LENGTH, - &buf[10]); + 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); + sc_write16be(&buf[3], msg->uhid_create.vendor_id); + sc_write16be(&buf[5], msg->uhid_create.product_id); + + size_t index = 7; + index += write_string_tiny(&buf[index], msg->uhid_create.name, 127); + + sc_write16be(&buf[index], msg->uhid_create.report_desc_size); + index += 2; + + memcpy(&buf[index], msg->uhid_create.report_desc, + msg->uhid_create.report_desc_size); + index += msg->uhid_create.report_desc_size; + + return index; + case SC_CONTROL_MSG_TYPE_UHID_INPUT: + sc_write16be(&buf[1], msg->uhid_input.id); + sc_write16be(&buf[3], msg->uhid_input.size); + memcpy(&buf[5], msg->uhid_input.data, msg->uhid_input.size); + return 5 + msg->uhid_input.size; + 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: @@ -226,9 +264,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"); @@ -242,12 +280,60 @@ sc_control_msg_log(const struct sc_control_msg *msg) { case SC_CONTROL_MSG_TYPE_ROTATE_DEVICE: LOG_CMSG("rotate device"); break; + case SC_CONTROL_MSG_TYPE_UHID_CREATE: { + // Quote only if name is not null + const char *name = msg->uhid_create.name; + const char *quote = name ? "\"" : ""; + LOG_CMSG("UHID create [%" PRIu16 "] %04" PRIx16 ":%04" PRIx16 + " name=%s%s%s report_desc_size=%" PRIu16, + msg->uhid_create.id, + msg->uhid_create.vendor_id, + msg->uhid_create.product_id, + quote, name, quote, + msg->uhid_create.report_desc_size); + break; + } + case SC_CONTROL_MSG_TYPE_UHID_INPUT: { + char *hex = sc_str_to_hex_string(msg->uhid_input.data, + msg->uhid_input.size); + if (hex) { + LOG_CMSG("UHID input [%" PRIu16 "] %s", + msg->uhid_input.id, hex); + free(hex); + } else { + LOG_CMSG("UHID input [%" PRIu16 "] size=%" PRIu16, + msg->uhid_input.id, msg->uhid_input.size); + } + break; + } + case SC_CONTROL_MSG_TYPE_UHID_DESTROY: + LOG_CMSG("UHID destroy [%" PRIu16 "]", msg->uhid_destroy.id); + break; + 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; } } +bool +sc_control_msg_is_droppable(const struct sc_control_msg *msg) { + // Cannot drop UHID_CREATE messages, because it would cause all further + // UHID_INPUT messages for this device to be invalid. + // Cannot drop UHID_DESTROY messages either, because a further UHID_CREATE + // with the same id may fail. + return msg->type != SC_CONTROL_MSG_TYPE_UHID_CREATE + && msg->type != SC_CONTROL_MSG_TYPE_UHID_DESTROY; +} + void sc_control_msg_destroy(struct sc_control_msg *msg) { switch (msg->type) { @@ -257,6 +343,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; diff --git a/app/src/control_msg.h b/app/src/control_msg.h index b90a00b3..74dbcba8 100644 --- a/app/src/control_msg.h +++ b/app/src/control_msg.h @@ -10,6 +10,7 @@ #include "android/input.h" #include "android/keycodes.h" #include "coords.h" +#include "hid/hid_event.h" #define SC_CONTROL_MSG_MAX_SIZE (1 << 18) // 256k @@ -17,12 +18,11 @@ // type: 1 byte; sequence: 8 bytes; paste flag: 1 byte; length: 4 bytes #define SC_CONTROL_MSG_CLIPBOARD_TEXT_MAX_LENGTH (SC_CONTROL_MSG_MAX_SIZE - 14) -#define POINTER_ID_MOUSE UINT64_C(-1) -#define POINTER_ID_GENERIC_FINGER UINT64_C(-2) +#define SC_POINTER_ID_MOUSE UINT64_C(-1) +#define SC_POINTER_ID_GENERIC_FINGER UINT64_C(-2) // Used for injecting an additional virtual pointer for pinch-to-zoom -#define POINTER_ID_VIRTUAL_MOUSE UINT64_C(-3) -#define POINTER_ID_VIRTUAL_FINGER UINT64_C(-4) +#define SC_POINTER_ID_VIRTUAL_FINGER UINT64_C(-3) enum sc_control_msg_type { SC_CONTROL_MSG_TYPE_INJECT_KEYCODE, @@ -35,14 +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, -}; - -enum sc_screen_power_mode { - // see - SC_SCREEN_POWER_MODE_OFF = 0, - SC_SCREEN_POWER_MODE_NORMAL = 2, + 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, + SC_CONTROL_MSG_TYPE_START_APP, + SC_CONTROL_MSG_TYPE_RESET_VIDEO, }; enum sc_copy_key { @@ -90,19 +90,43 @@ 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; + uint16_t vendor_id; + uint16_t product_id; + const char *name; // pointer to static data + uint16_t report_desc_size; + const uint8_t *report_desc; // pointer to static data + } uhid_create; + struct { + uint16_t id; + uint16_t size; + uint8_t data[SC_HID_MAX_SIZE]; + } uhid_input; + struct { + uint16_t id; + } uhid_destroy; + struct { + char *name; + } start_app; }; }; // buf size must be at least CONTROL_MSG_MAX_SIZE // return the number of bytes written size_t -sc_control_msg_serialize(const struct sc_control_msg *msg, unsigned char *buf); +sc_control_msg_serialize(const struct sc_control_msg *msg, uint8_t *buf); void sc_control_msg_log(const struct sc_control_msg *msg); +// Even when the buffer is "full", some messages must absolutely not be dropped +// to avoid inconsistencies. +bool +sc_control_msg_is_droppable(const struct sc_control_msg *msg); + void sc_control_msg_destroy(struct sc_control_msg *msg); diff --git a/app/src/controller.c b/app/src/controller.c index 0139e42c..749de0a5 100644 --- a/app/src/controller.c +++ b/app/src/controller.c @@ -4,19 +4,38 @@ #include "util/log.h" -#define SC_CONTROL_MSG_QUEUE_MAX 64 +// Drop droppable events above this limit +#define SC_CONTROL_MSG_QUEUE_LIMIT 60 + +static void +sc_controller_receiver_on_ended(struct sc_receiver *receiver, bool error, + void *userdata) { + (void) receiver; + + struct sc_controller *controller = userdata; + // Forward the event to the controller listener + controller->cbs->on_ended(controller, error, controller->cbs_userdata); +} bool sc_controller_init(struct sc_controller *controller, sc_socket control_socket, - struct sc_acksync *acksync) { + const struct sc_controller_callbacks *cbs, + void *cbs_userdata) { sc_vecdeque_init(&controller->queue); - bool ok = sc_vecdeque_reserve(&controller->queue, SC_CONTROL_MSG_QUEUE_MAX); + // Add 4 to support 4 non-droppable events without re-allocation + bool ok = sc_vecdeque_reserve(&controller->queue, + SC_CONTROL_MSG_QUEUE_LIMIT + 4); if (!ok) { return false; } - ok = sc_receiver_init(&controller->receiver, control_socket, acksync); + static const struct sc_receiver_callbacks receiver_cbs = { + .on_ended = sc_controller_receiver_on_ended, + }; + + ok = sc_receiver_init(&controller->receiver, control_socket, &receiver_cbs, + controller); if (!ok) { sc_vecdeque_destroy(&controller->queue); return false; @@ -40,9 +59,21 @@ sc_controller_init(struct sc_controller *controller, sc_socket control_socket, controller->control_socket = control_socket; controller->stopped = false; + assert(cbs && cbs->on_ended); + controller->cbs = cbs; + controller->cbs_userdata = cbs_userdata; + return true; } +void +sc_controller_configure(struct sc_controller *controller, + struct sc_acksync *acksync, + struct sc_uhid_devices *uhid_devices) { + controller->receiver.acksync = acksync; + controller->receiver.uhid_devices = uhid_devices; +} + void sc_controller_destroy(struct sc_controller *controller) { sc_cond_destroy(&controller->msg_cond); @@ -65,39 +96,59 @@ sc_controller_push_msg(struct sc_controller *controller, sc_control_msg_log(msg); } + bool pushed = false; + sc_mutex_lock(&controller->mutex); - bool full = sc_vecdeque_is_full(&controller->queue); - if (!full) { + size_t size = sc_vecdeque_size(&controller->queue); + if (size < SC_CONTROL_MSG_QUEUE_LIMIT) { bool was_empty = sc_vecdeque_is_empty(&controller->queue); sc_vecdeque_push_noresize(&controller->queue, *msg); + pushed = true; if (was_empty) { sc_cond_signal(&controller->msg_cond); } + } else if (!sc_control_msg_is_droppable(msg)) { + bool ok = sc_vecdeque_push(&controller->queue, *msg); + if (ok) { + pushed = true; + } else { + // A non-droppable event must be dropped anyway + LOG_OOM(); + } } - // Otherwise (if the queue is full), the msg is discarded + // Otherwise, the msg is discarded sc_mutex_unlock(&controller->mutex); - return !full; + return pushed; } static bool process_msg(struct sc_controller *controller, - const struct sc_control_msg *msg) { - static unsigned char serialized_msg[SC_CONTROL_MSG_MAX_SIZE]; + const struct sc_control_msg *msg, bool *eos) { + static uint8_t serialized_msg[SC_CONTROL_MSG_MAX_SIZE]; size_t length = sc_control_msg_serialize(msg, serialized_msg); if (!length) { + *eos = false; return false; } + ssize_t w = net_send_all(controller->control_socket, serialized_msg, length); - return (size_t) w == length; + if ((size_t) w != length) { + *eos = true; + return false; + } + + return true; } static int run_controller(void *data) { struct sc_controller *controller = data; + bool error = false; + for (;;) { sc_mutex_lock(&controller->mutex); while (!controller->stopped @@ -107,6 +158,7 @@ run_controller(void *data) { if (controller->stopped) { // stop immediately, do not process further msgs sc_mutex_unlock(&controller->mutex); + LOGD("Controller stopped"); break; } @@ -114,13 +166,20 @@ run_controller(void *data) { struct sc_control_msg msg = sc_vecdeque_pop(&controller->queue); sc_mutex_unlock(&controller->mutex); - bool ok = process_msg(controller, &msg); + bool eos; + bool ok = process_msg(controller, &msg, &eos); sc_control_msg_destroy(&msg); if (!ok) { - LOGD("Could not write msg to socket"); + if (eos) { + LOGD("Controller stopped (socket closed)"); + } // else error already logged + error = !eos; break; } } + + controller->cbs->on_ended(controller, error, controller->cbs_userdata); + return 0; } diff --git a/app/src/controller.h b/app/src/controller.h index a044b2bf..57ad79b3 100644 --- a/app/src/controller.h +++ b/app/src/controller.h @@ -22,11 +22,25 @@ struct sc_controller { bool stopped; struct sc_control_msg_queue queue; struct sc_receiver receiver; + + const struct sc_controller_callbacks *cbs; + void *cbs_userdata; +}; + +struct sc_controller_callbacks { + void (*on_ended)(struct sc_controller *controller, bool error, + void *userdata); }; bool sc_controller_init(struct sc_controller *controller, sc_socket control_socket, - struct sc_acksync *acksync); + const struct sc_controller_callbacks *cbs, + void *cbs_userdata); + +void +sc_controller_configure(struct sc_controller *controller, + struct sc_acksync *acksync, + struct sc_uhid_devices *uhid_devices); void sc_controller_destroy(struct sc_controller *controller); diff --git a/app/src/decoder.c b/app/src/decoder.c index 5d42b8b0..4d0a1daf 100644 --- a/app/src/decoder.c +++ b/app/src/decoder.c @@ -1,11 +1,9 @@ #include "decoder.h" -#include -#include -#include +#include +#include +#include -#include "events.h" -#include "trait/frame_sink.h" #include "util/log.h" /** Downcast packet_sink to decoder */ diff --git a/app/src/decoder.h b/app/src/decoder.h index ba8903f4..1f525fae 100644 --- a/app/src/decoder.h +++ b/app/src/decoder.h @@ -3,13 +3,11 @@ #include "common.h" +#include + #include "trait/frame_source.h" #include "trait/packet_sink.h" -#include -#include -#include - struct sc_decoder { struct sc_packet_sink packet_sink; // packet sink trait struct sc_frame_source frame_source; // frame source trait diff --git a/app/src/delay_buffer.c b/app/src/delay_buffer.c index f6141b35..f75c6f72 100644 --- a/app/src/delay_buffer.c +++ b/app/src/delay_buffer.c @@ -2,14 +2,10 @@ #include #include - -#include -#include +#include #include "util/log.h" -#define SC_BUFFERING_NDEBUG // comment to debug - /** Downcast frame_sink to sc_delay_buffer */ #define DOWNCAST(SINK) container_of(SINK, struct sc_delay_buffer, frame_sink) @@ -80,7 +76,7 @@ run_buffering(void *data) { goto stopped; } -#ifndef SC_BUFFERING_NDEBUG +#ifdef SC_BUFFERING_DEBUG LOGD("Buffering: %" PRItick ";%" PRItick ";%" PRItick, pts, dframe.push_date, sc_tick_now()); #endif @@ -134,6 +130,7 @@ sc_delay_buffer_frame_sink_open(struct sc_frame_sink *sink, sc_clock_init(&db->clock); sc_vecdeque_init(&db->queue); + db->stopped = false; if (!sc_frame_source_sinks_open(&db->frame_source, ctx)) { goto error_destroy_wait_cond; @@ -206,7 +203,7 @@ sc_delay_buffer_frame_sink_push(struct sc_frame_sink *sink, return false; } -#ifndef SC_BUFFERING_NDEBUG +#ifdef SC_BUFFERING_DEBUG dframe.push_date = sc_tick_now(); #endif diff --git a/app/src/delay_buffer.h b/app/src/delay_buffer.h index 53592372..61cd77e4 100644 --- a/app/src/delay_buffer.h +++ b/app/src/delay_buffer.h @@ -4,6 +4,7 @@ #include "common.h" #include +#include #include "clock.h" #include "trait/frame_source.h" @@ -12,12 +13,14 @@ #include "util/tick.h" #include "util/vecdeque.h" +//#define SC_BUFFERING_DEBUG // uncomment to debug + // forward declarations typedef struct AVFrame AVFrame; struct sc_delayed_frame { AVFrame *frame; -#ifndef NDEBUG +#ifdef SC_BUFFERING_DEBUG sc_tick push_date; #endif }; diff --git a/app/src/demuxer.c b/app/src/demuxer.c index c9ee8f3c..885cd6ee 100644 --- a/app/src/demuxer.c +++ b/app/src/demuxer.c @@ -1,14 +1,11 @@ #include "demuxer.h" #include +#include +#include #include -#include -#include -#include "decoder.h" -#include "events.h" #include "packet_merger.h" -#include "recorder.h" #include "util/binary.h" #include "util/log.h" @@ -227,8 +224,9 @@ run_demuxer(void *data) { } // Config packets must be merged with the next non-config packet only for - // video streams - bool must_merge_config_packet = codec->type == AVMEDIA_TYPE_VIDEO; + // H.26x + bool must_merge_config_packet = raw_codec_id == SC_CODEC_ID_H264 + || raw_codec_id == SC_CODEC_ID_H265; struct sc_packet_merger merger; @@ -277,7 +275,6 @@ run_demuxer(void *data) { finally_close_sinks: sc_packet_source_sinks_close(&demuxer->packet_source); finally_free_context: - // This also calls avcodec_close() internally avcodec_free_context(&codec_ctx); end: demuxer->cbs->on_ended(demuxer, status, demuxer->cbs_userdata); diff --git a/app/src/demuxer.h b/app/src/demuxer.h index 5587d12d..2b7cb703 100644 --- a/app/src/demuxer.h +++ b/app/src/demuxer.h @@ -4,12 +4,8 @@ #include "common.h" #include -#include -#include -#include #include "trait/packet_source.h" -#include "trait/packet_sink.h" #include "util/net.h" #include "util/thread.h" diff --git a/app/src/device_msg.c b/app/src/device_msg.c index 265c7505..7621c040 100644 --- a/app/src/device_msg.c +++ b/app/src/device_msg.c @@ -8,19 +8,22 @@ #include "util/log.h" ssize_t -device_msg_deserialize(const unsigned char *buf, size_t len, - struct device_msg *msg) { - if (len < 5) { - // at least type + empty string length - return 0; // not available +sc_device_msg_deserialize(const uint8_t *buf, size_t len, + struct sc_device_msg *msg) { + if (!len) { + return 0; // no message } msg->type = buf[0]; switch (msg->type) { case DEVICE_MSG_TYPE_CLIPBOARD: { + if (len < 5) { + // at least type + empty string length + return 0; // no complete message + } size_t clipboard_len = sc_read32be(&buf[1]); if (clipboard_len > len - 5) { - return 0; // not available + return 0; // no complete message } char *text = malloc(clipboard_len + 1); if (!text) { @@ -36,10 +39,38 @@ device_msg_deserialize(const unsigned char *buf, size_t len, return 5 + clipboard_len; } case DEVICE_MSG_TYPE_ACK_CLIPBOARD: { + if (len < 9) { + return 0; // no complete message + } uint64_t sequence = sc_read64be(&buf[1]); msg->ack_clipboard.sequence = sequence; return 9; } + case DEVICE_MSG_TYPE_UHID_OUTPUT: { + if (len < 5) { + // at least id + size + return 0; // not available + } + uint16_t id = sc_read16be(&buf[1]); + size_t size = sc_read16be(&buf[3]); + if (size < len - 5) { + return 0; // not available + } + uint8_t *data = malloc(size); + if (!data) { + LOG_OOM(); + return -1; + } + if (size) { + memcpy(data, &buf[5], size); + } + + msg->uhid_output.id = id; + msg->uhid_output.size = size; + msg->uhid_output.data = data; + + return 5 + size; + } default: LOGW("Unknown device message type: %d", (int) msg->type); return -1; // error, we cannot recover @@ -47,8 +78,16 @@ device_msg_deserialize(const unsigned char *buf, size_t len, } void -device_msg_destroy(struct device_msg *msg) { - if (msg->type == DEVICE_MSG_TYPE_CLIPBOARD) { - free(msg->clipboard.text); +sc_device_msg_destroy(struct sc_device_msg *msg) { + switch (msg->type) { + case DEVICE_MSG_TYPE_CLIPBOARD: + free(msg->clipboard.text); + break; + case DEVICE_MSG_TYPE_UHID_OUTPUT: + free(msg->uhid_output.data); + break; + default: + // nothing to do + break; } } diff --git a/app/src/device_msg.h b/app/src/device_msg.h index e8d9fed4..d6c701bb 100644 --- a/app/src/device_msg.h +++ b/app/src/device_msg.h @@ -3,21 +3,22 @@ #include "common.h" -#include +#include #include -#include +#include #define DEVICE_MSG_MAX_SIZE (1 << 18) // 256k // type: 1 byte; length: 4 bytes #define DEVICE_MSG_TEXT_MAX_LENGTH (DEVICE_MSG_MAX_SIZE - 5) -enum device_msg_type { +enum sc_device_msg_type { DEVICE_MSG_TYPE_CLIPBOARD, DEVICE_MSG_TYPE_ACK_CLIPBOARD, + DEVICE_MSG_TYPE_UHID_OUTPUT, }; -struct device_msg { - enum device_msg_type type; +struct sc_device_msg { + enum sc_device_msg_type type; union { struct { char *text; // owned, to be freed by free() @@ -25,15 +26,20 @@ struct device_msg { struct { uint64_t sequence; } ack_clipboard; + struct { + uint16_t id; + uint16_t size; + uint8_t *data; // owned, to be freed by free() + } uhid_output; }; }; // return the number of bytes consumed (0 for no msg available, -1 on error) ssize_t -device_msg_deserialize(const unsigned char *buf, size_t len, - struct device_msg *msg); +sc_device_msg_deserialize(const uint8_t *buf, size_t len, + struct sc_device_msg *msg); void -device_msg_destroy(struct device_msg *msg); +sc_device_msg_destroy(struct sc_device_msg *msg); #endif diff --git a/app/src/display.c b/app/src/display.c index 906b5d65..aee8ef80 100644 --- a/app/src/display.c +++ b/app/src/display.c @@ -1,11 +1,36 @@ #include "display.h" #include +#include +#include +#include #include "util/log.h" +static bool +sc_display_init_novideo_icon(struct sc_display *display, + SDL_Surface *icon_novideo) { + assert(icon_novideo); + + if (SDL_RenderSetLogicalSize(display->renderer, + icon_novideo->w, icon_novideo->h)) { + LOGW("Could not set renderer logical size: %s", SDL_GetError()); + // don't fail + } + + display->texture = SDL_CreateTextureFromSurface(display->renderer, + icon_novideo); + if (!display->texture) { + LOGE("Could not create texture: %s", SDL_GetError()); + return false; + } + + return true; +} + bool -sc_display_init(struct sc_display *display, SDL_Window *window, bool mipmaps) { +sc_display_init(struct sc_display *display, SDL_Window *window, + SDL_Surface *icon_novideo, bool mipmaps) { display->renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED); if (!display->renderer) { @@ -20,6 +45,10 @@ sc_display_init(struct sc_display *display, SDL_Window *window, bool mipmaps) { display->mipmaps = false; +#ifdef SC_DISPLAY_FORCE_OPENGL_CORE_PROFILE + display->gl_context = NULL; +#endif + // starts with "opengl" bool use_opengl = renderer_name && !strncmp(renderer_name, "opengl", 6); if (use_opengl) { @@ -59,11 +88,25 @@ sc_display_init(struct sc_display *display, SDL_Window *window, bool mipmaps) { LOGI("Trilinear filtering disabled"); } } else if (mipmaps) { - LOGD("Trilinear filtering disabled (not an OpenGL renderer"); + LOGD("Trilinear filtering disabled (not an OpenGL renderer)"); } + display->texture = NULL; display->pending.flags = 0; display->pending.frame = NULL; + display->has_frame = false; + + if (icon_novideo) { + // Without video, set a static scrcpy icon as window content + bool ok = sc_display_init_novideo_icon(display, icon_novideo); + if (!ok) { +#ifdef SC_DISPLAY_FORCE_OPENGL_CORE_PROFILE + SDL_GL_DeleteContext(display->gl_context); +#endif + SDL_DestroyRenderer(display->renderer); + return false; + } + } return true; } @@ -195,9 +238,25 @@ sc_display_set_texture_size(struct sc_display *display, struct sc_size size) { return SC_DISPLAY_RESULT_OK; } +static SDL_YUV_CONVERSION_MODE +sc_display_to_sdl_color_range(enum AVColorRange color_range) { + return color_range == AVCOL_RANGE_JPEG ? SDL_YUV_CONVERSION_JPEG + : SDL_YUV_CONVERSION_AUTOMATIC; +} + static bool sc_display_update_texture_internal(struct sc_display *display, const AVFrame *frame) { + if (!display->has_frame) { + // First frame + display->has_frame = true; + + // Configure YUV color range conversion + SDL_YUV_CONVERSION_MODE sdl_color_range = + sc_display_to_sdl_color_range(frame->color_range); + SDL_SetYUVConversionMode(sdl_color_range); + } + int ret = SDL_UpdateYUVTexture(display->texture, NULL, frame->data[0], frame->linesize[0], frame->data[1], frame->linesize[1], diff --git a/app/src/display.h b/app/src/display.h index 643ce73c..4de9b0a9 100644 --- a/app/src/display.h +++ b/app/src/display.h @@ -4,7 +4,8 @@ #include "common.h" #include -#include +#include +#include #include #include "coords.h" @@ -33,6 +34,8 @@ struct sc_display { struct sc_size size; AVFrame *frame; } pending; + + bool has_frame; }; enum sc_display_result { @@ -42,7 +45,8 @@ enum sc_display_result { }; bool -sc_display_init(struct sc_display *display, SDL_Window *window, bool mipmaps); +sc_display_init(struct sc_display *display, SDL_Window *window, + SDL_Surface *icon_novideo, bool mipmaps); void sc_display_destroy(struct sc_display *display); diff --git a/app/src/events.c b/app/src/events.c new file mode 100644 index 00000000..b4322d1b --- /dev/null +++ b/app/src/events.c @@ -0,0 +1,68 @@ +#include "events.h" + +#include + +#include "util/log.h" +#include "util/thread.h" + +bool +sc_push_event_impl(uint32_t type, const char *name) { + SDL_Event event; + event.type = type; + int ret = SDL_PushEvent(&event); + // ret < 0: error (queue full) + // ret == 0: event was filtered + // ret == 1: success + if (ret != 1) { + LOGE("Could not post %s event: %s", name, SDL_GetError()); + return false; + } + + return true; +} + +bool +sc_post_to_main_thread(sc_runnable_fn run, void *userdata) { + SDL_Event event = { + .user = { + .type = SC_EVENT_RUN_ON_MAIN_THREAD, + .data1 = run, + .data2 = userdata, + }, + }; + int ret = SDL_PushEvent(&event); + // ret < 0: error (queue full) + // ret == 0: event was filtered + // ret == 1: success + if (ret != 1) { + if (ret == 0) { + // if ret == 0, this is expected on exit, log in debug mode + LOGD("Could not post runnable to main thread (filtered)"); + } else { + assert(ret < 0); + LOGW("Could not post runnable to main thread: %s", SDL_GetError()); + } + return false; + } + + return true; +} + +static int SDLCALL +task_event_filter(void *userdata, SDL_Event *event) { + (void) userdata; + + if (event->type == SC_EVENT_RUN_ON_MAIN_THREAD) { + // Reject this event type from now on + return 0; + } + + return 1; +} + +void +sc_reject_new_runnables(void) { + assert(sc_thread_get_id() == SC_MAIN_THREAD_ID); + + SDL_SetEventFilter(task_event_filter, NULL); +} diff --git a/app/src/events.h b/app/src/events.h index 8bfa2582..2fe4d3a7 100644 --- a/app/src/events.h +++ b/app/src/events.h @@ -1,9 +1,38 @@ -#define SC_EVENT_NEW_FRAME SDL_USEREVENT -#define SC_EVENT_DEVICE_DISCONNECTED (SDL_USEREVENT + 1) -#define SC_EVENT_SERVER_CONNECTION_FAILED (SDL_USEREVENT + 2) -#define SC_EVENT_SERVER_CONNECTED (SDL_USEREVENT + 3) -#define SC_EVENT_USB_DEVICE_DISCONNECTED (SDL_USEREVENT + 4) -#define SC_EVENT_DEMUXER_ERROR (SDL_USEREVENT + 5) -#define SC_EVENT_RECORDER_ERROR (SDL_USEREVENT + 6) -#define SC_EVENT_SCREEN_INIT_SIZE (SDL_USEREVENT + 7) -#define SC_EVENT_TIME_LIMIT_REACHED (SDL_USEREVENT + 8) +#ifndef SC_EVENTS_H +#define SC_EVENTS_H + +#include "common.h" + +#include +#include +#include + +enum { + SC_EVENT_NEW_FRAME = SDL_USEREVENT, + SC_EVENT_RUN_ON_MAIN_THREAD, + SC_EVENT_DEVICE_DISCONNECTED, + SC_EVENT_SERVER_CONNECTION_FAILED, + SC_EVENT_SERVER_CONNECTED, + SC_EVENT_USB_DEVICE_DISCONNECTED, + SC_EVENT_DEMUXER_ERROR, + SC_EVENT_RECORDER_ERROR, + SC_EVENT_SCREEN_INIT_SIZE, + SC_EVENT_TIME_LIMIT_REACHED, + SC_EVENT_CONTROLLER_ERROR, + SC_EVENT_AOA_OPEN_ERROR, +}; + +bool +sc_push_event_impl(uint32_t type, const char *name); + +#define sc_push_event(TYPE) sc_push_event_impl(TYPE, # TYPE) + +typedef void (*sc_runnable_fn)(void *userdata); + +bool +sc_post_to_main_thread(sc_runnable_fn run, void *userdata); + +void +sc_reject_new_runnables(void); + +#endif diff --git a/app/src/file_pusher.c b/app/src/file_pusher.c index 06911052..681fb5d6 100644 --- a/app/src/file_pusher.c +++ b/app/src/file_pusher.c @@ -1,11 +1,11 @@ #include "file_pusher.h" #include +#include #include #include "adb/adb.h" #include "util/log.h" -#include "util/process_intr.h" #define DEFAULT_PUSH_TARGET "/sdcard/Download/" diff --git a/app/src/fps_counter.c b/app/src/fps_counter.c index dd4ae1da..1daa42ba 100644 --- a/app/src/fps_counter.c +++ b/app/src/fps_counter.c @@ -1,6 +1,7 @@ #include "fps_counter.h" #include +#include #include "util/log.h" diff --git a/app/src/fps_counter.h b/app/src/fps_counter.h index e7619271..3eab461c 100644 --- a/app/src/fps_counter.h +++ b/app/src/fps_counter.h @@ -5,9 +5,9 @@ #include #include -#include #include "util/thread.h" +#include "util/tick.h" struct sc_fps_counter { sc_thread thread; diff --git a/app/src/frame_buffer.c b/app/src/frame_buffer.c index 5699b58f..9fd4cf6f 100644 --- a/app/src/frame_buffer.c +++ b/app/src/frame_buffer.c @@ -1,8 +1,6 @@ #include "frame_buffer.h" #include -#include -#include #include "util/log.h" diff --git a/app/src/frame_buffer.h b/app/src/frame_buffer.h index f97261cd..e748adfb 100644 --- a/app/src/frame_buffer.h +++ b/app/src/frame_buffer.h @@ -4,6 +4,7 @@ #include "common.h" #include +#include #include "util/thread.h" diff --git a/app/src/hid/hid_event.h b/app/src/hid/hid_event.h new file mode 100644 index 00000000..b0d45ce8 --- /dev/null +++ b/app/src/hid/hid_event.h @@ -0,0 +1,27 @@ +#ifndef SC_HID_EVENT_H +#define SC_HID_EVENT_H + +#include "common.h" + +#include +#include + +#define SC_HID_MAX_SIZE 15 + +struct sc_hid_input { + uint16_t hid_id; + uint8_t data[SC_HID_MAX_SIZE]; + uint8_t size; +}; + +struct sc_hid_open { + uint16_t hid_id; + const uint8_t *report_desc; // pointer to static memory + size_t report_desc_size; +}; + +struct sc_hid_close { + uint16_t hid_id; +}; + +#endif diff --git a/app/src/hid/hid_gamepad.c b/app/src/hid/hid_gamepad.c new file mode 100644 index 00000000..842eae9e --- /dev/null +++ b/app/src/hid/hid_gamepad.c @@ -0,0 +1,454 @@ +#include "hid_gamepad.h" + +#include +#include +#include +#include + +#include "util/binary.h" +#include "util/log.h" + +// 2x2 bytes for left stick (X, Y) +// 2x2 bytes for right stick (Z, Rz) +// 2x2 bytes for L2/R2 triggers +// 2 bytes for buttons + padding, +// 1 byte for hat switch (dpad) + padding +#define SC_HID_GAMEPAD_EVENT_SIZE 15 + +// The ->buttons field stores the state for all buttons, but only some of them +// (the 16 LSB) must be transmitted "as is". The DPAD (hat switch) buttons are +// stored locally in the MSB of this field, but not transmitted as is: they are +// transformed to generate another specific byte. +#define SC_HID_BUTTONS_MASK 0xFFFF + +// outside SC_HID_BUTTONS_MASK +#define SC_GAMEPAD_BUTTONS_BIT_DPAD_UP UINT32_C(0x10000) +#define SC_GAMEPAD_BUTTONS_BIT_DPAD_DOWN UINT32_C(0x20000) +#define SC_GAMEPAD_BUTTONS_BIT_DPAD_LEFT UINT32_C(0x40000) +#define SC_GAMEPAD_BUTTONS_BIT_DPAD_RIGHT UINT32_C(0x80000) + +/** + * Gamepad descriptor manually crafted to transmit the input reports. + * + * The HID specification is available here: + * + * + * The HID Usage Tables is also useful: + * + */ +static const uint8_t SC_HID_GAMEPAD_REPORT_DESC[] = { + // Usage Page (Generic Desktop) + 0x05, 0x01, + // Usage (Gamepad) + 0x09, 0x05, + + // Collection (Application) + 0xA1, 0x01, + + // Collection (Physical) + 0xA1, 0x00, + + // Usage Page (Generic Desktop) + 0x05, 0x01, + // Usage (X) Left stick x + 0x09, 0x30, + // Usage (Y) Left stick y + 0x09, 0x31, + // Usage (Rx) Right stick x + 0x09, 0x33, + // Usage (Ry) Right stick y + 0x09, 0x34, + // Logical Minimum (0) + 0x15, 0x00, + // Logical Maximum (65535) + // Cannot use 26 FF FF because 0xFFFF is interpreted as signed 16-bit + 0x27, 0xFF, 0xFF, 0x00, 0x00, // little-endian + // Report Size (16) + 0x75, 0x10, + // Report Count (4) + 0x95, 0x04, + // Input (Data, Variable, Absolute): 4x2 bytes (X, Y, Z, Rz) + 0x81, 0x02, + + // Usage Page (Generic Desktop) + 0x05, 0x01, + // Usage (Z) + 0x09, 0x32, + // Usage (Rz) + 0x09, 0x35, + // Logical Minimum (0) + 0x15, 0x00, + // Logical Maximum (32767) + 0x26, 0xFF, 0x7F, + // Report Size (16) + 0x75, 0x10, + // Report Count (2) + 0x95, 0x02, + // Input (Data, Variable, Absolute): 2x2 bytes (L2, R2) + 0x81, 0x02, + + // Usage Page (Buttons) + 0x05, 0x09, + // Usage Minimum (1) + 0x19, 0x01, + // Usage Maximum (16) + 0x29, 0x10, + // Logical Minimum (0) + 0x15, 0x00, + // Logical Maximum (1) + 0x25, 0x01, + // Report Count (16) + 0x95, 0x10, + // Report Size (1) + 0x75, 0x01, + // Input (Data, Variable, Absolute): 16 buttons bits + 0x81, 0x02, + + // Usage Page (Generic Desktop) + 0x05, 0x01, + // Usage (Hat switch) + 0x09, 0x39, + // Logical Minimum (1) + 0x15, 0x01, + // Logical Maximum (8) + 0x25, 0x08, + // Report Size (4) + 0x75, 0x04, + // Report Count (1) + 0x95, 0x01, + // Input (Data, Variable, Null State): 4-bit value + 0x81, 0x42, + + // End Collection + 0xC0, + + // End Collection + 0xC0, +}; + +/** + * A gamepad HID input report is 15 bytes long: + * - bytes 0-3: left stick state + * - bytes 4-7: right stick state + * - bytes 8-11: L2/R2 triggers state + * - bytes 12-13: buttons state + * - bytes 14: hat switch position (dpad) + * + * +---------------+ + * byte 0: |. . . . . . . .| + * | | left stick x (0-65535, little-endian) + * byte 1: |. . . . . . . .| + * +---------------+ + * byte 2: |. . . . . . . .| + * | | left stick y (0-65535, little-endian) + * byte 3: |. . . . . . . .| + * +---------------+ + * byte 4: |. . . . . . . .| + * | | right stick x (0-65535, little-endian) + * byte 5: |. . . . . . . .| + * +---------------+ + * byte 6: |. . . . . . . .| + * | | right stick y (0-65535, little-endian) + * byte 7: |. . . . . . . .| + * +---------------+ + * byte 8: |. . . . . . . .| + * | | L2 trigger (0-32767, little-endian) + * byte 9: |0 . . . . . . .| + * +---------------+ + * byte 10: |. . . . . . . .| + * | | R2 trigger (0-32767, little-endian) + * byte 11: |0 . . . . . . .| + * +---------------+ + * + * ,--------------- SC_GAMEPAD_BUTTON_RIGHT_SHOULDER + * | ,------------- SC_GAMEPAD_BUTTON_LEFT_SHOULDER + * | | + * | | ,--------- SC_GAMEPAD_BUTTON_NORTH + * | | | ,------- SC_GAMEPAD_BUTTON_WEST + * | | | | + * | | | | ,--- SC_GAMEPAD_BUTTON_EAST + * | | | | | ,- SC_GAMEPAD_BUTTON_SOUTH + * v v v v v v + * +---------------+ + * byte 12: |. . 0 . . 0 . .| + * | | Buttons (16-bit little-endian) + * byte 13: |0 . . . . . 0 0| + * +---------------+ + * ^ ^ ^ ^ ^ + * | | | | | + * | | | | | + * | | | | `----- SC_GAMEPAD_BUTTON_BACK + * | | | `------- SC_GAMEPAD_BUTTON_START + * | | `--------- SC_GAMEPAD_BUTTON_GUIDE + * | `----------- SC_GAMEPAD_BUTTON_LEFT_STICK + * `------------- SC_GAMEPAD_BUTTON_RIGHT_STICK + * + * +---------------+ + * byte 14: |0 0 0 0 . . . .| hat switch (dpad) position (0-8) + * +---------------+ + * 9 possible positions and their values: + * 8 1 2 + * 7 0 3 + * 6 5 4 + * (8 is top-left, 1 is top, 2 is top-right, etc.) + */ + +// [-32768 to 32767] -> [0 to 65535] +#define AXIS_RESCALE(V) (uint16_t) (((int32_t) V) + 0x8000) + +static void +sc_hid_gamepad_slot_init(struct sc_hid_gamepad_slot *slot, + uint32_t gamepad_id) { + assert(gamepad_id != SC_GAMEPAD_ID_INVALID); + slot->gamepad_id = gamepad_id; + slot->buttons = 0; + slot->axis_left_x = AXIS_RESCALE(0); + slot->axis_left_y = AXIS_RESCALE(0); + slot->axis_right_x = AXIS_RESCALE(0); + slot->axis_right_y = AXIS_RESCALE(0); + slot->axis_left_trigger = 0; + slot->axis_right_trigger = 0; +} + +static ssize_t +sc_hid_gamepad_slot_find(struct sc_hid_gamepad *hid, uint32_t gamepad_id) { + for (size_t i = 0; i < SC_MAX_GAMEPADS; ++i) { + if (gamepad_id == hid->slots[i].gamepad_id) { + // found + return i; + } + } + + return -1; +} + +void +sc_hid_gamepad_init(struct sc_hid_gamepad *hid) { + for (size_t i = 0; i < SC_MAX_GAMEPADS; ++i) { + hid->slots[i].gamepad_id = SC_GAMEPAD_ID_INVALID; + } +} + +static inline uint16_t +sc_hid_gamepad_slot_get_id(size_t slot_idx) { + assert(slot_idx < SC_MAX_GAMEPADS); + return SC_HID_ID_GAMEPAD_FIRST + slot_idx; +} + +bool +sc_hid_gamepad_generate_open(struct sc_hid_gamepad *hid, + struct sc_hid_open *hid_open, + uint32_t gamepad_id) { + assert(gamepad_id != SC_GAMEPAD_ID_INVALID); + ssize_t slot_idx = sc_hid_gamepad_slot_find(hid, SC_GAMEPAD_ID_INVALID); + if (slot_idx == -1) { + LOGW("No gamepad slot available for new gamepad %" PRIu32, gamepad_id); + return false; + } + + sc_hid_gamepad_slot_init(&hid->slots[slot_idx], gamepad_id); + + uint16_t hid_id = sc_hid_gamepad_slot_get_id(slot_idx); + hid_open->hid_id = hid_id; + hid_open->report_desc = SC_HID_GAMEPAD_REPORT_DESC; + hid_open->report_desc_size = sizeof(SC_HID_GAMEPAD_REPORT_DESC); + + return true; +} + +bool +sc_hid_gamepad_generate_close(struct sc_hid_gamepad *hid, + struct sc_hid_close *hid_close, + uint32_t gamepad_id) { + assert(gamepad_id != SC_GAMEPAD_ID_INVALID); + ssize_t slot_idx = sc_hid_gamepad_slot_find(hid, gamepad_id); + if (slot_idx == -1) { + LOGW("Unknown gamepad removed %" PRIu32, gamepad_id); + return false; + } + + hid->slots[slot_idx].gamepad_id = SC_GAMEPAD_ID_INVALID; + + uint16_t hid_id = sc_hid_gamepad_slot_get_id(slot_idx); + hid_close->hid_id = hid_id; + + return true; +} + +static uint8_t +sc_hid_gamepad_get_dpad_value(uint32_t buttons) { + // Value depending on direction: + // 8 1 2 + // 7 0 3 + // 6 5 4 + if (buttons & SC_GAMEPAD_BUTTONS_BIT_DPAD_UP) { + if (buttons & SC_GAMEPAD_BUTTONS_BIT_DPAD_LEFT) { + return 8; + } + if (buttons & SC_GAMEPAD_BUTTONS_BIT_DPAD_RIGHT) { + return 2; + } + return 1; + } + if (buttons & SC_GAMEPAD_BUTTONS_BIT_DPAD_DOWN) { + if (buttons & SC_GAMEPAD_BUTTONS_BIT_DPAD_LEFT) { + return 6; + } + if (buttons & SC_GAMEPAD_BUTTONS_BIT_DPAD_RIGHT) { + return 4; + } + return 5; + } + if (buttons & SC_GAMEPAD_BUTTONS_BIT_DPAD_LEFT) { + return 7; + } + if (buttons & SC_GAMEPAD_BUTTONS_BIT_DPAD_RIGHT) { + return 3; + } + + return 0; +} + +static void +sc_hid_gamepad_event_from_slot(uint16_t hid_id, + const struct sc_hid_gamepad_slot *slot, + struct sc_hid_input *hid_input) { + hid_input->hid_id = hid_id; + hid_input->size = SC_HID_GAMEPAD_EVENT_SIZE; + + uint8_t *data = hid_input->data; + // Values must be written in little-endian + sc_write16le(data, slot->axis_left_x); + sc_write16le(data + 2, slot->axis_left_y); + sc_write16le(data + 4, slot->axis_right_x); + sc_write16le(data + 6, slot->axis_right_y); + sc_write16le(data + 8, slot->axis_left_trigger); + sc_write16le(data + 10, slot->axis_right_trigger); + sc_write16le(data + 12, slot->buttons & SC_HID_BUTTONS_MASK); + data[14] = sc_hid_gamepad_get_dpad_value(slot->buttons); +} + +static uint32_t +sc_hid_gamepad_get_button_id(enum sc_gamepad_button button) { + switch (button) { + case SC_GAMEPAD_BUTTON_SOUTH: + return 0x0001; + case SC_GAMEPAD_BUTTON_EAST: + return 0x0002; + case SC_GAMEPAD_BUTTON_WEST: + return 0x0008; + case SC_GAMEPAD_BUTTON_NORTH: + return 0x0010; + case SC_GAMEPAD_BUTTON_BACK: + return 0x0400; + case SC_GAMEPAD_BUTTON_GUIDE: + return 0x1000; + case SC_GAMEPAD_BUTTON_START: + return 0x0800; + case SC_GAMEPAD_BUTTON_LEFT_STICK: + return 0x2000; + case SC_GAMEPAD_BUTTON_RIGHT_STICK: + return 0x4000; + case SC_GAMEPAD_BUTTON_LEFT_SHOULDER: + return 0x0040; + case SC_GAMEPAD_BUTTON_RIGHT_SHOULDER: + return 0x0080; + case SC_GAMEPAD_BUTTON_DPAD_UP: + return SC_GAMEPAD_BUTTONS_BIT_DPAD_UP; + case SC_GAMEPAD_BUTTON_DPAD_DOWN: + return SC_GAMEPAD_BUTTONS_BIT_DPAD_DOWN; + case SC_GAMEPAD_BUTTON_DPAD_LEFT: + return SC_GAMEPAD_BUTTONS_BIT_DPAD_LEFT; + case SC_GAMEPAD_BUTTON_DPAD_RIGHT: + return SC_GAMEPAD_BUTTONS_BIT_DPAD_RIGHT; + default: + // unknown button, ignore + return 0; + } +} + +bool +sc_hid_gamepad_generate_input_from_button(struct sc_hid_gamepad *hid, + struct sc_hid_input *hid_input, + const struct sc_gamepad_button_event *event) { + if ((event->button < 0) || (event->button > 15)) { + return false; + } + + uint32_t gamepad_id = event->gamepad_id; + + ssize_t slot_idx = sc_hid_gamepad_slot_find(hid, gamepad_id); + if (slot_idx == -1) { + LOGW("Axis event for unknown gamepad %" PRIu32, gamepad_id); + return false; + } + + assert(slot_idx < SC_MAX_GAMEPADS); + + struct sc_hid_gamepad_slot *slot = &hid->slots[slot_idx]; + + uint32_t button = sc_hid_gamepad_get_button_id(event->button); + if (!button) { + // unknown button, ignore + return false; + } + + if (event->action == SC_ACTION_DOWN) { + slot->buttons |= button; + } else { + assert(event->action == SC_ACTION_UP); + slot->buttons &= ~button; + } + + uint16_t hid_id = sc_hid_gamepad_slot_get_id(slot_idx); + sc_hid_gamepad_event_from_slot(hid_id, slot, hid_input); + + return true; +} + +bool +sc_hid_gamepad_generate_input_from_axis(struct sc_hid_gamepad *hid, + struct sc_hid_input *hid_input, + const struct sc_gamepad_axis_event *event) { + uint32_t gamepad_id = event->gamepad_id; + + ssize_t slot_idx = sc_hid_gamepad_slot_find(hid, gamepad_id); + if (slot_idx == -1) { + LOGW("Button event for unknown gamepad %" PRIu32, gamepad_id); + return false; + } + + assert(slot_idx < SC_MAX_GAMEPADS); + + struct sc_hid_gamepad_slot *slot = &hid->slots[slot_idx]; + + switch (event->axis) { + case SC_GAMEPAD_AXIS_LEFTX: + slot->axis_left_x = AXIS_RESCALE(event->value); + break; + case SC_GAMEPAD_AXIS_LEFTY: + slot->axis_left_y = AXIS_RESCALE(event->value); + break; + case SC_GAMEPAD_AXIS_RIGHTX: + slot->axis_right_x = AXIS_RESCALE(event->value); + break; + case SC_GAMEPAD_AXIS_RIGHTY: + slot->axis_right_y = AXIS_RESCALE(event->value); + break; + case SC_GAMEPAD_AXIS_LEFT_TRIGGER: + // Trigger is always positive between 0 and 32767 + slot->axis_left_trigger = MAX(0, event->value); + break; + case SC_GAMEPAD_AXIS_RIGHT_TRIGGER: + // Trigger is always positive between 0 and 32767 + slot->axis_right_trigger = MAX(0, event->value); + break; + default: + return false; + } + + uint16_t hid_id = sc_hid_gamepad_slot_get_id(slot_idx); + sc_hid_gamepad_event_from_slot(hid_id, slot, hid_input); + + return true; +} diff --git a/app/src/hid/hid_gamepad.h b/app/src/hid/hid_gamepad.h new file mode 100644 index 00000000..8d939ac7 --- /dev/null +++ b/app/src/hid/hid_gamepad.h @@ -0,0 +1,54 @@ +#ifndef SC_HID_GAMEPAD_H +#define SC_HID_GAMEPAD_H + +#include "common.h" + +#include +#include + +#include "hid/hid_event.h" +#include "input_events.h" + +#define SC_MAX_GAMEPADS 8 +#define SC_HID_ID_GAMEPAD_FIRST 3 +#define SC_HID_ID_GAMEPAD_LAST (SC_HID_ID_GAMEPAD_FIRST + SC_MAX_GAMEPADS - 1) + +struct sc_hid_gamepad_slot { + uint32_t gamepad_id; + uint32_t buttons; + uint16_t axis_left_x; + uint16_t axis_left_y; + uint16_t axis_right_x; + uint16_t axis_right_y; + uint16_t axis_left_trigger; + uint16_t axis_right_trigger; +}; + +struct sc_hid_gamepad { + struct sc_hid_gamepad_slot slots[SC_MAX_GAMEPADS]; +}; + +void +sc_hid_gamepad_init(struct sc_hid_gamepad *hid); + +bool +sc_hid_gamepad_generate_open(struct sc_hid_gamepad *hid, + struct sc_hid_open *hid_open, + uint32_t gamepad_id); + +bool +sc_hid_gamepad_generate_close(struct sc_hid_gamepad *hid, + struct sc_hid_close *hid_close, + uint32_t gamepad_id); + +bool +sc_hid_gamepad_generate_input_from_button(struct sc_hid_gamepad *hid, + struct sc_hid_input *hid_input, + const struct sc_gamepad_button_event *event); + +bool +sc_hid_gamepad_generate_input_from_axis(struct sc_hid_gamepad *hid, + struct sc_hid_input *hid_input, + const struct sc_gamepad_axis_event *event); + +#endif diff --git a/app/src/usb/hid_keyboard.c b/app/src/hid/hid_keyboard.c similarity index 53% rename from app/src/usb/hid_keyboard.c rename to app/src/hid/hid_keyboard.c index e717006a..6477396a 100644 --- a/app/src/usb/hid_keyboard.c +++ b/app/src/hid/hid_keyboard.c @@ -1,49 +1,47 @@ #include "hid_keyboard.h" #include +#include -#include "input_events.h" #include "util/log.h" -/** Downcast key processor to hid_keyboard */ -#define DOWNCAST(KP) container_of(KP, struct sc_hid_keyboard, key_processor) +#define SC_HID_MOD_NONE 0x00 +#define SC_HID_MOD_LEFT_CONTROL (1 << 0) +#define SC_HID_MOD_LEFT_SHIFT (1 << 1) +#define SC_HID_MOD_LEFT_ALT (1 << 2) +#define SC_HID_MOD_LEFT_GUI (1 << 3) +#define SC_HID_MOD_RIGHT_CONTROL (1 << 4) +#define SC_HID_MOD_RIGHT_SHIFT (1 << 5) +#define SC_HID_MOD_RIGHT_ALT (1 << 6) +#define SC_HID_MOD_RIGHT_GUI (1 << 7) -#define HID_KEYBOARD_ACCESSORY_ID 1 - -#define HID_MODIFIER_NONE 0x00 -#define HID_MODIFIER_LEFT_CONTROL (1 << 0) -#define HID_MODIFIER_LEFT_SHIFT (1 << 1) -#define HID_MODIFIER_LEFT_ALT (1 << 2) -#define HID_MODIFIER_LEFT_GUI (1 << 3) -#define HID_MODIFIER_RIGHT_CONTROL (1 << 4) -#define HID_MODIFIER_RIGHT_SHIFT (1 << 5) -#define HID_MODIFIER_RIGHT_ALT (1 << 6) -#define HID_MODIFIER_RIGHT_GUI (1 << 7) - -#define HID_KEYBOARD_INDEX_MODIFIER 0 -#define HID_KEYBOARD_INDEX_KEYS 2 +#define SC_HID_KEYBOARD_INDEX_MODS 0 +#define SC_HID_KEYBOARD_INDEX_KEYS 2 // USB HID protocol says 6 keys in an event is the requirement for BIOS // keyboard support, though OS could support more keys via modifying the report // desc. 6 should be enough for scrcpy. -#define HID_KEYBOARD_MAX_KEYS 6 -#define HID_KEYBOARD_EVENT_SIZE \ - (HID_KEYBOARD_INDEX_KEYS + HID_KEYBOARD_MAX_KEYS) +#define SC_HID_KEYBOARD_MAX_KEYS 6 +#define SC_HID_KEYBOARD_INPUT_SIZE \ + (SC_HID_KEYBOARD_INDEX_KEYS + SC_HID_KEYBOARD_MAX_KEYS) -#define HID_RESERVED 0x00 -#define HID_ERROR_ROLL_OVER 0x01 +#define SC_HID_RESERVED 0x00 +#define SC_HID_ERROR_ROLL_OVER 0x01 /** - * For HID over AOAv2, only report descriptor is needed. + * For HID, only report descriptor is needed. * * The specification is available here: - * + * * * In particular, read: - * - 6.2.2 Report Descriptor + * - §6.2.2 Report Descriptor * - Appendix B.1 Protocol 1 (Keyboard) * - Appendix C: Keyboard Implementation * + * The HID Usage Tables is also useful: + * + * * Normally a basic HID keyboard uses 8 bytes: * Modifier Reserved Key Key Key Key Key Key * @@ -53,7 +51,7 @@ * * (change vid:pid' to your device's vendor ID and product ID). */ -static const unsigned char keyboard_report_desc[] = { +static const uint8_t SC_HID_KEYBOARD_REPORT_DESC[] = { // Usage Page (Generic Desktop) 0x05, 0x01, // Usage (Keyboard) @@ -66,7 +64,7 @@ static const unsigned char keyboard_report_desc[] = { 0x05, 0x07, // Usage Minimum (224) 0x19, 0xE0, - // Usage Maximum (231) + // Usage Maximum (231) 0x29, 0xE7, // Logical Minimum (0) 0x15, 0x00, @@ -119,7 +117,7 @@ static const unsigned char keyboard_report_desc[] = { // Report Size (8) 0x75, 0x08, // Report Count (6) - 0x95, HID_KEYBOARD_MAX_KEYS, + 0x95, SC_HID_KEYBOARD_MAX_KEYS, // Input (Data, Array): Keys 0x81, 0x00, @@ -128,7 +126,7 @@ static const unsigned char keyboard_report_desc[] = { }; /** - * A keyboard HID event is 8 bytes long: + * A keyboard HID input report is 8 bytes long: * * - byte 0: modifiers (1 flag per modifier key, 8 possible modifier keys) * - byte 1: reserved (always 0) @@ -201,51 +199,51 @@ static const unsigned char keyboard_report_desc[] = { * +---------------+ */ -static unsigned char -sdl_keymod_to_hid_modifiers(uint16_t mod) { - unsigned char modifiers = HID_MODIFIER_NONE; - if (mod & SC_MOD_LCTRL) { - modifiers |= HID_MODIFIER_LEFT_CONTROL; - } - if (mod & SC_MOD_LSHIFT) { - modifiers |= HID_MODIFIER_LEFT_SHIFT; - } - if (mod & SC_MOD_LALT) { - modifiers |= HID_MODIFIER_LEFT_ALT; - } - if (mod & SC_MOD_LGUI) { - modifiers |= HID_MODIFIER_LEFT_GUI; - } - if (mod & SC_MOD_RCTRL) { - modifiers |= HID_MODIFIER_RIGHT_CONTROL; - } - if (mod & SC_MOD_RSHIFT) { - modifiers |= HID_MODIFIER_RIGHT_SHIFT; - } - if (mod & SC_MOD_RALT) { - modifiers |= HID_MODIFIER_RIGHT_ALT; - } - if (mod & SC_MOD_RGUI) { - modifiers |= HID_MODIFIER_RIGHT_GUI; - } - return modifiers; +static void +sc_hid_keyboard_input_init(struct sc_hid_input *hid_input) { + hid_input->hid_id = SC_HID_ID_KEYBOARD; + hid_input->size = SC_HID_KEYBOARD_INPUT_SIZE; + + uint8_t *data = hid_input->data; + + data[SC_HID_KEYBOARD_INDEX_MODS] = SC_HID_MOD_NONE; + data[1] = SC_HID_RESERVED; + memset(&data[SC_HID_KEYBOARD_INDEX_KEYS], 0, SC_HID_KEYBOARD_MAX_KEYS); } -static bool -sc_hid_keyboard_event_init(struct sc_hid_event *hid_event) { - unsigned char *buffer = malloc(HID_KEYBOARD_EVENT_SIZE); - if (!buffer) { - LOG_OOM(); - return false; +static uint16_t +sc_hid_mod_from_sdl_keymod(uint16_t mod) { + uint16_t mods = SC_HID_MOD_NONE; + if (mod & SC_MOD_LCTRL) { + mods |= SC_HID_MOD_LEFT_CONTROL; } + if (mod & SC_MOD_LSHIFT) { + mods |= SC_HID_MOD_LEFT_SHIFT; + } + if (mod & SC_MOD_LALT) { + mods |= SC_HID_MOD_LEFT_ALT; + } + if (mod & SC_MOD_LGUI) { + mods |= SC_HID_MOD_LEFT_GUI; + } + if (mod & SC_MOD_RCTRL) { + mods |= SC_HID_MOD_RIGHT_CONTROL; + } + if (mod & SC_MOD_RSHIFT) { + mods |= SC_HID_MOD_RIGHT_SHIFT; + } + if (mod & SC_MOD_RALT) { + mods |= SC_HID_MOD_RIGHT_ALT; + } + if (mod & SC_MOD_RGUI) { + mods |= SC_HID_MOD_RIGHT_GUI; + } + return mods; +} - buffer[HID_KEYBOARD_INDEX_MODIFIER] = HID_MODIFIER_NONE; - buffer[1] = HID_RESERVED; - memset(&buffer[HID_KEYBOARD_INDEX_KEYS], 0, HID_KEYBOARD_MAX_KEYS); - - sc_hid_event_init(hid_event, HID_KEYBOARD_ACCESSORY_ID, buffer, - HID_KEYBOARD_EVENT_SIZE); - return true; +void +sc_hid_keyboard_init(struct sc_hid_keyboard *hid) { + memset(hid->keys, false, SC_HID_KEYBOARD_KEYS); } static inline bool @@ -253,10 +251,10 @@ scancode_is_modifier(enum sc_scancode scancode) { return scancode >= SC_SCANCODE_LCTRL && scancode <= SC_SCANCODE_RGUI; } -static bool -convert_hid_keyboard_event(struct sc_hid_keyboard *kb, - struct sc_hid_event *hid_event, - const struct sc_key_event *event) { +bool +sc_hid_keyboard_generate_input_from_key(struct sc_hid_keyboard *hid, + struct sc_hid_input *hid_input, + const struct sc_key_event *event) { enum sc_scancode scancode = event->scancode; assert(scancode >= 0); @@ -268,39 +266,37 @@ convert_hid_keyboard_event(struct sc_hid_keyboard *kb, return false; } - if (!sc_hid_keyboard_event_init(hid_event)) { - LOGW("Could not initialize HID keyboard event"); - return false; - } + sc_hid_keyboard_input_init(hid_input); - unsigned char modifiers = sdl_keymod_to_hid_modifiers(event->mods_state); + uint16_t mods = sc_hid_mod_from_sdl_keymod(event->mods_state); if (scancode < SC_HID_KEYBOARD_KEYS) { // Pressed is true and released is false - kb->keys[scancode] = (event->action == SC_ACTION_DOWN); + hid->keys[scancode] = (event->action == SC_ACTION_DOWN); LOGV("keys[%02x] = %s", scancode, - kb->keys[scancode] ? "true" : "false"); + hid->keys[scancode] ? "true" : "false"); } - hid_event->buffer[HID_KEYBOARD_INDEX_MODIFIER] = modifiers; + hid_input->data[SC_HID_KEYBOARD_INDEX_MODS] = mods; - unsigned char *keys_buffer = &hid_event->buffer[HID_KEYBOARD_INDEX_KEYS]; + uint8_t *keys_data = &hid_input->data[SC_HID_KEYBOARD_INDEX_KEYS]; // Re-calculate pressed keys every time int keys_pressed_count = 0; for (int i = 0; i < SC_HID_KEYBOARD_KEYS; ++i) { - if (kb->keys[i]) { + if (hid->keys[i]) { // USB HID protocol says that if keys exceeds report count, a // phantom state should be reported - if (keys_pressed_count >= HID_KEYBOARD_MAX_KEYS) { + if (keys_pressed_count >= SC_HID_KEYBOARD_MAX_KEYS) { // Phantom state: // - Modifiers // - Reserved // - ErrorRollOver * HID_MAX_KEYS - memset(keys_buffer, HID_ERROR_ROLL_OVER, HID_KEYBOARD_MAX_KEYS); + memset(keys_data, SC_HID_ERROR_ROLL_OVER, + SC_HID_KEYBOARD_MAX_KEYS); goto end; } - keys_buffer[keys_pressed_count] = i; + keys_data[keys_pressed_count] = i; ++keys_pressed_count; } } @@ -308,124 +304,42 @@ convert_hid_keyboard_event(struct sc_hid_keyboard *kb, end: LOGV("hid keyboard: key %-4s scancode=%02x (%u) mod=%02x", event->action == SC_ACTION_DOWN ? "down" : "up", event->scancode, - event->scancode, modifiers); + event->scancode, mods); return true; } - -static bool -push_mod_lock_state(struct sc_hid_keyboard *kb, uint16_t mods_state) { +bool +sc_hid_keyboard_generate_input_from_mods(struct sc_hid_input *hid_input, + uint16_t mods_state) { bool capslock = mods_state & SC_MOD_CAPS; bool numlock = mods_state & SC_MOD_NUM; if (!capslock && !numlock) { // Nothing to do - return true; - } - - struct sc_hid_event hid_event; - if (!sc_hid_keyboard_event_init(&hid_event)) { - LOGW("Could not initialize HID keyboard event"); return false; } + sc_hid_keyboard_input_init(hid_input); + unsigned i = 0; if (capslock) { - hid_event.buffer[HID_KEYBOARD_INDEX_KEYS + i] = SC_SCANCODE_CAPSLOCK; + hid_input->data[SC_HID_KEYBOARD_INDEX_KEYS + i] = SC_SCANCODE_CAPSLOCK; ++i; } if (numlock) { - hid_event.buffer[HID_KEYBOARD_INDEX_KEYS + i] = SC_SCANCODE_NUMLOCK; + hid_input->data[SC_HID_KEYBOARD_INDEX_KEYS + i] = SC_SCANCODE_NUMLOCK; ++i; } - if (!sc_aoa_push_hid_event(kb->aoa, &hid_event)) { - sc_hid_event_destroy(&hid_event); - LOGW("Could not request HID event (mod lock state)"); - return false; - } - - LOGD("HID keyboard state synchronized"); - return true; } -static void -sc_key_processor_process_key(struct sc_key_processor *kp, - const struct sc_key_event *event, - uint64_t ack_to_wait) { - if (event->repeat) { - // In USB HID protocol, key repeat is handled by the host (Android), so - // just ignore key repeat here. - return; - } - - struct sc_hid_keyboard *kb = DOWNCAST(kp); - - struct sc_hid_event hid_event; - // Not all keys are supported, just ignore unsupported keys - if (convert_hid_keyboard_event(kb, &hid_event, event)) { - if (!kb->mod_lock_synchronized) { - // Inject CAPSLOCK and/or NUMLOCK if necessary to synchronize - // keyboard state - if (push_mod_lock_state(kb, event->mods_state)) { - kb->mod_lock_synchronized = true; - } - } - - if (ack_to_wait) { - // Ctrl+v is pressed, so clipboard synchronization has been - // requested. Wait until clipboard synchronization is acknowledged - // by the server, otherwise it could paste the old clipboard - // content. - hid_event.ack_to_wait = ack_to_wait; - } - - if (!sc_aoa_push_hid_event(kb->aoa, &hid_event)) { - sc_hid_event_destroy(&hid_event); - LOGW("Could not request HID event (key)"); - } - } +void sc_hid_keyboard_generate_open(struct sc_hid_open *hid_open) { + hid_open->hid_id = SC_HID_ID_KEYBOARD; + hid_open->report_desc = SC_HID_KEYBOARD_REPORT_DESC; + hid_open->report_desc_size = sizeof(SC_HID_KEYBOARD_REPORT_DESC); } -bool -sc_hid_keyboard_init(struct sc_hid_keyboard *kb, struct sc_aoa *aoa) { - kb->aoa = aoa; - - bool ok = sc_aoa_setup_hid(aoa, HID_KEYBOARD_ACCESSORY_ID, - keyboard_report_desc, - ARRAY_LEN(keyboard_report_desc)); - if (!ok) { - LOGW("Register HID keyboard failed"); - return false; - } - - // Reset all states - memset(kb->keys, false, SC_HID_KEYBOARD_KEYS); - - kb->mod_lock_synchronized = false; - - static const struct sc_key_processor_ops ops = { - .process_key = sc_key_processor_process_key, - // Never forward text input via HID (all the keys are injected - // separately) - .process_text = NULL, - }; - - // Clipboard synchronization is requested over the control socket, while HID - // events are sent over AOA, so it must wait for clipboard synchronization - // to be acknowledged by the device before injecting Ctrl+v. - kb->key_processor.async_paste = true; - kb->key_processor.ops = &ops; - - return true; -} - -void -sc_hid_keyboard_destroy(struct sc_hid_keyboard *kb) { - // Unregister HID keyboard so the soft keyboard shows again on Android - bool ok = sc_aoa_unregister_hid(kb->aoa, HID_KEYBOARD_ACCESSORY_ID); - if (!ok) { - LOGW("Could not unregister HID keyboard"); - } +void sc_hid_keyboard_generate_close(struct sc_hid_close *hid_close) { + hid_close->hid_id = SC_HID_ID_KEYBOARD; } diff --git a/app/src/usb/hid_keyboard.h b/app/src/hid/hid_keyboard.h similarity index 63% rename from app/src/usb/hid_keyboard.h rename to app/src/hid/hid_keyboard.h index 7173a898..5ecfd8cf 100644 --- a/app/src/usb/hid_keyboard.h +++ b/app/src/hid/hid_keyboard.h @@ -4,9 +4,10 @@ #include "common.h" #include +#include -#include "aoa_hid.h" -#include "trait/key_processor.h" +#include "hid/hid_event.h" +#include "input_events.h" // See "SDL2/SDL_scancode.h". // Maybe SDL_Keycode is used by most people, but SDL_Scancode is taken from USB @@ -14,6 +15,8 @@ // 0x65 is Application, typically AT-101 Keyboard ends here. #define SC_HID_KEYBOARD_KEYS 0x66 +#define SC_HID_ID_KEYBOARD 1 + /** * HID keyboard events are sequence-based, every time keyboard state changes * it sends an array of currently pressed keys, the host is responsible for @@ -27,18 +30,25 @@ * phantom state. */ struct sc_hid_keyboard { - struct sc_key_processor key_processor; // key processor trait - - struct sc_aoa *aoa; bool keys[SC_HID_KEYBOARD_KEYS]; - - bool mod_lock_synchronized; }; -bool -sc_hid_keyboard_init(struct sc_hid_keyboard *kb, struct sc_aoa *aoa); +void +sc_hid_keyboard_init(struct sc_hid_keyboard *hid); void -sc_hid_keyboard_destroy(struct sc_hid_keyboard *kb); +sc_hid_keyboard_generate_open(struct sc_hid_open *hid_open); + +void +sc_hid_keyboard_generate_close(struct sc_hid_close *hid_close); + +bool +sc_hid_keyboard_generate_input_from_key(struct sc_hid_keyboard *hid, + struct sc_hid_input *hid_input, + const struct sc_key_event *event); + +bool +sc_hid_keyboard_generate_input_from_mods(struct sc_hid_input *hid_input, + uint16_t mods_state); #endif diff --git a/app/src/hid/hid_mouse.c b/app/src/hid/hid_mouse.c new file mode 100644 index 00000000..33f0807e --- /dev/null +++ b/app/src/hid/hid_mouse.c @@ -0,0 +1,222 @@ +#include "hid_mouse.h" + +#include + +// 1 byte for buttons + padding, 1 byte for X position, 1 byte for Y position, +// 1 byte for wheel motion, 1 byte for hozizontal scrolling +#define SC_HID_MOUSE_INPUT_SIZE 5 + +/** + * Mouse descriptor from the specification: + * + * + * Appendix E (p71): §E.10 Report Descriptor (Mouse) + * + * The usage tags (like Wheel) are listed in "HID Usage Tables": + * + * §4 Generic Desktop Page (0x01) (p32) + */ +static const uint8_t SC_HID_MOUSE_REPORT_DESC[] = { + // Usage Page (Generic Desktop) + 0x05, 0x01, + // Usage (Mouse) + 0x09, 0x02, + + // Collection (Application) + 0xA1, 0x01, + + // Usage (Pointer) + 0x09, 0x01, + + // Collection (Physical) + 0xA1, 0x00, + + // Usage Page (Buttons) + 0x05, 0x09, + + // Usage Minimum (1) + 0x19, 0x01, + // Usage Maximum (5) + 0x29, 0x05, + // Logical Minimum (0) + 0x15, 0x00, + // Logical Maximum (1) + 0x25, 0x01, + // Report Count (5) + 0x95, 0x05, + // Report Size (1) + 0x75, 0x01, + // Input (Data, Variable, Absolute): 5 buttons bits + 0x81, 0x02, + + // Report Count (1) + 0x95, 0x01, + // Report Size (3) + 0x75, 0x03, + // Input (Constant): 3 bits padding + 0x81, 0x01, + + // Usage Page (Generic Desktop) + 0x05, 0x01, + // Usage (X) + 0x09, 0x30, + // Usage (Y) + 0x09, 0x31, + // Usage (Wheel) + 0x09, 0x38, + // Logical Minimum (-127) + 0x15, 0x81, + // Logical Maximum (127) + 0x25, 0x7F, + // Report Size (8) + 0x75, 0x08, + // Report Count (3) + 0x95, 0x03, + // Input (Data, Variable, Relative): 3 position bytes (X, Y, Wheel) + 0x81, 0x06, + + // Usage Page (Consumer Page) + 0x05, 0x0C, + // Usage(AC Pan) + 0x0A, 0x38, 0x02, + // Logical Minimum (-127) + 0x15, 0x81, + // Logical Maximum (127) + 0x25, 0x7F, + // Report Size (8) + 0x75, 0x08, + // Report Count (1) + 0x95, 0x01, + // Input (Data, Variable, Relative): 1 byte (AC Pan) + 0x81, 0x06, + + // End Collection + 0xC0, + + // End Collection + 0xC0, +}; + +/** + * A mouse HID input report is 4 bytes long: + * + * - byte 0: buttons state + * - byte 1: relative x motion (signed byte from -127 to 127) + * - byte 2: relative y motion (signed byte from -127 to 127) + * - byte 3: wheel motion (-1, 0 or 1) + * + * 7 6 5 4 3 2 1 0 + * +---------------+ + * byte 0: |0 0 0 . . . . .| buttons state + * +---------------+ + * ^ ^ ^ ^ ^ + * | | | | `- left button + * | | | `--- right button + * | | `----- middle button + * | `------- button 4 + * `--------- button 5 + * + * +---------------+ + * byte 1: |. . . . . . . .| relative x motion + * +---------------+ + * byte 2: |. . . . . . . .| relative y motion + * +---------------+ + * byte 3: |. . . . . . . .| wheel motion + * +---------------+ + * + * As an example, here is the report for a motion of (x=5, y=-4) with left + * button pressed: + * + * +---------------+ + * |0 0 0 0 0 0 0 1| left button pressed + * +---------------+ + * |0 0 0 0 0 1 0 1| horizontal motion (x = 5) + * +---------------+ + * |1 1 1 1 1 1 0 0| relative y motion (y = -4) + * +---------------+ + * |0 0 0 0 0 0 0 0| wheel motion + * +---------------+ + */ + +static void +sc_hid_mouse_input_init(struct sc_hid_input *hid_input) { + hid_input->hid_id = SC_HID_ID_MOUSE; + hid_input->size = SC_HID_MOUSE_INPUT_SIZE; + // Leave ->data uninitialized, it will be fully initialized by callers +} + +static uint8_t +sc_hid_buttons_from_buttons_state(uint8_t buttons_state) { + uint8_t c = 0; + if (buttons_state & SC_MOUSE_BUTTON_LEFT) { + c |= 1 << 0; + } + if (buttons_state & SC_MOUSE_BUTTON_RIGHT) { + c |= 1 << 1; + } + if (buttons_state & SC_MOUSE_BUTTON_MIDDLE) { + c |= 1 << 2; + } + if (buttons_state & SC_MOUSE_BUTTON_X1) { + c |= 1 << 3; + } + if (buttons_state & SC_MOUSE_BUTTON_X2) { + c |= 1 << 4; + } + return c; +} + +void +sc_hid_mouse_generate_input_from_motion(struct sc_hid_input *hid_input, + const struct sc_mouse_motion_event *event) { + sc_hid_mouse_input_init(hid_input); + + uint8_t *data = hid_input->data; + data[0] = sc_hid_buttons_from_buttons_state(event->buttons_state); + data[1] = CLAMP(event->xrel, -127, 127); + data[2] = CLAMP(event->yrel, -127, 127); + data[3] = 0; // no vertical scrolling + data[4] = 0; // no horizontal scrolling +} + +void +sc_hid_mouse_generate_input_from_click(struct sc_hid_input *hid_input, + const struct sc_mouse_click_event *event) { + sc_hid_mouse_input_init(hid_input); + + uint8_t *data = hid_input->data; + data[0] = sc_hid_buttons_from_buttons_state(event->buttons_state); + data[1] = 0; // no x motion + data[2] = 0; // no y motion + data[3] = 0; // no vertical scrolling + data[4] = 0; // no horizontal scrolling +} + +bool +sc_hid_mouse_generate_input_from_scroll(struct sc_hid_input *hid_input, + const struct sc_mouse_scroll_event *event) { + if (!event->vscroll_int && !event->hscroll_int) { + // Need a full integral value for HID + return false; + } + + sc_hid_mouse_input_init(hid_input); + + uint8_t *data = hid_input->data; + data[0] = 0; // buttons state irrelevant (and unknown) + data[1] = 0; // no x motion + data[2] = 0; // no y motion + data[3] = CLAMP(event->vscroll_int, -127, 127); + data[4] = CLAMP(event->hscroll_int, -127, 127); + return true; +} + +void sc_hid_mouse_generate_open(struct sc_hid_open *hid_open) { + hid_open->hid_id = SC_HID_ID_MOUSE; + hid_open->report_desc = SC_HID_MOUSE_REPORT_DESC; + hid_open->report_desc_size = sizeof(SC_HID_MOUSE_REPORT_DESC); +} + +void sc_hid_mouse_generate_close(struct sc_hid_close *hid_close) { + hid_close->hid_id = SC_HID_ID_MOUSE; +} diff --git a/app/src/hid/hid_mouse.h b/app/src/hid/hid_mouse.h new file mode 100644 index 00000000..4ae4bfd4 --- /dev/null +++ b/app/src/hid/hid_mouse.h @@ -0,0 +1,29 @@ +#ifndef SC_HID_MOUSE_H +#define SC_HID_MOUSE_H + +#include "common.h" + +#include "hid/hid_event.h" +#include "input_events.h" + +#define SC_HID_ID_MOUSE 2 + +void +sc_hid_mouse_generate_open(struct sc_hid_open *hid_open); + +void +sc_hid_mouse_generate_close(struct sc_hid_close *hid_close); + +void +sc_hid_mouse_generate_input_from_motion(struct sc_hid_input *hid_input, + const struct sc_mouse_motion_event *event); + +void +sc_hid_mouse_generate_input_from_click(struct sc_hid_input *hid_input, + const struct sc_mouse_click_event *event); + +bool +sc_hid_mouse_generate_input_from_scroll(struct sc_hid_input *hid_input, + const struct sc_mouse_scroll_event *event); + +#endif diff --git a/app/src/icon.c b/app/src/icon.c index a9aad875..797afc75 100644 --- a/app/src/icon.c +++ b/app/src/icon.c @@ -2,16 +2,22 @@ #include #include +#include +#include +#include #include #include +#include #include #include +#include #include "config.h" -#include "compat.h" -#include "util/file.h" +#include "util/env.h" +#ifdef PORTABLE +# include "util/file.h" +#endif #include "util/log.h" -#include "util/str.h" #define SCRCPY_PORTABLE_ICON_FILENAME "icon.png" #define SCRCPY_DEFAULT_ICON_PATH \ @@ -19,35 +25,22 @@ static char * get_icon_path(void) { -#ifdef __WINDOWS__ - const wchar_t *icon_path_env = _wgetenv(L"SCRCPY_ICON_PATH"); -#else - const char *icon_path_env = getenv("SCRCPY_ICON_PATH"); -#endif - if (icon_path_env) { + char *icon_path = sc_get_env("SCRCPY_ICON_PATH"); + if (icon_path) { // if the envvar is set, use it -#ifdef __WINDOWS__ - char *icon_path = sc_str_from_wchars(icon_path_env); -#else - char *icon_path = strdup(icon_path_env); -#endif - if (!icon_path) { - LOG_OOM(); - return NULL; - } LOGD("Using SCRCPY_ICON_PATH: %s", icon_path); return icon_path; } #ifndef PORTABLE LOGD("Using icon: " SCRCPY_DEFAULT_ICON_PATH); - char *icon_path = strdup(SCRCPY_DEFAULT_ICON_PATH); + icon_path = strdup(SCRCPY_DEFAULT_ICON_PATH); if (!icon_path) { LOG_OOM(); return NULL; } #else - char *icon_path = sc_file_get_local_path(SCRCPY_PORTABLE_ICON_FILENAME); + icon_path = sc_file_get_local_path(SCRCPY_PORTABLE_ICON_FILENAME); if (!icon_path) { LOGE("Could not get icon path"); return NULL; @@ -78,7 +71,19 @@ decode_image(const char *path) { goto close_input; } - int stream = av_find_best_stream(ctx, AVMEDIA_TYPE_VIDEO, -1, -1, NULL, 0); + +// In ffmpeg/doc/APIchanges: +// 2021-04-27 - 46dac8cf3d - lavf 59.0.100 - avformat.h +// av_find_best_stream now uses a const AVCodec ** parameter +// for the returned decoder. +#if LIBAVFORMAT_VERSION_INT >= AV_VERSION_INT(59, 0, 100) + const AVCodec *codec; +#else + AVCodec *codec; +#endif + + int stream = + av_find_best_stream(ctx, AVMEDIA_TYPE_VIDEO, -1, -1, &codec, 0); if (stream < 0 ) { LOGE("Could not find best image stream"); goto close_input; @@ -86,12 +91,6 @@ decode_image(const char *path) { AVCodecParameters *params = ctx->streams[stream]->codecpar; - const AVCodec *codec = avcodec_find_decoder(params->codec_id); - if (!codec) { - LOGE("Could not find image decoder"); - goto close_input; - } - AVCodecContext *codec_ctx = avcodec_alloc_context3(codec); if (!codec_ctx) { LOG_OOM(); @@ -111,21 +110,21 @@ decode_image(const char *path) { AVFrame *frame = av_frame_alloc(); if (!frame) { LOG_OOM(); - goto close_codec; + goto free_codec_ctx; } AVPacket *packet = av_packet_alloc(); if (!packet) { LOG_OOM(); av_frame_free(&frame); - goto close_codec; + goto free_codec_ctx; } if (av_read_frame(ctx, packet) < 0) { LOGE("Could not read frame"); av_packet_free(&packet); av_frame_free(&frame); - goto close_codec; + goto free_codec_ctx; } int ret; @@ -133,22 +132,20 @@ decode_image(const char *path) { LOGE("Could not send icon packet: %d", ret); av_packet_free(&packet); av_frame_free(&frame); - goto close_codec; + goto free_codec_ctx; } if ((ret = avcodec_receive_frame(codec_ctx, frame)) != 0) { LOGE("Could not receive icon frame: %d", ret); av_packet_free(&packet); av_frame_free(&frame); - goto close_codec; + goto free_codec_ctx; } av_packet_free(&packet); result = frame; -close_codec: - avcodec_close(codec_ctx); free_codec_ctx: avcodec_free_context(&codec_ctx); close_input: diff --git a/app/src/icon.h b/app/src/icon.h index 3251e48f..6bcf46d2 100644 --- a/app/src/icon.h +++ b/app/src/icon.h @@ -3,9 +3,7 @@ #include "common.h" -#include -#include -#include +#include SDL_Surface * scrcpy_icon_load(void); diff --git a/app/src/input_events.h b/app/src/input_events.h index 5831ba0f..1e34b50e 100644 --- a/app/src/input_events.h +++ b/app/src/input_events.h @@ -322,6 +322,38 @@ enum sc_mouse_button { SC_MOUSE_BUTTON_X2 = SDL_BUTTON(SDL_BUTTON_X2), }; +// Use the naming from SDL3 for gamepad axis and buttons: +// + +enum sc_gamepad_axis { + SC_GAMEPAD_AXIS_UNKNOWN = -1, + SC_GAMEPAD_AXIS_LEFTX = SDL_CONTROLLER_AXIS_LEFTX, + SC_GAMEPAD_AXIS_LEFTY = SDL_CONTROLLER_AXIS_LEFTY, + SC_GAMEPAD_AXIS_RIGHTX = SDL_CONTROLLER_AXIS_RIGHTX, + SC_GAMEPAD_AXIS_RIGHTY = SDL_CONTROLLER_AXIS_RIGHTY, + SC_GAMEPAD_AXIS_LEFT_TRIGGER = SDL_CONTROLLER_AXIS_TRIGGERLEFT, + SC_GAMEPAD_AXIS_RIGHT_TRIGGER = SDL_CONTROLLER_AXIS_TRIGGERRIGHT, +}; + +enum sc_gamepad_button { + SC_GAMEPAD_BUTTON_UNKNOWN = -1, + SC_GAMEPAD_BUTTON_SOUTH = SDL_CONTROLLER_BUTTON_A, + SC_GAMEPAD_BUTTON_EAST = SDL_CONTROLLER_BUTTON_B, + SC_GAMEPAD_BUTTON_WEST = SDL_CONTROLLER_BUTTON_X, + SC_GAMEPAD_BUTTON_NORTH = SDL_CONTROLLER_BUTTON_Y, + SC_GAMEPAD_BUTTON_BACK = SDL_CONTROLLER_BUTTON_BACK, + SC_GAMEPAD_BUTTON_GUIDE = SDL_CONTROLLER_BUTTON_GUIDE, + SC_GAMEPAD_BUTTON_START = SDL_CONTROLLER_BUTTON_START, + SC_GAMEPAD_BUTTON_LEFT_STICK = SDL_CONTROLLER_BUTTON_LEFTSTICK, + SC_GAMEPAD_BUTTON_RIGHT_STICK = SDL_CONTROLLER_BUTTON_RIGHTSTICK, + SC_GAMEPAD_BUTTON_LEFT_SHOULDER = SDL_CONTROLLER_BUTTON_LEFTSHOULDER, + SC_GAMEPAD_BUTTON_RIGHT_SHOULDER = SDL_CONTROLLER_BUTTON_RIGHTSHOULDER, + SC_GAMEPAD_BUTTON_DPAD_UP = SDL_CONTROLLER_BUTTON_DPAD_UP, + SC_GAMEPAD_BUTTON_DPAD_DOWN = SDL_CONTROLLER_BUTTON_DPAD_DOWN, + SC_GAMEPAD_BUTTON_DPAD_LEFT = SDL_CONTROLLER_BUTTON_DPAD_LEFT, + SC_GAMEPAD_BUTTON_DPAD_RIGHT = SDL_CONTROLLER_BUTTON_DPAD_RIGHT, +}; + static_assert(sizeof(enum sc_mod) >= sizeof(SDL_Keymod), "SDL_Keymod must be convertible to sc_mod"); @@ -361,6 +393,8 @@ struct sc_mouse_scroll_event { struct sc_position position; float hscroll; float vscroll; + int32_t hscroll_int; + int32_t vscroll_int; uint8_t buttons_state; // bitwise-OR of sc_mouse_button values }; @@ -379,6 +413,27 @@ struct sc_touch_event { float pressure; }; +// As documented in : +// The ID value starts at 0 and increments from there. The value -1 is an +// invalid ID. +#define SC_GAMEPAD_ID_INVALID UINT32_C(-1) + +struct sc_gamepad_device_event { + uint32_t gamepad_id; +}; + +struct sc_gamepad_button_event { + uint32_t gamepad_id; + enum sc_action action; + enum sc_gamepad_button button; +}; + +struct sc_gamepad_axis_event { + uint32_t gamepad_id; + enum sc_gamepad_axis axis; + int16_t value; +}; + static inline uint16_t sc_mods_state_from_sdl(uint16_t mods_state) { return mods_state; @@ -436,19 +491,40 @@ sc_mouse_button_from_sdl(uint8_t button) { } static inline uint8_t -sc_mouse_buttons_state_from_sdl(uint32_t buttons_state, - bool forward_all_clicks) { +sc_mouse_buttons_state_from_sdl(uint32_t buttons_state) { assert(buttons_state < 0x100); // fits in uint8_t - uint8_t mask = SC_MOUSE_BUTTON_LEFT; - if (forward_all_clicks) { - mask |= SC_MOUSE_BUTTON_RIGHT - | SC_MOUSE_BUTTON_MIDDLE - | SC_MOUSE_BUTTON_X1 - | SC_MOUSE_BUTTON_X2; - } + // SC_MOUSE_BUTTON_* constants are initialized from SDL_BUTTON(index) + return buttons_state; +} - return buttons_state & mask; +static inline enum sc_gamepad_axis +sc_gamepad_axis_from_sdl(uint8_t axis) { + if (axis <= SDL_CONTROLLER_AXIS_TRIGGERRIGHT) { + // SC_GAMEPAD_AXIS_* constants are initialized from + // SDL_CONTROLLER_AXIS_* + return axis; + } + return SC_GAMEPAD_AXIS_UNKNOWN; +} + +static inline enum sc_gamepad_button +sc_gamepad_button_from_sdl(uint8_t button) { + if (button <= SDL_CONTROLLER_BUTTON_DPAD_RIGHT) { + // SC_GAMEPAD_BUTTON_* constants are initialized from + // SDL_CONTROLLER_BUTTON_* + return button; + } + return SC_GAMEPAD_BUTTON_UNKNOWN; +} + +static inline enum sc_action +sc_action_from_sdl_controllerbutton_type(uint32_t type) { + assert(type == SDL_CONTROLLERBUTTONDOWN || type == SDL_CONTROLLERBUTTONUP); + if (type == SDL_CONTROLLERBUTTONDOWN) { + return SC_ACTION_DOWN; + } + return SC_ACTION_UP; } #endif diff --git a/app/src/input_manager.c b/app/src/input_manager.c index 9a487836..3e4dd0f3 100644 --- a/app/src/input_manager.c +++ b/app/src/input_manager.c @@ -1,81 +1,45 @@ #include "input_manager.h" #include -#include +#include +#include +#include +#include "android/input.h" +#include "android/keycodes.h" #include "input_events.h" #include "screen.h" +#include "shortcut_mod.h" #include "util/log.h" -#define SC_SDL_SHORTCUT_MODS_MASK (KMOD_CTRL | KMOD_ALT | KMOD_GUI) - -static inline uint16_t -to_sdl_mod(unsigned shortcut_mod) { - uint16_t sdl_mod = 0; - if (shortcut_mod & SC_SHORTCUT_MOD_LCTRL) { - sdl_mod |= KMOD_LCTRL; - } - if (shortcut_mod & SC_SHORTCUT_MOD_RCTRL) { - sdl_mod |= KMOD_RCTRL; - } - if (shortcut_mod & SC_SHORTCUT_MOD_LALT) { - sdl_mod |= KMOD_LALT; - } - if (shortcut_mod & SC_SHORTCUT_MOD_RALT) { - sdl_mod |= KMOD_RALT; - } - if (shortcut_mod & SC_SHORTCUT_MOD_LSUPER) { - sdl_mod |= KMOD_LGUI; - } - if (shortcut_mod & SC_SHORTCUT_MOD_RSUPER) { - sdl_mod |= KMOD_RGUI; - } - return sdl_mod; -} - -static bool -is_shortcut_mod(struct sc_input_manager *im, uint16_t sdl_mod) { - // keep only the relevant modifier keys - sdl_mod &= SC_SDL_SHORTCUT_MODS_MASK; - - assert(im->sdl_shortcut_mods.count); - assert(im->sdl_shortcut_mods.count < SC_MAX_SHORTCUT_MODS); - for (unsigned i = 0; i < im->sdl_shortcut_mods.count; ++i) { - if (im->sdl_shortcut_mods.data[i] == sdl_mod) { - return true; - } - } - - return false; -} - void sc_input_manager_init(struct sc_input_manager *im, const struct sc_input_manager_params *params) { - assert(!params->controller || (params->kp && params->kp->ops)); - assert(!params->controller || (params->mp && params->mp->ops)); + // A key/mouse processor may not be present if there is no controller + assert((!params->kp && !params->mp && !params->gp) || params->controller); + // A processor must have ops initialized + assert(!params->kp || params->kp->ops); + assert(!params->mp || params->mp->ops); + assert(!params->gp || params->gp->ops); im->controller = params->controller; im->fp = params->fp; im->screen = params->screen; im->kp = params->kp; im->mp = params->mp; + im->gp = params->gp; - im->forward_all_clicks = params->forward_all_clicks; + im->mouse_bindings = params->mouse_bindings; im->legacy_paste = params->legacy_paste; im->clipboard_autosync = params->clipboard_autosync; - const struct sc_shortcut_mods *shortcut_mods = params->shortcut_mods; - assert(shortcut_mods->count); - assert(shortcut_mods->count < SC_MAX_SHORTCUT_MODS); - for (unsigned i = 0; i < shortcut_mods->count; ++i) { - uint16_t sdl_mod = to_sdl_mod(shortcut_mods->data[i]); - assert(sdl_mod); - im->sdl_shortcut_mods.data[i] = sdl_mod; - } - im->sdl_shortcut_mods.count = shortcut_mods->count; + im->sdl_shortcut_mods = sc_shortcut_mods_to_sdl(params->shortcut_mods); im->vfinger_down = false; + im->vfinger_invert_x = false; + im->vfinger_invert_y = false; + + im->mouse_buttons_state = 0; im->last_keycode = SDLK_UNKNOWN; im->last_mod = 0; @@ -85,8 +49,10 @@ sc_input_manager_init(struct sc_input_manager *im, } static void -send_keycode(struct sc_controller *controller, enum android_keycode keycode, +send_keycode(struct sc_input_manager *im, enum android_keycode keycode, enum sc_action action, const char *name) { + assert(im->controller && im->kp); + // send DOWN event struct sc_control_msg msg; msg.type = SC_CONTROL_MSG_TYPE_INJECT_KEYCODE; @@ -97,100 +63,109 @@ send_keycode(struct sc_controller *controller, enum android_keycode keycode, msg.inject_keycode.metastate = 0; msg.inject_keycode.repeat = 0; - if (!sc_controller_push_msg(controller, &msg)) { + if (!sc_controller_push_msg(im->controller, &msg)) { LOGW("Could not request 'inject %s'", name); } } static inline void -action_home(struct sc_controller *controller, enum sc_action action) { - send_keycode(controller, AKEYCODE_HOME, action, "HOME"); +action_home(struct sc_input_manager *im, enum sc_action action) { + send_keycode(im, AKEYCODE_HOME, action, "HOME"); } static inline void -action_back(struct sc_controller *controller, enum sc_action action) { - send_keycode(controller, AKEYCODE_BACK, action, "BACK"); +action_back(struct sc_input_manager *im, enum sc_action action) { + send_keycode(im, AKEYCODE_BACK, action, "BACK"); } static inline void -action_app_switch(struct sc_controller *controller, enum sc_action action) { - send_keycode(controller, AKEYCODE_APP_SWITCH, action, "APP_SWITCH"); +action_app_switch(struct sc_input_manager *im, enum sc_action action) { + send_keycode(im, AKEYCODE_APP_SWITCH, action, "APP_SWITCH"); } static inline void -action_power(struct sc_controller *controller, enum sc_action action) { - send_keycode(controller, AKEYCODE_POWER, action, "POWER"); +action_power(struct sc_input_manager *im, enum sc_action action) { + send_keycode(im, AKEYCODE_POWER, action, "POWER"); } static inline void -action_volume_up(struct sc_controller *controller, enum sc_action action) { - send_keycode(controller, AKEYCODE_VOLUME_UP, action, "VOLUME_UP"); +action_volume_up(struct sc_input_manager *im, enum sc_action action) { + send_keycode(im, AKEYCODE_VOLUME_UP, action, "VOLUME_UP"); } static inline void -action_volume_down(struct sc_controller *controller, enum sc_action action) { - send_keycode(controller, AKEYCODE_VOLUME_DOWN, action, "VOLUME_DOWN"); +action_volume_down(struct sc_input_manager *im, enum sc_action action) { + send_keycode(im, AKEYCODE_VOLUME_DOWN, action, "VOLUME_DOWN"); } static inline void -action_menu(struct sc_controller *controller, enum sc_action action) { - send_keycode(controller, AKEYCODE_MENU, action, "MENU"); +action_menu(struct sc_input_manager *im, enum sc_action action) { + send_keycode(im, AKEYCODE_MENU, action, "MENU"); } // turn the screen on if it was off, press BACK otherwise // If the screen is off, it is turned on only on ACTION_DOWN static void -press_back_or_turn_screen_on(struct sc_controller *controller, +press_back_or_turn_screen_on(struct sc_input_manager *im, enum sc_action action) { + assert(im->controller && im->kp); + struct sc_control_msg msg; msg.type = SC_CONTROL_MSG_TYPE_BACK_OR_SCREEN_ON; msg.back_or_screen_on.action = action == SC_ACTION_DOWN ? AKEY_EVENT_ACTION_DOWN : AKEY_EVENT_ACTION_UP; - if (!sc_controller_push_msg(controller, &msg)) { + if (!sc_controller_push_msg(im->controller, &msg)) { LOGW("Could not request 'press back or turn screen on'"); } } static void -expand_notification_panel(struct sc_controller *controller) { +expand_notification_panel(struct sc_input_manager *im) { + assert(im->controller); + struct sc_control_msg msg; msg.type = SC_CONTROL_MSG_TYPE_EXPAND_NOTIFICATION_PANEL; - if (!sc_controller_push_msg(controller, &msg)) { + if (!sc_controller_push_msg(im->controller, &msg)) { LOGW("Could not request 'expand notification panel'"); } } static void -expand_settings_panel(struct sc_controller *controller) { +expand_settings_panel(struct sc_input_manager *im) { + assert(im->controller); + struct sc_control_msg msg; msg.type = SC_CONTROL_MSG_TYPE_EXPAND_SETTINGS_PANEL; - if (!sc_controller_push_msg(controller, &msg)) { + if (!sc_controller_push_msg(im->controller, &msg)) { LOGW("Could not request 'expand settings panel'"); } } static void -collapse_panels(struct sc_controller *controller) { +collapse_panels(struct sc_input_manager *im) { + assert(im->controller); + struct sc_control_msg msg; msg.type = SC_CONTROL_MSG_TYPE_COLLAPSE_PANELS; - if (!sc_controller_push_msg(controller, &msg)) { + if (!sc_controller_push_msg(im->controller, &msg)) { LOGW("Could not request 'collapse notification panel'"); } } static bool -get_device_clipboard(struct sc_controller *controller, - enum sc_copy_key copy_key) { +get_device_clipboard(struct sc_input_manager *im, enum sc_copy_key copy_key) { + assert(im->controller && im->kp); + struct sc_control_msg msg; msg.type = SC_CONTROL_MSG_TYPE_GET_CLIPBOARD; msg.get_clipboard.copy_key = copy_key; - if (!sc_controller_push_msg(controller, &msg)) { + if (!sc_controller_push_msg(im->controller, &msg)) { LOGW("Could not request 'get device clipboard'"); return false; } @@ -199,8 +174,10 @@ get_device_clipboard(struct sc_controller *controller, } static bool -set_device_clipboard(struct sc_controller *controller, bool paste, +set_device_clipboard(struct sc_input_manager *im, bool paste, uint64_t sequence) { + assert(im->controller && im->kp); + char *text = SDL_GetClipboardText(); if (!text) { LOGW("Could not get clipboard text: %s", SDL_GetError()); @@ -220,7 +197,7 @@ set_device_clipboard(struct sc_controller *controller, bool paste, msg.set_clipboard.text = text_dup; msg.set_clipboard.paste = paste; - if (!sc_controller_push_msg(controller, &msg)) { + if (!sc_controller_push_msg(im->controller, &msg)) { free(text_dup); LOGW("Could not request 'set device clipboard'"); return false; @@ -230,19 +207,22 @@ set_device_clipboard(struct sc_controller *controller, bool paste, } static void -set_screen_power_mode(struct sc_controller *controller, - enum sc_screen_power_mode mode) { - struct sc_control_msg msg; - msg.type = SC_CONTROL_MSG_TYPE_SET_SCREEN_POWER_MODE; - msg.set_screen_power_mode.mode = mode; +set_display_power(struct sc_input_manager *im, bool on) { + assert(im->controller); - if (!sc_controller_push_msg(controller, &msg)) { + struct sc_control_msg msg; + 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'"); } } static void -switch_fps_counter_state(struct sc_fps_counter *fps_counter) { +switch_fps_counter_state(struct sc_input_manager *im) { + struct sc_fps_counter *fps_counter = &im->screen->fps_counter; + // the started state can only be written from the current thread, so there // is no ToCToU issue if (sc_fps_counter_is_started(fps_counter)) { @@ -254,7 +234,9 @@ switch_fps_counter_state(struct sc_fps_counter *fps_counter) { } static void -clipboard_paste(struct sc_controller *controller) { +clipboard_paste(struct sc_input_manager *im) { + assert(im->controller && im->kp); + char *text = SDL_GetClipboardText(); if (!text) { LOGW("Could not get clipboard text: %s", SDL_GetError()); @@ -276,25 +258,52 @@ clipboard_paste(struct sc_controller *controller) { struct sc_control_msg msg; msg.type = SC_CONTROL_MSG_TYPE_INJECT_TEXT; msg.inject_text.text = text_dup; - if (!sc_controller_push_msg(controller, &msg)) { + if (!sc_controller_push_msg(im->controller, &msg)) { free(text_dup); LOGW("Could not request 'paste clipboard'"); } } static void -rotate_device(struct sc_controller *controller) { +rotate_device(struct sc_input_manager *im) { + assert(im->controller); + struct sc_control_msg msg; msg.type = SC_CONTROL_MSG_TYPE_ROTATE_DEVICE; - if (!sc_controller_push_msg(controller, &msg)) { + if (!sc_controller_push_msg(im->controller, &msg)) { LOGW("Could not request device rotation"); } } static void -apply_orientation_transform(struct sc_screen *screen, +open_hard_keyboard_settings(struct sc_input_manager *im) { + assert(im->controller); + + struct sc_control_msg msg; + msg.type = SC_CONTROL_MSG_TYPE_OPEN_HARD_KEYBOARD_SETTINGS; + + if (!sc_controller_push_msg(im->controller, &msg)) { + LOGW("Could not request opening hard keyboard settings"); + } +} + +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) { + struct sc_screen *screen = im->screen; enum sc_orientation new_orientation = sc_orientation_apply(screen->orientation, transform); sc_screen_set_orientation(screen, new_orientation); @@ -308,7 +317,8 @@ sc_input_manager_process_text_input(struct sc_input_manager *im, return; } - if (is_shortcut_mod(im, SDL_GetModState())) { + if (sc_shortcut_mods_is_shortcut_mod(im->sdl_shortcut_mods, + SDL_GetModState())) { // A shortcut must never generate text events return; } @@ -331,9 +341,7 @@ simulate_virtual_finger(struct sc_input_manager *im, msg.inject_touch_event.action = action; msg.inject_touch_event.position.screen_size = im->screen->frame_size; msg.inject_touch_event.position.point = point; - msg.inject_touch_event.pointer_id = - im->forward_all_clicks ? POINTER_ID_VIRTUAL_MOUSE - : POINTER_ID_VIRTUAL_FINGER; + msg.inject_touch_event.pointer_id = SC_POINTER_ID_VIRTUAL_FINGER; msg.inject_touch_event.pressure = up ? 0.0f : 1.0f; msg.inject_touch_event.action_button = 0; msg.inject_touch_event.buttons = 0; @@ -347,9 +355,14 @@ simulate_virtual_finger(struct sc_input_manager *im, } static struct sc_point -inverse_point(struct sc_point point, struct sc_size size) { - point.x = size.width - point.x; - point.y = size.height - point.y; +inverse_point(struct sc_point point, struct sc_size size, + bool invert_x, bool invert_y) { + if (invert_x) { + point.x = size.width - point.x; + } + if (invert_y) { + point.y = size.height - point.y; + } return point; } @@ -357,166 +370,186 @@ static void sc_input_manager_process_key(struct sc_input_manager *im, const SDL_KeyboardEvent *event) { // controller is NULL if --no-control is requested - struct sc_controller *controller = im->controller; + bool control = im->controller; + bool paused = im->screen->paused; + bool video = im->screen->video; - SDL_Keycode keycode = event->keysym.sym; + SDL_Keycode sdl_keycode = event->keysym.sym; uint16_t mod = event->keysym.mod; bool down = event->type == SDL_KEYDOWN; bool ctrl = event->keysym.mod & KMOD_CTRL; bool shift = event->keysym.mod & KMOD_SHIFT; bool repeat = event->repeat; - bool smod = is_shortcut_mod(im, mod); + // Either the modifier includes a shortcut modifier, or the key + // press/release is a modifier key. + // The second condition is necessary to ignore the release of the modifier + // key (because in this case mod is 0). + uint16_t mods = im->sdl_shortcut_mods; + bool is_shortcut = sc_shortcut_mods_is_shortcut_mod(mods, mod) + || sc_shortcut_mods_is_shortcut_key(mods, sdl_keycode); if (down && !repeat) { - if (keycode == im->last_keycode && mod == im->last_mod) { + if (sdl_keycode == im->last_keycode && mod == im->last_mod) { ++im->key_repeat; } else { im->key_repeat = 0; - im->last_keycode = keycode; + im->last_keycode = sdl_keycode; im->last_mod = mod; } } - // The shortcut modifier is pressed - if (smod) { + if (is_shortcut) { enum sc_action action = down ? SC_ACTION_DOWN : SC_ACTION_UP; - switch (keycode) { + switch (sdl_keycode) { case SDLK_h: - if (controller && !shift && !repeat) { - action_home(controller, action); + if (im->kp && !shift && !repeat && !paused) { + action_home(im, action); } return; case SDLK_b: // fall-through case SDLK_BACKSPACE: - if (controller && !shift && !repeat) { - action_back(controller, action); + if (im->kp && !shift && !repeat && !paused) { + action_back(im, action); } return; case SDLK_s: - if (controller && !shift && !repeat) { - action_app_switch(controller, action); + if (im->kp && !shift && !repeat && !paused) { + action_app_switch(im, action); } return; case SDLK_m: - if (controller && !shift && !repeat) { - action_menu(controller, action); + if (im->kp && !shift && !repeat && !paused) { + action_menu(im, action); } return; case SDLK_p: - if (controller && !shift && !repeat) { - action_power(controller, action); + if (im->kp && !shift && !repeat && !paused) { + action_power(im, action); } return; case SDLK_o: - if (controller && !repeat && down) { - enum sc_screen_power_mode mode = shift - ? SC_SCREEN_POWER_MODE_NORMAL - : SC_SCREEN_POWER_MODE_OFF; - set_screen_power_mode(controller, mode); + if (control && !repeat && down && !paused) { + bool on = shift; + set_display_power(im, on); + } + return; + case SDLK_z: + if (video && down && !repeat) { + sc_screen_set_paused(im->screen, !shift); } return; case SDLK_DOWN: if (shift) { - if (!repeat & down) { - apply_orientation_transform(im->screen, + if (video && !repeat && down) { + apply_orientation_transform(im, SC_ORIENTATION_FLIP_180); } - } else if (controller) { + } else if (im->kp && !paused) { // forward repeated events - action_volume_down(controller, action); + action_volume_down(im, action); } return; case SDLK_UP: if (shift) { - if (!repeat & down) { - apply_orientation_transform(im->screen, + if (video && !repeat && down) { + apply_orientation_transform(im, SC_ORIENTATION_FLIP_180); } - } else if (controller) { + } else if (im->kp && !paused) { // forward repeated events - action_volume_up(controller, action); + action_volume_up(im, action); } return; case SDLK_LEFT: - if (!repeat && down) { + if (video && !repeat && down) { if (shift) { - apply_orientation_transform(im->screen, + apply_orientation_transform(im, SC_ORIENTATION_FLIP_0); } else { - apply_orientation_transform(im->screen, + apply_orientation_transform(im, SC_ORIENTATION_270); } } return; case SDLK_RIGHT: - if (!repeat && down) { + if (video && !repeat && down) { if (shift) { - apply_orientation_transform(im->screen, + apply_orientation_transform(im, SC_ORIENTATION_FLIP_0); } else { - apply_orientation_transform(im->screen, + apply_orientation_transform(im, SC_ORIENTATION_90); } } return; case SDLK_c: - if (controller && !shift && !repeat && down) { - get_device_clipboard(controller, SC_COPY_KEY_COPY); + if (im->kp && !shift && !repeat && down && !paused) { + get_device_clipboard(im, SC_COPY_KEY_COPY); } return; case SDLK_x: - if (controller && !shift && !repeat && down) { - get_device_clipboard(controller, SC_COPY_KEY_CUT); + if (im->kp && !shift && !repeat && down && !paused) { + get_device_clipboard(im, SC_COPY_KEY_CUT); } return; case SDLK_v: - if (controller && !repeat && down) { + if (im->kp && !repeat && down && !paused) { if (shift || im->legacy_paste) { // inject the text as input events - clipboard_paste(controller); + clipboard_paste(im); } else { // store the text in the device clipboard and paste, // without requesting an acknowledgment - set_device_clipboard(controller, true, - SC_SEQUENCE_INVALID); + set_device_clipboard(im, true, SC_SEQUENCE_INVALID); } } return; case SDLK_f: - if (!shift && !repeat && down) { - sc_screen_switch_fullscreen(im->screen); + if (video && !shift && !repeat && down) { + sc_screen_toggle_fullscreen(im->screen); } return; case SDLK_w: - if (!shift && !repeat && down) { + if (video && !shift && !repeat && down) { sc_screen_resize_to_fit(im->screen); } return; case SDLK_g: - if (!shift && !repeat && down) { + if (video && !shift && !repeat && down) { sc_screen_resize_to_pixel_perfect(im->screen); } return; case SDLK_i: - if (!shift && !repeat && down) { - switch_fps_counter_state(&im->screen->fps_counter); + if (video && !shift && !repeat && down) { + switch_fps_counter_state(im); } return; case SDLK_n: - if (controller && !repeat && down) { + if (control && !repeat && down && !paused) { if (shift) { - collapse_panels(controller); + collapse_panels(im); } else if (im->key_repeat == 0) { - expand_notification_panel(controller); + expand_notification_panel(im); } else { - expand_settings_panel(controller); + expand_settings_panel(im); } } return; case SDLK_r: - if (controller && !shift && !repeat && down) { - rotate_device(controller); + if (control && !repeat && down && !paused) { + if (shift) { + reset_video(im); + } else { + rotate_device(im); + } + } + return; + case SDLK_k: + if (control && !shift && !repeat && down && !paused + && im->kp && im->kp->hid) { + // Only if the current keyboard is hid + open_hard_keyboard_settings(im); } return; } @@ -524,16 +557,16 @@ sc_input_manager_process_key(struct sc_input_manager *im, return; } - if (!controller) { + if (!im->kp || paused) { return; } uint64_t ack_to_wait = SC_SEQUENCE_INVALID; - bool is_ctrl_v = ctrl && !shift && keycode == SDLK_v && down && !repeat; + bool is_ctrl_v = ctrl && !shift && sdl_keycode == SDLK_v && down && !repeat; if (im->clipboard_autosync && is_ctrl_v) { if (im->legacy_paste) { // inject the text as input events - clipboard_paste(controller); + clipboard_paste(im); return; } @@ -543,7 +576,7 @@ sc_input_manager_process_key(struct sc_input_manager *im, // Synchronize the computer clipboard to the device clipboard before // sending Ctrl+v, to allow seamless copy-paste. - bool ok = set_device_clipboard(controller, false, sequence); + bool ok = set_device_clipboard(im, false, sequence); if (!ok) { LOGW("Clipboard could not be synchronized, Ctrl+v not injected"); return; @@ -557,10 +590,20 @@ sc_input_manager_process_key(struct sc_input_manager *im, } } + enum sc_keycode keycode = sc_keycode_from_sdl(sdl_keycode); + if (keycode == SC_KEYCODE_UNKNOWN) { + return; + } + + enum sc_scancode scancode = sc_scancode_from_sdl(event->keysym.scancode); + if (scancode == SC_SCANCODE_UNKNOWN) { + return; + } + struct sc_key_event evt = { .action = sc_action_from_sdl_keyboard_type(event->type), - .keycode = sc_keycode_from_sdl(event->keysym.sym), - .scancode = sc_scancode_from_sdl(event->keysym.scancode), + .keycode = keycode, + .scancode = scancode, .repeat = event->repeat, .mods_state = sc_mods_state_from_sdl(event->keysym.mod), }; @@ -569,29 +612,38 @@ sc_input_manager_process_key(struct sc_input_manager *im, im->kp->ops->process_key(im->kp, &evt, ack_to_wait); } +static struct sc_position +sc_input_manager_get_position(struct sc_input_manager *im, int32_t x, + int32_t y) { + if (im->mp->relative_mode) { + // No absolute position + return (struct sc_position) { + .screen_size = {0, 0}, + .point = {0, 0}, + }; + } + + return (struct sc_position) { + .screen_size = im->screen->frame_size, + .point = sc_screen_convert_window_to_frame_coords(im->screen, x, y), + }; +} + static void sc_input_manager_process_mouse_motion(struct sc_input_manager *im, const SDL_MouseMotionEvent *event) { - if (event->which == SDL_TOUCH_MOUSEID) { // simulated from touch events, so it's a duplicate return; } struct sc_mouse_motion_event evt = { - .position = { - .screen_size = im->screen->frame_size, - .point = sc_screen_convert_window_to_frame_coords(im->screen, - event->x, - event->y), - }, - .pointer_id = im->forward_all_clicks ? POINTER_ID_MOUSE - : POINTER_ID_GENERIC_FINGER, + .position = sc_input_manager_get_position(im, event->x, event->y), + .pointer_id = im->vfinger_down ? SC_POINTER_ID_GENERIC_FINGER + : SC_POINTER_ID_MOUSE, .xrel = event->xrel, .yrel = event->yrel, - .buttons_state = - sc_mouse_buttons_state_from_sdl(event->state, - im->forward_all_clicks), + .buttons_state = im->mouse_buttons_state, }; assert(im->mp->ops->process_mouse_motion); @@ -605,7 +657,9 @@ sc_input_manager_process_mouse_motion(struct sc_input_manager *im, struct sc_point mouse = sc_screen_convert_window_to_frame_coords(im->screen, event->x, event->y); - struct sc_point vfinger = inverse_point(mouse, im->screen->frame_size); + struct sc_point vfinger = inverse_point(mouse, im->screen->frame_size, + im->vfinger_invert_x, + im->vfinger_invert_y); simulate_virtual_finger(im, AMOTION_EVENT_ACTION_MOVE, vfinger); } } @@ -640,81 +694,134 @@ sc_input_manager_process_touch(struct sc_input_manager *im, im->mp->ops->process_touch(im->mp, &evt); } +static enum sc_mouse_binding +sc_input_manager_get_binding(const struct sc_mouse_binding_set *bindings, + uint8_t sdl_button) { + switch (sdl_button) { + case SDL_BUTTON_LEFT: + return SC_MOUSE_BINDING_CLICK; + case SDL_BUTTON_RIGHT: + return bindings->right_click; + case SDL_BUTTON_MIDDLE: + return bindings->middle_click; + case SDL_BUTTON_X1: + return bindings->click4; + case SDL_BUTTON_X2: + return bindings->click5; + default: + return SC_MOUSE_BINDING_DISABLED; + } +} + static void sc_input_manager_process_mouse_button(struct sc_input_manager *im, const SDL_MouseButtonEvent *event) { - struct sc_controller *controller = im->controller; - if (event->which == SDL_TOUCH_MOUSEID) { // simulated from touch events, so it's a duplicate return; } + bool control = im->controller; + bool paused = im->screen->paused; bool down = event->type == SDL_MOUSEBUTTONDOWN; - if (!im->forward_all_clicks) { - if (controller) { - enum sc_action action = down ? SC_ACTION_DOWN : SC_ACTION_UP; - if (event->button == SDL_BUTTON_X1) { - action_app_switch(controller, action); - return; - } - if (event->button == SDL_BUTTON_X2 && down) { - if (event->clicks < 2) { - expand_notification_panel(controller); - } else { - expand_settings_panel(controller); - } - return; - } - if (event->button == SDL_BUTTON_RIGHT) { - press_back_or_turn_screen_on(controller, action); - return; - } - if (event->button == SDL_BUTTON_MIDDLE) { - action_home(controller, action); - return; - } - } - - // double-click on black borders resize to fit the device screen - if (event->button == SDL_BUTTON_LEFT && event->clicks == 2) { - int32_t x = event->x; - int32_t y = event->y; - sc_screen_hidpi_scale_coords(im->screen, &x, &y); - SDL_Rect *r = &im->screen->rect; - bool outside = x < r->x || x >= r->x + r->w - || y < r->y || y >= r->y + r->h; - if (outside) { - if (down) { - sc_screen_resize_to_fit(im->screen); - } - return; - } - } - // otherwise, send the click event to the device - } - - if (!controller) { + enum sc_mouse_button button = sc_mouse_button_from_sdl(event->button); + if (button == SC_MOUSE_BUTTON_UNKNOWN) { return; } - uint32_t sdl_buttons_state = SDL_GetMouseState(NULL, NULL); + if (!down) { + // Mark the button as released + im->mouse_buttons_state &= ~button; + } + + SDL_Keymod keymod = SDL_GetModState(); + bool ctrl_pressed = keymod & KMOD_CTRL; + bool shift_pressed = keymod & KMOD_SHIFT; + + if (control && !paused) { + enum sc_action action = down ? SC_ACTION_DOWN : SC_ACTION_UP; + + struct sc_mouse_binding_set *bindings = !shift_pressed + ? &im->mouse_bindings.pri + : &im->mouse_bindings.sec; + enum sc_mouse_binding binding = + sc_input_manager_get_binding(bindings, event->button); + assert(binding != SC_MOUSE_BINDING_AUTO); + switch (binding) { + case SC_MOUSE_BINDING_DISABLED: + // ignore click + return; + case SC_MOUSE_BINDING_BACK: + if (im->kp) { + press_back_or_turn_screen_on(im, action); + } + return; + case SC_MOUSE_BINDING_HOME: + if (im->kp) { + action_home(im, action); + } + return; + case SC_MOUSE_BINDING_APP_SWITCH: + if (im->kp) { + action_app_switch(im, action); + } + return; + case SC_MOUSE_BINDING_EXPAND_NOTIFICATION_PANEL: + if (down) { + if (event->clicks < 2) { + expand_notification_panel(im); + } else { + expand_settings_panel(im); + } + } + return; + default: + assert(binding == SC_MOUSE_BINDING_CLICK); + break; + } + } + + // double-click on black borders resizes to fit the device screen + bool video = im->screen->video; + bool mouse_relative_mode = im->mp && im->mp->relative_mode; + if (video && !mouse_relative_mode && event->button == SDL_BUTTON_LEFT + && event->clicks == 2) { + int32_t x = event->x; + int32_t y = event->y; + sc_screen_hidpi_scale_coords(im->screen, &x, &y); + SDL_Rect *r = &im->screen->rect; + bool outside = x < r->x || x >= r->x + r->w + || y < r->y || y >= r->y + r->h; + if (outside) { + if (down) { + sc_screen_resize_to_fit(im->screen); + } + return; + } + } + + if (!im->mp || paused) { + return; + } + + if (down) { + // Mark the button as pressed + im->mouse_buttons_state |= button; + } + + bool change_vfinger = event->button == SDL_BUTTON_LEFT && + ((down && !im->vfinger_down && (ctrl_pressed || shift_pressed)) || + (!down && im->vfinger_down)); + bool use_finger = im->vfinger_down || change_vfinger; struct sc_mouse_click_event evt = { - .position = { - .screen_size = im->screen->frame_size, - .point = sc_screen_convert_window_to_frame_coords(im->screen, - event->x, - event->y), - }, + .position = sc_input_manager_get_position(im, event->x, event->y), .action = sc_action_from_sdl_mousebutton_type(event->type), - .button = sc_mouse_button_from_sdl(event->button), - .pointer_id = im->forward_all_clicks ? POINTER_ID_MOUSE - : POINTER_ID_GENERIC_FINGER, - .buttons_state = - sc_mouse_buttons_state_from_sdl(sdl_buttons_state, - im->forward_all_clicks), + .button = button, + .pointer_id = use_finger ? SC_POINTER_ID_GENERIC_FINGER + : SC_POINTER_ID_MOUSE, + .buttons_state = im->mouse_buttons_state, }; assert(im->mp->ops->process_mouse_click); @@ -726,7 +833,7 @@ sc_input_manager_process_mouse_button(struct sc_input_manager *im, return; } - // Pinch-to-zoom simulation. + // Pinch-to-zoom, rotate and tilt simulation. // // If Ctrl is hold when the left-click button is pressed, then // pinch-to-zoom mode is enabled: on every mouse event until the left-click @@ -735,14 +842,34 @@ sc_input_manager_process_mouse_button(struct sc_input_manager *im, // // In other words, the center of the rotation/scaling is the center of the // screen. -#define CTRL_PRESSED (SDL_GetModState() & (KMOD_LCTRL | KMOD_RCTRL)) - if (event->button == SDL_BUTTON_LEFT && - ((down && !im->vfinger_down && CTRL_PRESSED) || - (!down && im->vfinger_down))) { + // + // To simulate a vertical tilt gesture (a vertical slide with two fingers), + // Shift can be used instead of Ctrl. The "virtual finger" has a position + // inverted with respect to the vertical axis of symmetry in the middle of + // the screen. + // + // To simulate a horizontal tilt gesture (a horizontal slide with two + // fingers), Ctrl+Shift can be used. The "virtual finger" has a position + // inverted with respect to the horizontal axis of symmetry in the middle + // of the screen. It is expected to be less frequently used, that's why the + // one-mod shortcuts are assigned to rotation and vertical tilt. + if (change_vfinger) { struct sc_point mouse = sc_screen_convert_window_to_frame_coords(im->screen, event->x, event->y); - struct sc_point vfinger = inverse_point(mouse, im->screen->frame_size); + if (down) { + // Ctrl Shift invert_x invert_y + // ---- ----- ==> -------- -------- + // 0 0 0 0 - + // 0 1 1 0 vertical tilt + // 1 0 1 1 rotate + // 1 1 0 1 horizontal tilt + im->vfinger_invert_x = ctrl_pressed ^ shift_pressed; + im->vfinger_invert_y = ctrl_pressed; + } + struct sc_point vfinger = inverse_point(mouse, im->screen->frame_size, + im->vfinger_invert_x, + im->vfinger_invert_y); enum android_motionevent_action action = down ? AMOTION_EVENT_ACTION_DOWN : AMOTION_EVENT_ACTION_UP; @@ -765,27 +892,98 @@ sc_input_manager_process_mouse_wheel(struct sc_input_manager *im, int mouse_x; int mouse_y; uint32_t buttons = SDL_GetMouseState(&mouse_x, &mouse_y); + (void) buttons; // Actual buttons are tracked manually to ignore shortcuts struct sc_mouse_scroll_event evt = { - .position = { - .screen_size = im->screen->frame_size, - .point = sc_screen_convert_window_to_frame_coords(im->screen, - mouse_x, mouse_y), - }, + .position = sc_input_manager_get_position(im, mouse_x, mouse_y), #if SDL_VERSION_ATLEAST(2, 0, 18) - .hscroll = CLAMP(event->preciseX, -1.0f, 1.0f), - .vscroll = CLAMP(event->preciseY, -1.0f, 1.0f), + .hscroll = event->preciseX, + .vscroll = event->preciseY, #else - .hscroll = CLAMP(event->x, -1, 1), - .vscroll = CLAMP(event->y, -1, 1), + .hscroll = event->x, + .vscroll = event->y, #endif - .buttons_state = - sc_mouse_buttons_state_from_sdl(buttons, im->forward_all_clicks), + .hscroll_int = event->x, + .vscroll_int = event->y, + .buttons_state = im->mouse_buttons_state, }; im->mp->ops->process_mouse_scroll(im->mp, &evt); } +static void +sc_input_manager_process_gamepad_device(struct sc_input_manager *im, + const SDL_ControllerDeviceEvent *event) { + if (event->type == SDL_CONTROLLERDEVICEADDED) { + SDL_GameController *gc = SDL_GameControllerOpen(event->which); + if (!gc) { + LOGW("Could not open game controller"); + return; + } + + SDL_Joystick *joystick = SDL_GameControllerGetJoystick(gc); + if (!joystick) { + LOGW("Could not get controller joystick"); + SDL_GameControllerClose(gc); + return; + } + + struct sc_gamepad_device_event evt = { + .gamepad_id = SDL_JoystickInstanceID(joystick), + }; + im->gp->ops->process_gamepad_added(im->gp, &evt); + } else if (event->type == SDL_CONTROLLERDEVICEREMOVED) { + SDL_JoystickID id = event->which; + + SDL_GameController *gc = SDL_GameControllerFromInstanceID(id); + if (gc) { + SDL_GameControllerClose(gc); + } else { + LOGW("Unknown gamepad device removed"); + } + + struct sc_gamepad_device_event evt = { + .gamepad_id = id, + }; + im->gp->ops->process_gamepad_removed(im->gp, &evt); + } else { + // Nothing to do + return; + } +} + +static void +sc_input_manager_process_gamepad_axis(struct sc_input_manager *im, + const SDL_ControllerAxisEvent *event) { + enum sc_gamepad_axis axis = sc_gamepad_axis_from_sdl(event->axis); + if (axis == SC_GAMEPAD_AXIS_UNKNOWN) { + return; + } + + struct sc_gamepad_axis_event evt = { + .gamepad_id = event->which, + .axis = axis, + .value = event->value, + }; + im->gp->ops->process_gamepad_axis(im->gp, &evt); +} + +static void +sc_input_manager_process_gamepad_button(struct sc_input_manager *im, + const SDL_ControllerButtonEvent *event) { + enum sc_gamepad_button button = sc_gamepad_button_from_sdl(event->button); + if (button == SC_GAMEPAD_BUTTON_UNKNOWN) { + return; + } + + struct sc_gamepad_button_event evt = { + .gamepad_id = event->which, + .action = sc_action_from_sdl_controllerbutton_type(event->type), + .button = button, + }; + im->gp->ops->process_gamepad_button(im->gp, &evt); +} + static bool is_apk(const char *file) { const char *ext = strrchr(file, '.'); @@ -818,9 +1016,10 @@ void sc_input_manager_handle_event(struct sc_input_manager *im, const SDL_Event *event) { bool control = im->controller; + bool paused = im->screen->paused; switch (event->type) { case SDL_TEXTINPUT: - if (!control) { + if (!im->kp || paused) { break; } sc_input_manager_process_text_input(im, &event->text); @@ -832,13 +1031,13 @@ sc_input_manager_handle_event(struct sc_input_manager *im, sc_input_manager_process_key(im, &event->key); break; case SDL_MOUSEMOTION: - if (!control) { + if (!im->mp || paused) { break; } sc_input_manager_process_mouse_motion(im, &event->motion); break; case SDL_MOUSEWHEEL: - if (!control) { + if (!im->mp || paused) { break; } sc_input_manager_process_mouse_wheel(im, &event->wheel); @@ -852,11 +1051,32 @@ sc_input_manager_handle_event(struct sc_input_manager *im, case SDL_FINGERMOTION: case SDL_FINGERDOWN: case SDL_FINGERUP: - if (!control) { + if (!im->mp || paused) { break; } sc_input_manager_process_touch(im, &event->tfinger); break; + case SDL_CONTROLLERDEVICEADDED: + case SDL_CONTROLLERDEVICEREMOVED: + // Handle device added or removed even if paused + if (!im->gp) { + break; + } + sc_input_manager_process_gamepad_device(im, &event->cdevice); + break; + case SDL_CONTROLLERAXISMOTION: + if (!im->gp || paused) { + break; + } + sc_input_manager_process_gamepad_axis(im, &event->caxis); + break; + case SDL_CONTROLLERBUTTONDOWN: + case SDL_CONTROLLERBUTTONUP: + if (!im->gp || paused) { + break; + } + sc_input_manager_process_gamepad_button(im, &event->cbutton); + break; case SDL_DROPFILE: { if (!control) { break; diff --git a/app/src/input_manager.h b/app/src/input_manager.h index b5a762eb..af4cbc69 100644 --- a/app/src/input_manager.h +++ b/app/src/input_manager.h @@ -4,13 +4,14 @@ #include "common.h" #include - -#include +#include +#include +#include #include "controller.h" #include "file_pusher.h" -#include "fps_counter.h" #include "options.h" +#include "trait/gamepad_processor.h" #include "trait/key_processor.h" #include "trait/mouse_processor.h" @@ -21,17 +22,19 @@ struct sc_input_manager { struct sc_key_processor *kp; struct sc_mouse_processor *mp; + struct sc_gamepad_processor *gp; - bool forward_all_clicks; + struct sc_mouse_bindings mouse_bindings; bool legacy_paste; bool clipboard_autosync; - struct { - unsigned data[SC_MAX_SHORTCUT_MODS]; - unsigned count; - } sdl_shortcut_mods; + uint16_t sdl_shortcut_mods; bool vfinger_down; + bool vfinger_invert_x; + bool vfinger_invert_y; + + uint8_t mouse_buttons_state; // OR of enum sc_mouse_button values // Tracks the number of identical consecutive shortcut key down events. // Not to be confused with event->repeat, which counts the number of @@ -49,11 +52,12 @@ struct sc_input_manager_params { struct sc_screen *screen; struct sc_key_processor *kp; struct sc_mouse_processor *mp; + struct sc_gamepad_processor *gp; - bool forward_all_clicks; + struct sc_mouse_bindings mouse_bindings; bool legacy_paste; bool clipboard_autosync; - const struct sc_shortcut_mods *shortcut_mods; + uint8_t shortcut_mods; // OR of enum sc_shortcut_mod values }; void diff --git a/app/src/keyboard_inject.c b/app/src/keyboard_sdk.c similarity index 88% rename from app/src/keyboard_inject.c rename to app/src/keyboard_sdk.c index fe297310..466a1aeb 100644 --- a/app/src/keyboard_inject.c +++ b/app/src/keyboard_sdk.c @@ -1,16 +1,21 @@ -#include "keyboard_inject.h" +#include "keyboard_sdk.h" #include +#include +#include +#include +#include #include "android/input.h" +#include "android/keycodes.h" #include "control_msg.h" #include "controller.h" #include "input_events.h" #include "util/intmap.h" #include "util/log.h" -/** Downcast key processor to sc_keyboard_inject */ -#define DOWNCAST(KP) container_of(KP, struct sc_keyboard_inject, key_processor) +/** Downcast key processor to sc_keyboard_sdk */ +#define DOWNCAST(KP) container_of(KP, struct sc_keyboard_sdk, key_processor) static enum android_keyevent_action convert_keycode_action(enum sc_action action) { @@ -45,6 +50,10 @@ convert_keycode(enum sc_keycode from, enum android_keycode *to, uint16_t mod, {SC_KEYCODE_RCTRL, AKEYCODE_CTRL_RIGHT}, {SC_KEYCODE_LSHIFT, AKEYCODE_SHIFT_LEFT}, {SC_KEYCODE_RSHIFT, AKEYCODE_SHIFT_RIGHT}, + {SC_KEYCODE_LALT, AKEYCODE_ALT_LEFT}, + {SC_KEYCODE_RALT, AKEYCODE_ALT_RIGHT}, + {SC_KEYCODE_LGUI, AKEYCODE_META_LEFT}, + {SC_KEYCODE_RGUI, AKEYCODE_META_RIGHT}, }; // Numpad navigation keys. @@ -166,11 +175,7 @@ convert_keycode(enum sc_keycode from, enum android_keycode *to, uint16_t mod, return false; } - if (mod & (SC_MOD_LALT | SC_MOD_RALT | SC_MOD_LGUI | SC_MOD_RGUI)) { - return false; - } - - // if ALT and META are not pressed, also handle letters and space + // Handle letters and space entry = SC_INTMAP_FIND_ENTRY(alphaspace_keys, from); if (entry) { *to = entry->value; @@ -271,20 +276,20 @@ sc_key_processor_process_key(struct sc_key_processor *kp, // is set before injecting Ctrl+v. (void) ack_to_wait; - struct sc_keyboard_inject *ki = DOWNCAST(kp); + struct sc_keyboard_sdk *kb = DOWNCAST(kp); if (event->repeat) { - if (!ki->forward_key_repeat) { + if (!kb->forward_key_repeat) { return; } - ++ki->repeat; + ++kb->repeat; } else { - ki->repeat = 0; + kb->repeat = 0; } struct sc_control_msg msg; - if (convert_input_key(event, &msg, ki->key_inject_mode, ki->repeat)) { - if (!sc_controller_push_msg(ki->controller, &msg)) { + if (convert_input_key(event, &msg, kb->key_inject_mode, kb->repeat)) { + if (!sc_controller_push_msg(kb->controller, &msg)) { LOGW("Could not request 'inject keycode'"); } } @@ -293,14 +298,14 @@ sc_key_processor_process_key(struct sc_key_processor *kp, static void sc_key_processor_process_text(struct sc_key_processor *kp, const struct sc_text_event *event) { - struct sc_keyboard_inject *ki = DOWNCAST(kp); + struct sc_keyboard_sdk *kb = DOWNCAST(kp); - if (ki->key_inject_mode == SC_KEY_INJECT_MODE_RAW) { + if (kb->key_inject_mode == SC_KEY_INJECT_MODE_RAW) { // Never inject text events return; } - if (ki->key_inject_mode == SC_KEY_INJECT_MODE_MIXED) { + if (kb->key_inject_mode == SC_KEY_INJECT_MODE_MIXED) { char c = event->text[0]; if (isalpha(c) || c == ' ') { assert(event->text[1] == '\0'); @@ -316,22 +321,22 @@ sc_key_processor_process_text(struct sc_key_processor *kp, LOGW("Could not strdup input text"); return; } - if (!sc_controller_push_msg(ki->controller, &msg)) { + if (!sc_controller_push_msg(kb->controller, &msg)) { free(msg.inject_text.text); LOGW("Could not request 'inject text'"); } } void -sc_keyboard_inject_init(struct sc_keyboard_inject *ki, - struct sc_controller *controller, - enum sc_key_inject_mode key_inject_mode, - bool forward_key_repeat) { - ki->controller = controller; - ki->key_inject_mode = key_inject_mode; - ki->forward_key_repeat = forward_key_repeat; +sc_keyboard_sdk_init(struct sc_keyboard_sdk *kb, + struct sc_controller *controller, + enum sc_key_inject_mode key_inject_mode, + bool forward_key_repeat) { + kb->controller = controller; + kb->key_inject_mode = key_inject_mode; + kb->forward_key_repeat = forward_key_repeat; - ki->repeat = 0; + kb->repeat = 0; static const struct sc_key_processor_ops ops = { .process_key = sc_key_processor_process_key, @@ -339,6 +344,7 @@ sc_keyboard_inject_init(struct sc_keyboard_inject *ki, }; // Key injection and clipboard synchronization are serialized - ki->key_processor.async_paste = false; - ki->key_processor.ops = &ops; + kb->key_processor.async_paste = false; + kb->key_processor.hid = false; + kb->key_processor.ops = &ops; } diff --git a/app/src/keyboard_inject.h b/app/src/keyboard_sdk.h similarity index 61% rename from app/src/keyboard_inject.h rename to app/src/keyboard_sdk.h index b7781c1f..700ba90b 100644 --- a/app/src/keyboard_inject.h +++ b/app/src/keyboard_sdk.h @@ -1,5 +1,5 @@ -#ifndef SC_KEYBOARD_INJECT_H -#define SC_KEYBOARD_INJECT_H +#ifndef SC_KEYBOARD_SDK_H +#define SC_KEYBOARD_SDK_H #include "common.h" @@ -9,7 +9,7 @@ #include "options.h" #include "trait/key_processor.h" -struct sc_keyboard_inject { +struct sc_keyboard_sdk { struct sc_key_processor key_processor; // key processor trait struct sc_controller *controller; @@ -23,9 +23,9 @@ struct sc_keyboard_inject { }; void -sc_keyboard_inject_init(struct sc_keyboard_inject *ki, - struct sc_controller *controller, - enum sc_key_inject_mode key_inject_mode, - bool forward_key_repeat); +sc_keyboard_sdk_init(struct sc_keyboard_sdk *kb, + struct sc_controller *controller, + enum sc_key_inject_mode key_inject_mode, + bool forward_key_repeat); #endif diff --git a/app/src/main.c b/app/src/main.c index 6050de11..c58e0be7 100644 --- a/app/src/main.c +++ b/app/src/main.c @@ -1,9 +1,6 @@ #include "common.h" -#include #include -#include -#include #ifdef HAVE_V4L2 # include #endif @@ -16,6 +13,7 @@ #include "usb/scrcpy_otg.h" #include "util/log.h" #include "util/net.h" +#include "util/thread.h" #include "version.h" #ifdef _WIN32 @@ -67,6 +65,9 @@ main_scrcpy(int argc, char *argv[]) { goto end; } + // The current thread is the main thread + SC_MAIN_THREAD_ID = sc_thread_get_id(); + #ifdef SCRCPY_LAVF_REQUIRES_REGISTER_ALL av_register_all(); #endif diff --git a/app/src/mouse_capture.c b/app/src/mouse_capture.c new file mode 100644 index 00000000..25345faa --- /dev/null +++ b/app/src/mouse_capture.c @@ -0,0 +1,123 @@ +#include "mouse_capture.h" + +#include "shortcut_mod.h" +#include "util/log.h" + +void +sc_mouse_capture_init(struct sc_mouse_capture *mc, SDL_Window *window, + uint8_t shortcut_mods) { + mc->window = window; + mc->sdl_mouse_capture_keys = sc_shortcut_mods_to_sdl(shortcut_mods); + mc->mouse_capture_key_pressed = SDLK_UNKNOWN; +} + +static inline bool +sc_mouse_capture_is_capture_key(struct sc_mouse_capture *mc, SDL_Keycode key) { + return sc_shortcut_mods_is_shortcut_key(mc->sdl_mouse_capture_keys, key); +} + +bool +sc_mouse_capture_handle_event(struct sc_mouse_capture *mc, + const SDL_Event *event) { + switch (event->type) { + case SDL_WINDOWEVENT: + if (event->window.event == SDL_WINDOWEVENT_FOCUS_LOST) { + sc_mouse_capture_set_active(mc, false); + return true; + } + break; + case SDL_KEYDOWN: { + SDL_Keycode key = event->key.keysym.sym; + if (sc_mouse_capture_is_capture_key(mc, key)) { + if (!mc->mouse_capture_key_pressed) { + mc->mouse_capture_key_pressed = key; + } else { + // Another mouse capture key has been pressed, cancel + // mouse (un)capture + mc->mouse_capture_key_pressed = 0; + } + // Mouse capture keys are never forwarded to the device + return true; + } + break; + } + case SDL_KEYUP: { + SDL_Keycode key = event->key.keysym.sym; + SDL_Keycode cap = mc->mouse_capture_key_pressed; + mc->mouse_capture_key_pressed = 0; + if (sc_mouse_capture_is_capture_key(mc, key)) { + if (key == cap) { + // A mouse capture key has been pressed then released: + // toggle the capture mouse mode + sc_mouse_capture_toggle(mc); + } + // Mouse capture keys are never forwarded to the device + return true; + } + break; + } + case SDL_MOUSEWHEEL: + case SDL_MOUSEMOTION: + case SDL_MOUSEBUTTONDOWN: + if (!sc_mouse_capture_is_active(mc)) { + // The mouse will be captured on SDL_MOUSEBUTTONUP, so consume + // the event + return true; + } + break; + case SDL_MOUSEBUTTONUP: + if (!sc_mouse_capture_is_active(mc)) { + sc_mouse_capture_set_active(mc, true); + return true; + } + break; + case SDL_FINGERMOTION: + case SDL_FINGERDOWN: + case SDL_FINGERUP: + // Touch events are not compatible with relative mode + // (coordinates are not relative), so consume the event + return true; + } + + return false; +} + +void +sc_mouse_capture_set_active(struct sc_mouse_capture *mc, bool capture) { +#ifdef __APPLE__ + // Workaround for SDL bug on macOS: + // + if (capture) { + int mouse_x, mouse_y; + SDL_GetGlobalMouseState(&mouse_x, &mouse_y); + + int x, y, w, h; + SDL_GetWindowPosition(mc->window, &x, &y); + SDL_GetWindowSize(mc->window, &w, &h); + + bool outside_window = mouse_x < x || mouse_x >= x + w + || mouse_y < y || mouse_y >= y + h; + if (outside_window) { + SDL_WarpMouseInWindow(mc->window, w / 2, h / 2); + } + } +#else + (void) mc; +#endif + if (SDL_SetRelativeMouseMode(capture)) { + LOGE("Could not set relative mouse mode to %s: %s", + capture ? "true" : "false", SDL_GetError()); + } +} + +bool +sc_mouse_capture_is_active(struct sc_mouse_capture *mc) { + (void) mc; + return SDL_GetRelativeMouseMode(); +} + +void +sc_mouse_capture_toggle(struct sc_mouse_capture *mc) { + bool new_value = !sc_mouse_capture_is_active(mc); + sc_mouse_capture_set_active(mc, new_value); +} diff --git a/app/src/mouse_capture.h b/app/src/mouse_capture.h new file mode 100644 index 00000000..f352cc13 --- /dev/null +++ b/app/src/mouse_capture.h @@ -0,0 +1,38 @@ +#ifndef SC_MOUSE_CAPTURE_H +#define SC_MOUSE_CAPTURE_H + +#include "common.h" + +#include + +#include + +struct sc_mouse_capture { + SDL_Window *window; + uint16_t sdl_mouse_capture_keys; + + // To enable/disable mouse capture, a mouse capture key (LALT, LGUI or + // RGUI) must be pressed. This variable tracks the pressed capture key. + SDL_Keycode mouse_capture_key_pressed; + +}; + +void +sc_mouse_capture_init(struct sc_mouse_capture *mc, SDL_Window *window, + uint8_t shortcut_mods); + +void +sc_mouse_capture_set_active(struct sc_mouse_capture *mc, bool capture); + +bool +sc_mouse_capture_is_active(struct sc_mouse_capture *mc); + +void +sc_mouse_capture_toggle(struct sc_mouse_capture *mc); + +// Return true if it consumed the event +bool +sc_mouse_capture_handle_event(struct sc_mouse_capture *mc, + const SDL_Event *event); + +#endif diff --git a/app/src/mouse_inject.c b/app/src/mouse_sdk.c similarity index 80% rename from app/src/mouse_inject.c rename to app/src/mouse_sdk.c index 71b7a64d..7eceffa7 100644 --- a/app/src/mouse_inject.c +++ b/app/src/mouse_sdk.c @@ -1,16 +1,16 @@ -#include "mouse_inject.h" +#include "mouse_sdk.h" #include +#include #include "android/input.h" #include "control_msg.h" #include "controller.h" #include "input_events.h" -#include "util/intmap.h" #include "util/log.h" -/** Downcast mouse processor to sc_mouse_inject */ -#define DOWNCAST(MP) container_of(MP, struct sc_mouse_inject, mouse_processor) +/** Downcast mouse processor to sc_mouse_sdk */ +#define DOWNCAST(MP) container_of(MP, struct sc_mouse_sdk, mouse_processor) static enum android_motionevent_buttons convert_mouse_buttons(uint32_t state) { @@ -58,17 +58,18 @@ convert_touch_action(enum sc_touch_action action) { static void sc_mouse_processor_process_mouse_motion(struct sc_mouse_processor *mp, const struct sc_mouse_motion_event *event) { - if (!event->buttons_state) { + struct sc_mouse_sdk *m = DOWNCAST(mp); + + if (!m->mouse_hover && !event->buttons_state) { // Do not send motion events when no click is pressed return; } - struct sc_mouse_inject *mi = DOWNCAST(mp); - struct sc_control_msg msg = { .type = SC_CONTROL_MSG_TYPE_INJECT_TOUCH_EVENT, .inject_touch_event = { - .action = AMOTION_EVENT_ACTION_MOVE, + .action = event->buttons_state ? AMOTION_EVENT_ACTION_MOVE + : AMOTION_EVENT_ACTION_HOVER_MOVE, .pointer_id = event->pointer_id, .position = event->position, .pressure = 1.f, @@ -76,7 +77,7 @@ sc_mouse_processor_process_mouse_motion(struct sc_mouse_processor *mp, }, }; - if (!sc_controller_push_msg(mi->controller, &msg)) { + if (!sc_controller_push_msg(m->controller, &msg)) { LOGW("Could not request 'inject mouse motion event'"); } } @@ -84,7 +85,7 @@ sc_mouse_processor_process_mouse_motion(struct sc_mouse_processor *mp, static void sc_mouse_processor_process_mouse_click(struct sc_mouse_processor *mp, const struct sc_mouse_click_event *event) { - struct sc_mouse_inject *mi = DOWNCAST(mp); + struct sc_mouse_sdk *m = DOWNCAST(mp); struct sc_control_msg msg = { .type = SC_CONTROL_MSG_TYPE_INJECT_TOUCH_EVENT, @@ -98,7 +99,7 @@ sc_mouse_processor_process_mouse_click(struct sc_mouse_processor *mp, }, }; - if (!sc_controller_push_msg(mi->controller, &msg)) { + if (!sc_controller_push_msg(m->controller, &msg)) { LOGW("Could not request 'inject mouse click event'"); } } @@ -106,7 +107,7 @@ sc_mouse_processor_process_mouse_click(struct sc_mouse_processor *mp, static void sc_mouse_processor_process_mouse_scroll(struct sc_mouse_processor *mp, const struct sc_mouse_scroll_event *event) { - struct sc_mouse_inject *mi = DOWNCAST(mp); + struct sc_mouse_sdk *m = DOWNCAST(mp); struct sc_control_msg msg = { .type = SC_CONTROL_MSG_TYPE_INJECT_SCROLL_EVENT, @@ -118,7 +119,7 @@ sc_mouse_processor_process_mouse_scroll(struct sc_mouse_processor *mp, }, }; - if (!sc_controller_push_msg(mi->controller, &msg)) { + if (!sc_controller_push_msg(m->controller, &msg)) { LOGW("Could not request 'inject mouse scroll event'"); } } @@ -126,7 +127,7 @@ sc_mouse_processor_process_mouse_scroll(struct sc_mouse_processor *mp, static void sc_mouse_processor_process_touch(struct sc_mouse_processor *mp, const struct sc_touch_event *event) { - struct sc_mouse_inject *mi = DOWNCAST(mp); + struct sc_mouse_sdk *m = DOWNCAST(mp); struct sc_control_msg msg = { .type = SC_CONTROL_MSG_TYPE_INJECT_TOUCH_EVENT, @@ -139,15 +140,16 @@ sc_mouse_processor_process_touch(struct sc_mouse_processor *mp, }, }; - if (!sc_controller_push_msg(mi->controller, &msg)) { + if (!sc_controller_push_msg(m->controller, &msg)) { LOGW("Could not request 'inject touch event'"); } } void -sc_mouse_inject_init(struct sc_mouse_inject *mi, - struct sc_controller *controller) { - mi->controller = controller; +sc_mouse_sdk_init(struct sc_mouse_sdk *m, struct sc_controller *controller, + bool mouse_hover) { + m->controller = controller; + m->mouse_hover = mouse_hover; static const struct sc_mouse_processor_ops ops = { .process_mouse_motion = sc_mouse_processor_process_mouse_motion, @@ -156,7 +158,7 @@ sc_mouse_inject_init(struct sc_mouse_inject *mi, .process_touch = sc_mouse_processor_process_touch, }; - mi->mouse_processor.ops = &ops; + m->mouse_processor.ops = &ops; - mi->mouse_processor.relative_mode = false; + m->mouse_processor.relative_mode = false; } diff --git a/app/src/mouse_inject.h b/app/src/mouse_sdk.h similarity index 53% rename from app/src/mouse_inject.h rename to app/src/mouse_sdk.h index 59a6a5d8..fe92a2d7 100644 --- a/app/src/mouse_inject.h +++ b/app/src/mouse_sdk.h @@ -1,22 +1,22 @@ -#ifndef SC_MOUSE_INJECT_H -#define SC_MOUSE_INJECT_H +#ifndef SC_MOUSE_SDK_H +#define SC_MOUSE_SDK_H #include "common.h" #include #include "controller.h" -#include "screen.h" #include "trait/mouse_processor.h" -struct sc_mouse_inject { +struct sc_mouse_sdk { struct sc_mouse_processor mouse_processor; // mouse processor trait struct sc_controller *controller; + bool mouse_hover; }; void -sc_mouse_inject_init(struct sc_mouse_inject *mi, - struct sc_controller *controller); +sc_mouse_sdk_init(struct sc_mouse_sdk *m, struct sc_controller *controller, + bool mouse_hover); #endif diff --git a/app/src/opengl.c b/app/src/opengl.c index 376690af..0cb83ed7 100644 --- a/app/src/opengl.c +++ b/app/src/opengl.c @@ -2,7 +2,8 @@ #include #include -#include "SDL2/SDL.h" +#include +#include void sc_opengl_init(struct sc_opengl *gl) { diff --git a/app/src/options.c b/app/src/options.c index a13df585..0fe82d29 100644 --- a/app/src/options.c +++ b/app/src/options.c @@ -1,5 +1,7 @@ #include "options.h" +#include + const struct scrcpy_options scrcpy_options_default = { .serial = NULL, .crop = NULL, @@ -21,8 +23,23 @@ const struct scrcpy_options scrcpy_options_default = { .video_source = SC_VIDEO_SOURCE_DISPLAY, .audio_source = SC_AUDIO_SOURCE_AUTO, .record_format = SC_RECORD_FORMAT_AUTO, - .keyboard_input_mode = SC_KEYBOARD_INPUT_MODE_INJECT, - .mouse_input_mode = SC_MOUSE_INPUT_MODE_INJECT, + .keyboard_input_mode = SC_KEYBOARD_INPUT_MODE_AUTO, + .mouse_input_mode = SC_MOUSE_INPUT_MODE_AUTO, + .gamepad_input_mode = SC_GAMEPAD_INPUT_MODE_DISABLED, + .mouse_bindings = { + .pri = { + .right_click = SC_MOUSE_BINDING_AUTO, + .middle_click = SC_MOUSE_BINDING_AUTO, + .click4 = SC_MOUSE_BINDING_AUTO, + .click5 = SC_MOUSE_BINDING_AUTO, + }, + .sec = { + .right_click = SC_MOUSE_BINDING_AUTO, + .middle_click = SC_MOUSE_BINDING_AUTO, + .click4 = SC_MOUSE_BINDING_AUTO, + .click5 = SC_MOUSE_BINDING_AUTO, + }, + }, .camera_facing = SC_CAMERA_FACING_ANY, .port_range = { .first = DEFAULT_LOCAL_PORT_RANGE_FIRST, @@ -30,26 +47,26 @@ const struct scrcpy_options scrcpy_options_default = { }, .tunnel_host = 0, .tunnel_port = 0, - .shortcut_mods = { - .data = {SC_SHORTCUT_MOD_LALT, SC_SHORTCUT_MOD_LSUPER}, - .count = 2, - }, + .shortcut_mods = SC_SHORTCUT_MOD_LALT | SC_SHORTCUT_MOD_LSUPER, .max_size = 0, .video_bit_rate = 0, .audio_bit_rate = 0, - .max_fps = 0, - .lock_video_orientation = SC_LOCK_VIDEO_ORIENTATION_UNLOCKED, + .max_fps = NULL, + .capture_orientation = SC_ORIENTATION_0, + .capture_orientation_lock = SC_ORIENTATION_UNLOCKED, .display_orientation = SC_ORIENTATION_0, .record_orientation = SC_ORIENTATION_0, + .display_ime_policy = SC_DISPLAY_IME_POLICY_UNDEFINED, .window_x = SC_WINDOW_POSITION_UNDEFINED, .window_y = SC_WINDOW_POSITION_UNDEFINED, .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, @@ -71,7 +88,6 @@ const struct scrcpy_options scrcpy_options_default = { .force_adb_forward = false, .disable_screensaver = false, .forward_key_repeat = true, - .forward_all_clicks = false, .legacy_paste = false, .power_off_on_close = false, .clipboard_autosync = true, @@ -89,6 +105,14 @@ const struct scrcpy_options scrcpy_options_default = { .kill_adb_on_close = false, .camera_high_speed = false, .list = 0, + .window = true, + .mouse_hover = true, + .audio_dup = false, + .new_display = NULL, + .start_app = NULL, + .angle = NULL, + .vd_destroy_content = true, + .vd_system_decorations = true, }; enum sc_orientation diff --git a/app/src/options.h b/app/src/options.h index 11e64fa1..03b42913 100644 --- a/app/src/options.h +++ b/app/src/options.h @@ -5,7 +5,6 @@ #include #include -#include #include #include "util/tick.h" @@ -59,6 +58,15 @@ enum sc_audio_source { SC_AUDIO_SOURCE_AUTO, // OUTPUT for video DISPLAY, MIC for video CAMERA SC_AUDIO_SOURCE_OUTPUT, SC_AUDIO_SOURCE_MIC, + SC_AUDIO_SOURCE_PLAYBACK, + SC_AUDIO_SOURCE_MIC_UNPROCESSED, + SC_AUDIO_SOURCE_MIC_CAMCORDER, + SC_AUDIO_SOURCE_MIC_VOICE_RECOGNITION, + SC_AUDIO_SOURCE_MIC_VOICE_COMMUNICATION, + SC_AUDIO_SOURCE_VOICE_CALL, + SC_AUDIO_SOURCE_VOICE_CALL_UPLINK, + SC_AUDIO_SOURCE_VOICE_CALL_DOWNLINK, + SC_AUDIO_SOURCE_VOICE_PERFORMANCE, }; enum sc_camera_facing { @@ -83,6 +91,19 @@ enum sc_orientation { // v v v SC_ORIENTATION_FLIP_270, // 1 1 1 }; +enum sc_orientation_lock { + SC_ORIENTATION_UNLOCKED, + SC_ORIENTATION_LOCKED_VALUE, // lock to specified orientation + SC_ORIENTATION_LOCKED_INITIAL, // lock to initial device orientation +}; + +enum sc_display_ime_policy { + SC_DISPLAY_IME_POLICY_UNDEFINED, + SC_DISPLAY_IME_POLICY_LOCAL, + SC_DISPLAY_IME_POLICY_FALLBACK, + SC_DISPLAY_IME_POLICY_HIDE, +}; + static inline bool sc_orientation_is_mirror(enum sc_orientation orientation) { assert(!(orientation & ~7)); @@ -129,24 +150,51 @@ sc_orientation_get_name(enum sc_orientation orientation) { } } -enum sc_lock_video_orientation { - SC_LOCK_VIDEO_ORIENTATION_UNLOCKED = -1, - // lock the current orientation when scrcpy starts - SC_LOCK_VIDEO_ORIENTATION_INITIAL = -2, - SC_LOCK_VIDEO_ORIENTATION_0 = 0, - SC_LOCK_VIDEO_ORIENTATION_90 = 3, - SC_LOCK_VIDEO_ORIENTATION_180 = 2, - SC_LOCK_VIDEO_ORIENTATION_270 = 1, -}; - enum sc_keyboard_input_mode { - SC_KEYBOARD_INPUT_MODE_INJECT, - SC_KEYBOARD_INPUT_MODE_HID, + SC_KEYBOARD_INPUT_MODE_AUTO, + SC_KEYBOARD_INPUT_MODE_UHID_OR_AOA, // normal vs otg mode + SC_KEYBOARD_INPUT_MODE_DISABLED, + SC_KEYBOARD_INPUT_MODE_SDK, + SC_KEYBOARD_INPUT_MODE_UHID, + SC_KEYBOARD_INPUT_MODE_AOA, }; enum sc_mouse_input_mode { - SC_MOUSE_INPUT_MODE_INJECT, - SC_MOUSE_INPUT_MODE_HID, + SC_MOUSE_INPUT_MODE_AUTO, + SC_MOUSE_INPUT_MODE_UHID_OR_AOA, // normal vs otg mode + SC_MOUSE_INPUT_MODE_DISABLED, + SC_MOUSE_INPUT_MODE_SDK, + SC_MOUSE_INPUT_MODE_UHID, + SC_MOUSE_INPUT_MODE_AOA, +}; + +enum sc_gamepad_input_mode { + SC_GAMEPAD_INPUT_MODE_DISABLED, + SC_GAMEPAD_INPUT_MODE_UHID_OR_AOA, // normal vs otg mode + SC_GAMEPAD_INPUT_MODE_UHID, + SC_GAMEPAD_INPUT_MODE_AOA, +}; + +enum sc_mouse_binding { + SC_MOUSE_BINDING_AUTO, + SC_MOUSE_BINDING_DISABLED, + SC_MOUSE_BINDING_CLICK, + SC_MOUSE_BINDING_BACK, + SC_MOUSE_BINDING_HOME, + SC_MOUSE_BINDING_APP_SWITCH, + SC_MOUSE_BINDING_EXPAND_NOTIFICATION_PANEL, +}; + +struct sc_mouse_binding_set { + enum sc_mouse_binding right_click; + enum sc_mouse_binding middle_click; + enum sc_mouse_binding click4; + enum sc_mouse_binding click5; +}; + +struct sc_mouse_bindings { + struct sc_mouse_binding_set pri; + struct sc_mouse_binding_set sec; // When Shift is pressed }; enum sc_key_inject_mode { @@ -163,8 +211,6 @@ enum sc_key_inject_mode { SC_KEY_INJECT_MODE_RAW, }; -#define SC_MAX_SHORTCUT_MODS 8 - enum sc_shortcut_mod { SC_SHORTCUT_MOD_LCTRL = 1 << 0, SC_SHORTCUT_MOD_RCTRL = 1 << 1, @@ -174,11 +220,6 @@ enum sc_shortcut_mod { SC_SHORTCUT_MOD_RSUPER = 1 << 5, }; -struct sc_shortcut_mods { - unsigned data[SC_MAX_SHORTCUT_MODS]; - unsigned count; -}; - struct sc_port_range { uint16_t first; uint16_t last; @@ -209,27 +250,33 @@ struct scrcpy_options { enum sc_record_format record_format; enum sc_keyboard_input_mode keyboard_input_mode; enum sc_mouse_input_mode mouse_input_mode; + enum sc_gamepad_input_mode gamepad_input_mode; + struct sc_mouse_bindings mouse_bindings; enum sc_camera_facing camera_facing; struct sc_port_range port_range; uint32_t tunnel_host; uint16_t tunnel_port; - struct sc_shortcut_mods shortcut_mods; + uint8_t shortcut_mods; // OR of enum sc_shortcut_mod values uint16_t max_size; uint32_t video_bit_rate; uint32_t audio_bit_rate; - uint16_t max_fps; - enum sc_lock_video_orientation lock_video_orientation; + const char *max_fps; // float to be parsed by the server + const char *angle; // float to be parsed by the server + enum sc_orientation capture_orientation; + enum sc_orientation_lock capture_orientation_lock; enum sc_orientation display_orientation; enum sc_orientation record_orientation; + enum sc_display_ime_policy display_ime_policy; int16_t window_x; // SC_WINDOW_POSITION_UNDEFINED for "auto" int16_t window_y; // SC_WINDOW_POSITION_UNDEFINED for "auto" 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; @@ -251,7 +298,6 @@ struct scrcpy_options { bool force_adb_forward; bool disable_screensaver; bool forward_key_repeat; - bool forward_all_clicks; bool legacy_paste; bool power_off_on_close; bool clipboard_autosync; @@ -272,7 +318,15 @@ 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; // [x][/] parsed by the server + const char *start_app; + bool vd_destroy_content; + bool vd_system_decorations; }; extern const struct scrcpy_options scrcpy_options_default; diff --git a/app/src/packet_merger.c b/app/src/packet_merger.c index 81b02d2c..dea038b6 100644 --- a/app/src/packet_merger.c +++ b/app/src/packet_merger.c @@ -1,5 +1,9 @@ #include "packet_merger.h" +#include +#include +#include + #include "util/log.h" void diff --git a/app/src/packet_merger.h b/app/src/packet_merger.h index e1824c2c..3f9972ce 100644 --- a/app/src/packet_merger.h +++ b/app/src/packet_merger.h @@ -5,7 +5,7 @@ #include #include -#include +#include /** * Config packets (containing the SPS/PPS) are sent in-band. A new config diff --git a/app/src/receiver.c b/app/src/receiver.c index e715a8e6..2ccb8a8b 100644 --- a/app/src/receiver.c +++ b/app/src/receiver.c @@ -1,21 +1,37 @@ #include "receiver.h" #include +#include #include #include "device_msg.h" +#include "events.h" #include "util/log.h" +#include "util/str.h" +#include "util/thread.h" + +struct sc_uhid_output_task_data { + struct sc_uhid_devices *uhid_devices; + uint16_t id; + uint16_t size; + uint8_t *data; +}; bool sc_receiver_init(struct sc_receiver *receiver, sc_socket control_socket, - struct sc_acksync *acksync) { + const struct sc_receiver_callbacks *cbs, void *cbs_userdata) { bool ok = sc_mutex_init(&receiver->mutex); if (!ok) { return false; } receiver->control_socket = control_socket; - receiver->acksync = acksync; + receiver->acksync = NULL; + receiver->uhid_devices = NULL; + + assert(cbs && cbs->on_ended); + receiver->cbs = cbs; + receiver->cbs_userdata = cbs_userdata; return true; } @@ -26,36 +42,123 @@ sc_receiver_destroy(struct sc_receiver *receiver) { } static void -process_msg(struct sc_receiver *receiver, struct device_msg *msg) { +task_set_clipboard(void *userdata) { + assert(sc_thread_get_id() == SC_MAIN_THREAD_ID); + + char *text = userdata; + + char *current = SDL_GetClipboardText(); + bool same = current && !strcmp(current, text); + SDL_free(current); + if (same) { + LOGD("Computer clipboard unchanged"); + } else { + LOGI("Device clipboard copied"); + SDL_SetClipboardText(text); + } + + free(text); +} + +static void +task_uhid_output(void *userdata) { + assert(sc_thread_get_id() == SC_MAIN_THREAD_ID); + + struct sc_uhid_output_task_data *data = userdata; + + sc_uhid_devices_process_hid_output(data->uhid_devices, data->id, data->data, + data->size); + + free(data->data); + free(data); +} + +static void +process_msg(struct sc_receiver *receiver, struct sc_device_msg *msg) { switch (msg->type) { case DEVICE_MSG_TYPE_CLIPBOARD: { - char *current = SDL_GetClipboardText(); - bool same = current && !strcmp(current, msg->clipboard.text); - SDL_free(current); - if (same) { - LOGD("Computer clipboard unchanged"); + // Take ownership of the text (do not destroy the msg) + char *text = msg->clipboard.text; + + bool ok = sc_post_to_main_thread(task_set_clipboard, text); + if (!ok) { + LOGW("Could not post clipboard to main thread"); + free(text); return; } - LOGI("Device clipboard copied"); - SDL_SetClipboardText(msg->clipboard.text); break; } case DEVICE_MSG_TYPE_ACK_CLIPBOARD: - assert(receiver->acksync); LOGD("Ack device clipboard sequence=%" PRIu64_, msg->ack_clipboard.sequence); + + // This is a programming error to receive this message if there is + // no ACK synchronization mechanism + assert(receiver->acksync); + + // Also check at runtime (do not trust the server) + if (!receiver->acksync) { + LOGE("Received unexpected ack"); + return; + } + sc_acksync_ack(receiver->acksync, msg->ack_clipboard.sequence); + // No allocation to free in the msg + break; + case DEVICE_MSG_TYPE_UHID_OUTPUT: + if (sc_get_log_level() <= SC_LOG_LEVEL_VERBOSE) { + char *hex = sc_str_to_hex_string(msg->uhid_output.data, + msg->uhid_output.size); + if (hex) { + LOGV("UHID output [%" PRIu16 "] %s", + msg->uhid_output.id, hex); + free(hex); + } else { + LOGV("UHID output [%" PRIu16 "] size=%" PRIu16, + msg->uhid_output.id, msg->uhid_output.size); + } + } + + if (!receiver->uhid_devices) { + LOGE("Received unexpected HID output message"); + sc_device_msg_destroy(msg); + return; + } + + struct sc_uhid_output_task_data *data = malloc(sizeof(*data)); + if (!data) { + LOG_OOM(); + return; + } + + // It is guaranteed that these pointers will still be valid when + // the main thread will process them (the main thread will stop + // processing SC_EVENT_RUN_ON_MAIN_THREAD on exit, when everything + // gets deinitialized) + data->uhid_devices = receiver->uhid_devices; + data->id = msg->uhid_output.id; + data->data = msg->uhid_output.data; // take ownership + data->size = msg->uhid_output.size; + + bool ok = sc_post_to_main_thread(task_uhid_output, data); + if (!ok) { + LOGW("Could not post UHID output to main thread"); + free(data->data); + free(data); + return; + } + break; } } static ssize_t -process_msgs(struct sc_receiver *receiver, const unsigned char *buf, size_t len) { +process_msgs(struct sc_receiver *receiver, const uint8_t *buf, size_t len) { size_t head = 0; for (;;) { - struct device_msg msg; - ssize_t r = device_msg_deserialize(&buf[head], len - head, &msg); + struct sc_device_msg msg; + ssize_t r = sc_device_msg_deserialize(&buf[head], len - head, &msg); if (r == -1) { return -1; } @@ -64,7 +167,7 @@ process_msgs(struct sc_receiver *receiver, const unsigned char *buf, size_t len) } process_msg(receiver, &msg); - device_msg_destroy(&msg); + // the device msg must be destroyed by process_msg() head += r; assert(head <= len); @@ -78,15 +181,18 @@ static int run_receiver(void *data) { struct sc_receiver *receiver = data; - static unsigned char buf[DEVICE_MSG_MAX_SIZE]; + static uint8_t buf[DEVICE_MSG_MAX_SIZE]; size_t head = 0; + bool error = false; + for (;;) { assert(head < DEVICE_MSG_MAX_SIZE); ssize_t r = net_recv(receiver->control_socket, buf + head, DEVICE_MSG_MAX_SIZE - head); if (r <= 0) { LOGD("Receiver stopped"); + // device disconnected: keep error=false break; } @@ -94,6 +200,7 @@ run_receiver(void *data) { ssize_t consumed = process_msgs(receiver, buf, head); if (consumed == -1) { // an error occurred + error = true; break; } @@ -104,6 +211,8 @@ run_receiver(void *data) { } } + receiver->cbs->on_ended(receiver, error, receiver->cbs_userdata); + return 0; } diff --git a/app/src/receiver.h b/app/src/receiver.h index eb959fb8..b1ae4fde 100644 --- a/app/src/receiver.h +++ b/app/src/receiver.h @@ -5,6 +5,7 @@ #include +#include "uhid/uhid_output.h" #include "util/acksync.h" #include "util/net.h" #include "util/thread.h" @@ -17,11 +18,19 @@ struct sc_receiver { sc_mutex mutex; struct sc_acksync *acksync; + struct sc_uhid_devices *uhid_devices; + + const struct sc_receiver_callbacks *cbs; + void *cbs_userdata; +}; + +struct sc_receiver_callbacks { + void (*on_ended)(struct sc_receiver *receiver, bool error, void *userdata); }; bool sc_receiver_init(struct sc_receiver *receiver, sc_socket control_socket, - struct sc_acksync *acksync); + const struct sc_receiver_callbacks *cbs, void *cbs_userdata); void sc_receiver_destroy(struct sc_receiver *receiver); diff --git a/app/src/recorder.c b/app/src/recorder.c index 9e0b3395..c26f8f2d 100644 --- a/app/src/recorder.c +++ b/app/src/recorder.c @@ -1,6 +1,9 @@ #include "recorder.h" #include +#include +#include +#include #include #include #include @@ -143,8 +146,14 @@ sc_recorder_open_output_file(struct sc_recorder *recorder) { return false; } - int ret = avio_open(&recorder->ctx->pb, recorder->filename, - AVIO_FLAG_WRITE); + char *file_url = sc_str_concat("file:", recorder->filename); + if (!file_url) { + avformat_free_context(recorder->ctx); + return false; + } + + int ret = avio_open(&recorder->ctx->pb, file_url, AVIO_FLAG_WRITE); + free(file_url); if (ret < 0) { LOGE("Failed to open output file: %s", recorder->filename); avformat_free_context(recorder->ctx); diff --git a/app/src/recorder.h b/app/src/recorder.h index d096e79a..70b73836 100644 --- a/app/src/recorder.h +++ b/app/src/recorder.h @@ -4,9 +4,10 @@ #include "common.h" #include +#include +#include #include -#include "coords.h" #include "options.h" #include "trait/packet_sink.h" #include "util/thread.h" diff --git a/app/src/scrcpy.c b/app/src/scrcpy.c index d62a5f52..a4c8c340 100644 --- a/app/src/scrcpy.c +++ b/app/src/scrcpy.c @@ -1,10 +1,11 @@ #include "scrcpy.h" +#include +#include +#include #include +#include #include -#include -#include -#include #include #ifdef _WIN32 @@ -20,22 +21,26 @@ #include "demuxer.h" #include "events.h" #include "file_pusher.h" -#include "keyboard_inject.h" -#include "mouse_inject.h" +#include "keyboard_sdk.h" +#include "mouse_sdk.h" #include "recorder.h" #include "screen.h" #include "server.h" +#include "uhid/gamepad_uhid.h" +#include "uhid/keyboard_uhid.h" +#include "uhid/mouse_uhid.h" #ifdef HAVE_USB # include "usb/aoa_hid.h" -# include "usb/hid_keyboard.h" -# include "usb/hid_mouse.h" +# include "usb/gamepad_aoa.h" +# include "usb/keyboard_aoa.h" +# include "usb/mouse_aoa.h" # include "usb/usb.h" #endif #include "util/acksync.h" #include "util/log.h" -#include "util/net.h" #include "util/rand.h" #include "util/timeout.h" +#include "util/tick.h" #ifdef HAVE_V4L2 # include "v4l2_sink.h" #endif @@ -49,7 +54,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; @@ -62,37 +67,34 @@ struct scrcpy { // sequence/ack helper to synchronize clipboard and Ctrl+v via HID struct sc_acksync acksync; #endif + struct sc_uhid_devices uhid_devices; union { - struct sc_keyboard_inject keyboard_inject; + struct sc_keyboard_sdk keyboard_sdk; + struct sc_keyboard_uhid keyboard_uhid; #ifdef HAVE_USB - struct sc_hid_keyboard keyboard_hid; + struct sc_keyboard_aoa keyboard_aoa; #endif }; union { - struct sc_mouse_inject mouse_inject; + struct sc_mouse_sdk mouse_sdk; + struct sc_mouse_uhid mouse_uhid; #ifdef HAVE_USB - struct sc_hid_mouse mouse_hid; + struct sc_mouse_aoa mouse_aoa; +#endif + }; + union { + struct sc_gamepad_uhid gamepad_uhid; +#ifdef HAVE_USB + struct sc_gamepad_aoa gamepad_aoa; #endif }; struct sc_timeout timeout; }; -static inline void -push_event(uint32_t type, const char *name) { - SDL_Event event; - event.type = type; - int ret = SDL_PushEvent(&event); - if (ret < 0) { - LOGE("Could not post %s event: %s", name, SDL_GetError()); - // What could we do? - } -} -#define PUSH_EVENT(TYPE) push_event(TYPE, # TYPE) - #ifdef _WIN32 static BOOL WINAPI windows_ctrl_handler(DWORD ctrl_type) { if (ctrl_type == CTRL_C_EVENT) { - PUSH_EVENT(SDL_QUIT); + sc_push_event(SDL_QUIT); return TRUE; } return FALSE; @@ -101,11 +103,21 @@ static BOOL WINAPI windows_ctrl_handler(DWORD ctrl_type) { static void sdl_set_hints(const char *render_driver) { - if (render_driver && !SDL_SetHint(SDL_HINT_RENDER_DRIVER, render_driver)) { LOGW("Could not set render driver"); } + // App name used in various contexts (such as PulseAudio) +#if defined(SCRCPY_SDL_HAS_HINT_APP_NAME) + if (!SDL_SetHint(SDL_HINT_APP_NAME, "scrcpy")) { + LOGW("Could not set app name"); + } +#elif defined(SCRCPY_SDL_HAS_HINT_AUDIO_DEVICE_APP_NAME) + if (!SDL_SetHint(SDL_HINT_AUDIO_DEVICE_APP_NAME, "scrcpy")) { + LOGW("Could not set audio device app name"); + } +#endif + // Linear filtering if (!SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "1")) { LOGW("Could not enable linear filtering"); @@ -136,6 +148,10 @@ sdl_set_hints(const char *render_driver) { if (!SDL_SetHint(SDL_HINT_VIDEO_MINIMIZE_ON_FOCUS_LOSS, "0")) { LOGW("Could not disable minimize on focus loss"); } + + if (!SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1")) { + LOGW("Could not allow joystick background events"); + } } static void @@ -160,7 +176,7 @@ sdl_configure(bool video_playback, bool disable_screensaver) { } static enum scrcpy_exit_code -event_loop(struct scrcpy *s) { +event_loop(struct scrcpy *s, bool has_screen) { SDL_Event event; while (SDL_WaitEvent(&event)) { switch (event.type) { @@ -170,17 +186,29 @@ event_loop(struct scrcpy *s) { case SC_EVENT_DEMUXER_ERROR: LOGE("Demuxer error"); return SCRCPY_EXIT_FAILURE; + case SC_EVENT_CONTROLLER_ERROR: + LOGE("Controller error"); + return SCRCPY_EXIT_FAILURE; case SC_EVENT_RECORDER_ERROR: LOGE("Recorder error"); return SCRCPY_EXIT_FAILURE; + case SC_EVENT_AOA_OPEN_ERROR: + LOGE("AOA open error"); + return SCRCPY_EXIT_FAILURE; case SC_EVENT_TIME_LIMIT_REACHED: LOGI("Time limit reached"); return SCRCPY_EXIT_SUCCESS; case SDL_QUIT: LOGD("User requested to quit"); return SCRCPY_EXIT_SUCCESS; + case SC_EVENT_RUN_ON_MAIN_THREAD: { + sc_runnable_fn run = event.user.data1; + void *userdata = event.user.data2; + run(userdata); + break; + } default: - if (!sc_screen_handle_event(&s->screen, &event)) { + if (has_screen && !sc_screen_handle_event(&s->screen, &event)) { return SCRCPY_EXIT_FAILURE; } break; @@ -189,6 +217,21 @@ event_loop(struct scrcpy *s) { return SCRCPY_EXIT_FAILURE; } +static void +terminate_event_loop(void) { + sc_reject_new_runnables(); + + SDL_Event event; + while (SDL_PollEvent(&event)) { + if (event.type == SC_EVENT_RUN_ON_MAIN_THREAD) { + // Make sure all posted runnables are run, to avoid memory leaks + sc_runnable_fn run = event.user.data1; + void *userdata = event.user.data2; + run(userdata); + } + } +} + // Return true on success, false on error static bool await_for_server(bool *connected) { @@ -223,7 +266,7 @@ sc_recorder_on_ended(struct sc_recorder *recorder, bool success, (void) userdata; if (!success) { - PUSH_EVENT(SC_EVENT_RECORDER_ERROR); + sc_push_event(SC_EVENT_RECORDER_ERROR); } } @@ -237,9 +280,9 @@ sc_video_demuxer_on_ended(struct sc_demuxer *demuxer, assert(status != SC_DEMUXER_STATUS_DISABLED); if (status == SC_DEMUXER_STATUS_EOS) { - PUSH_EVENT(SC_EVENT_DEVICE_DISCONNECTED); + sc_push_event(SC_EVENT_DEVICE_DISCONNECTED); } else { - PUSH_EVENT(SC_EVENT_DEMUXER_ERROR); + sc_push_event(SC_EVENT_DEMUXER_ERROR); } } @@ -253,11 +296,26 @@ sc_audio_demuxer_on_ended(struct sc_demuxer *demuxer, // Contrary to the video demuxer, keep mirroring if only the audio fails // (unless --require-audio is set). if (status == SC_DEMUXER_STATUS_EOS) { - PUSH_EVENT(SC_EVENT_DEVICE_DISCONNECTED); + sc_push_event(SC_EVENT_DEVICE_DISCONNECTED); } else if (status == SC_DEMUXER_STATUS_ERROR || (status == SC_DEMUXER_STATUS_DISABLED && options->require_audio)) { - PUSH_EVENT(SC_EVENT_DEMUXER_ERROR); + sc_push_event(SC_EVENT_DEMUXER_ERROR); + } +} + +static void +sc_controller_on_ended(struct sc_controller *controller, bool error, + void *userdata) { + // Note: this function may be called twice, once from the controller thread + // and once from the receiver thread + (void) controller; + (void) userdata; + + if (error) { + sc_push_event(SC_EVENT_CONTROLLER_ERROR); + } else { + sc_push_event(SC_EVENT_DEVICE_DISCONNECTED); } } @@ -266,7 +324,7 @@ sc_server_on_connection_failed(struct sc_server *server, void *userdata) { (void) server; (void) userdata; - PUSH_EVENT(SC_EVENT_SERVER_CONNECTION_FAILED); + sc_push_event(SC_EVENT_SERVER_CONNECTION_FAILED); } static void @@ -274,7 +332,7 @@ sc_server_on_connected(struct sc_server *server, void *userdata) { (void) server; (void) userdata; - PUSH_EVENT(SC_EVENT_SERVER_CONNECTED); + sc_push_event(SC_EVENT_SERVER_CONNECTED); } static void @@ -292,7 +350,7 @@ sc_timeout_on_timeout(struct sc_timeout *timeout, void *userdata) { (void) timeout; (void) userdata; - PUSH_EVENT(SC_EVENT_TIME_LIMIT_REACHED); + sc_push_event(SC_EVENT_TIME_LIMIT_REACHED); } // Generate a scrcpy id to differentiate multiple running scrcpy instances @@ -304,9 +362,28 @@ scrcpy_generate_scid(void) { return sc_rand_u32(&rand) & 0x7FFFFFFF; } +static void +init_sdl_gamepads(void) { + // Trigger a SDL_CONTROLLERDEVICEADDED event for all gamepads already + // connected + int num_joysticks = SDL_NumJoysticks(); + for (int i = 0; i < num_joysticks; ++i) { + if (SDL_IsGameController(i)) { + SDL_Event event; + event.cdevice.type = SDL_CONTROLLERDEVICEADDED; + event.cdevice.which = i; + SDL_PushEvent(&event); + } + } +} + enum scrcpy_exit_code scrcpy(struct scrcpy_options *options) { static struct scrcpy scrcpy; +#ifndef NDEBUG + // Detect missing initializations + memset(&scrcpy, 42, sizeof(scrcpy)); +#endif struct scrcpy *s = &scrcpy; // Minimal SDL initialization @@ -330,8 +407,9 @@ scrcpy(struct scrcpy_options *options) { bool audio_demuxer_started = false; #ifdef HAVE_USB bool aoa_hid_initialized = false; - bool hid_keyboard_initialized = false; - bool hid_mouse_initialized = false; + bool keyboard_aoa_initialized = false; + bool mouse_aoa_initialized = false; + bool gamepad_aoa_initialized = false; #endif bool controller_initialized = false; bool controller_started = false; @@ -362,11 +440,17 @@ scrcpy(struct scrcpy_options *options) { .video_bit_rate = options->video_bit_rate, .audio_bit_rate = options->audio_bit_rate, .max_fps = options->max_fps, - .lock_video_orientation = options->lock_video_orientation, + .angle = options->angle, + .screen_off_timeout = options->screen_off_timeout, + .capture_orientation = options->capture_orientation, + .capture_orientation_lock = options->capture_orientation_lock, .control = options->control, .display_id = options->display_id, + .new_display = options->new_display, + .display_ime_policy = options->display_ime_policy, .video = options->video, .audio = options->audio, + .audio_dup = options->audio_dup, .show_touches = options->show_touches, .stay_awake = options->stay_awake, .video_codec_options = options->video_codec_options, @@ -387,6 +471,8 @@ scrcpy(struct scrcpy_options *options) { .power_on = options->power_on, .kill_adb_on_close = options->kill_adb_on_close, .camera_high_speed = options->camera_high_speed, + .vd_destroy_content = options->vd_destroy_content, + .vd_system_decorations = options->vd_system_decorations, .list = options->list, }; @@ -399,6 +485,12 @@ scrcpy(struct scrcpy_options *options) { return SCRCPY_EXIT_FAILURE; } + if (options->window) { + // Set hints before starting the server thread to avoid race conditions + // in SDL + sdl_set_hints(options->render_driver); + } + if (!sc_server_start(&s->server)) { goto end; } @@ -415,16 +507,21 @@ scrcpy(struct scrcpy_options *options) { assert(!options->video_playback || options->video); assert(!options->audio_playback || options->audio); - if (options->video_playback) { - sdl_set_hints(options->render_driver); - } - - // Initialize the video subsystem even if --no-video or --no-video-playback - // is passed so that clipboard synchronization still works. - // - if (SDL_Init(SDL_INIT_VIDEO)) { - LOGE("Could not initialize SDL video: %s", SDL_GetError()); - goto end; + if (options->window || + (options->control && options->clipboard_autosync)) { + // Initialize the video subsystem even if --no-video or + // --no-video-playback is passed so that clipboard synchronization + // still works. + // + if (SDL_Init(SDL_INIT_VIDEO)) { + // If it fails, it is an error only if video playback is enabled + if (options->video_playback) { + LOGE("Could not initialize SDL video: %s", SDL_GetError()); + goto end; + } else { + LOGW("Could not initialize SDL video: %s", SDL_GetError()); + } + } } if (options->audio_playback) { @@ -434,6 +531,13 @@ scrcpy(struct scrcpy_options *options) { } } + if (options->gamepad_input_mode != SC_GAMEPAD_INPUT_MODE_DISABLED) { + if (SDL_Init(SDL_INIT_GAMECONTROLLER)) { + LOGE("Could not initialize SDL gamepad: %s", SDL_GetError()); + goto end; + } + } + sdl_configure(options->video_playback, options->disable_screensaver); // Await for server without blocking Ctrl+C handling @@ -531,14 +635,29 @@ scrcpy(struct scrcpy_options *options) { struct sc_controller *controller = NULL; struct sc_key_processor *kp = NULL; struct sc_mouse_processor *mp = NULL; + struct sc_gamepad_processor *gp = NULL; if (options->control) { + static const struct sc_controller_callbacks controller_cbs = { + .on_ended = sc_controller_on_ended, + }; + + if (!sc_controller_init(&s->controller, s->server.control_socket, + &controller_cbs, NULL)) { + goto end; + } + controller_initialized = true; + + controller = &s->controller; + #ifdef HAVE_USB - bool use_hid_keyboard = - options->keyboard_input_mode == SC_KEYBOARD_INPUT_MODE_HID; - bool use_hid_mouse = - options->mouse_input_mode == SC_MOUSE_INPUT_MODE_HID; - if (use_hid_keyboard || use_hid_mouse) { + bool use_keyboard_aoa = + options->keyboard_input_mode == SC_KEYBOARD_INPUT_MODE_AOA; + bool use_mouse_aoa = + options->mouse_input_mode == SC_MOUSE_INPUT_MODE_AOA; + bool use_gamepad_aoa = + options->gamepad_input_mode == SC_GAMEPAD_INPUT_MODE_AOA; + if (use_keyboard_aoa || use_mouse_aoa || use_gamepad_aoa) { bool ok = sc_acksync_init(&s->acksync); if (!ok) { goto end; @@ -548,7 +667,7 @@ scrcpy(struct scrcpy_options *options) { if (!ok) { LOGE("Failed to initialize USB"); sc_acksync_destroy(&s->acksync); - goto aoa_hid_end; + goto end; } assert(serial); @@ -556,7 +675,7 @@ scrcpy(struct scrcpy_options *options) { ok = sc_usb_select_device(&s->usb, serial, &usb_device); if (!ok) { sc_usb_destroy(&s->usb); - goto aoa_hid_end; + goto end; } LOGI("USB device: %s (%04" PRIx16 ":%04" PRIx16 ") %s %s", @@ -569,7 +688,7 @@ scrcpy(struct scrcpy_options *options) { LOGE("Failed to connect to USB device %s", serial); sc_usb_destroy(&s->usb); sc_acksync_destroy(&s->acksync); - goto aoa_hid_end; + goto end; } ok = sc_aoa_init(&s->aoa, &s->usb, &s->acksync); @@ -578,113 +697,122 @@ scrcpy(struct scrcpy_options *options) { sc_usb_disconnect(&s->usb); sc_usb_destroy(&s->usb); sc_acksync_destroy(&s->acksync); - goto aoa_hid_end; + goto end; } - if (use_hid_keyboard) { - if (sc_hid_keyboard_init(&s->keyboard_hid, &s->aoa)) { - hid_keyboard_initialized = true; - kp = &s->keyboard_hid.key_processor; + bool aoa_fail = false; + if (use_keyboard_aoa) { + if (sc_keyboard_aoa_init(&s->keyboard_aoa, &s->aoa)) { + keyboard_aoa_initialized = true; + kp = &s->keyboard_aoa.key_processor; } else { LOGE("Could not initialize HID keyboard"); + aoa_fail = true; + goto aoa_complete; } } - if (use_hid_mouse) { - if (sc_hid_mouse_init(&s->mouse_hid, &s->aoa)) { - hid_mouse_initialized = true; - mp = &s->mouse_hid.mouse_processor; + if (use_mouse_aoa) { + if (sc_mouse_aoa_init(&s->mouse_aoa, &s->aoa)) { + mouse_aoa_initialized = true; + mp = &s->mouse_aoa.mouse_processor; } else { LOGE("Could not initialized HID mouse"); + aoa_fail = true; + goto aoa_complete; } } - bool need_aoa = hid_keyboard_initialized || hid_mouse_initialized; + if (use_gamepad_aoa) { + sc_gamepad_aoa_init(&s->gamepad_aoa, &s->aoa); + gp = &s->gamepad_aoa.gamepad_processor; + gamepad_aoa_initialized = true; + } - if (!need_aoa || !sc_aoa_start(&s->aoa)) { +aoa_complete: + if (aoa_fail || !sc_aoa_start(&s->aoa)) { sc_acksync_destroy(&s->acksync); sc_usb_disconnect(&s->usb); sc_usb_destroy(&s->usb); sc_aoa_destroy(&s->aoa); - goto aoa_hid_end; + goto end; } acksync = &s->acksync; aoa_hid_initialized = true; - -aoa_hid_end: - if (!aoa_hid_initialized) { - if (hid_keyboard_initialized) { - sc_hid_keyboard_destroy(&s->keyboard_hid); - hid_keyboard_initialized = false; - } - if (hid_mouse_initialized) { - sc_hid_mouse_destroy(&s->mouse_hid); - hid_mouse_initialized = false; - } - } - - if (use_hid_keyboard && !hid_keyboard_initialized) { - LOGE("Fallback to default keyboard injection method " - "(-K/--hid-keyboard ignored)"); - options->keyboard_input_mode = SC_KEYBOARD_INPUT_MODE_INJECT; - } - - if (use_hid_mouse && !hid_mouse_initialized) { - LOGE("Fallback to default mouse injection method " - "(-M/--hid-mouse ignored)"); - options->mouse_input_mode = SC_MOUSE_INPUT_MODE_INJECT; - } } #else - assert(options->keyboard_input_mode != SC_KEYBOARD_INPUT_MODE_HID); - assert(options->mouse_input_mode != SC_MOUSE_INPUT_MODE_HID); + assert(options->keyboard_input_mode != SC_KEYBOARD_INPUT_MODE_AOA); + assert(options->mouse_input_mode != SC_MOUSE_INPUT_MODE_AOA); #endif - // keyboard_input_mode may have been reset if HID mode failed - if (options->keyboard_input_mode == SC_KEYBOARD_INPUT_MODE_INJECT) { - sc_keyboard_inject_init(&s->keyboard_inject, &s->controller, - options->key_inject_mode, - options->forward_key_repeat); - kp = &s->keyboard_inject.key_processor; + struct sc_keyboard_uhid *uhid_keyboard = NULL; + + if (options->keyboard_input_mode == SC_KEYBOARD_INPUT_MODE_SDK) { + sc_keyboard_sdk_init(&s->keyboard_sdk, &s->controller, + options->key_inject_mode, + options->forward_key_repeat); + kp = &s->keyboard_sdk.key_processor; + } else if (options->keyboard_input_mode + == SC_KEYBOARD_INPUT_MODE_UHID) { + bool ok = sc_keyboard_uhid_init(&s->keyboard_uhid, &s->controller); + if (!ok) { + goto end; + } + kp = &s->keyboard_uhid.key_processor; + uhid_keyboard = &s->keyboard_uhid; } - // mouse_input_mode may have been reset if HID mode failed - if (options->mouse_input_mode == SC_MOUSE_INPUT_MODE_INJECT) { - sc_mouse_inject_init(&s->mouse_inject, &s->controller); - mp = &s->mouse_inject.mouse_processor; + if (options->mouse_input_mode == SC_MOUSE_INPUT_MODE_SDK) { + sc_mouse_sdk_init(&s->mouse_sdk, &s->controller, + options->mouse_hover); + mp = &s->mouse_sdk.mouse_processor; + } else if (options->mouse_input_mode == SC_MOUSE_INPUT_MODE_UHID) { + bool ok = sc_mouse_uhid_init(&s->mouse_uhid, &s->controller); + if (!ok) { + goto end; + } + mp = &s->mouse_uhid.mouse_processor; } - if (!sc_controller_init(&s->controller, s->server.control_socket, - acksync)) { - goto end; + if (options->gamepad_input_mode == SC_GAMEPAD_INPUT_MODE_UHID) { + sc_gamepad_uhid_init(&s->gamepad_uhid, &s->controller); + gp = &s->gamepad_uhid.gamepad_processor; } - controller_initialized = true; + + struct sc_uhid_devices *uhid_devices = NULL; + if (uhid_keyboard) { + sc_uhid_devices_init(&s->uhid_devices, uhid_keyboard); + uhid_devices = &s->uhid_devices; + } + + sc_controller_configure(&s->controller, acksync, uhid_devices); if (!sc_controller_start(&s->controller)) { goto end; } controller_started = true; - controller = &s->controller; } // There is a controller if and only if control is enabled assert(options->control == !!controller); - if (options->video_playback) { + if (options->window) { const char *window_title = options->window_title ? options->window_title : info->device_name; struct sc_screen_params screen_params = { + .video = options->video_playback, .controller = controller, .fp = fp, .kp = kp, .mp = mp, - .forward_all_clicks = options->forward_all_clicks, + .gp = gp, + .mouse_bindings = options->mouse_bindings, .legacy_paste = options->legacy_paste, .clipboard_autosync = options->clipboard_autosync, - .shortcut_mods = &options->shortcut_mods, + .shortcut_mods = options->shortcut_mods, .window_title = window_title, .always_on_top = options->always_on_top, .window_x = options->window_x, @@ -698,20 +826,22 @@ aoa_hid_end: .start_fps_counter = options->start_fps_counter, }; - 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 (!sc_screen_init(&s->screen, &screen_params)) { goto end; } screen_initialized = true; - sc_frame_source_add_sink(src, &s->screen.frame_sink); + if (options->video_playback) { + struct sc_frame_source *src = &s->video_decoder.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); + } } if (options->audio_playback) { @@ -761,11 +891,11 @@ aoa_hid_end: // 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'"); } } @@ -790,12 +920,40 @@ aoa_hid_end: timeout_started = true; } - ret = event_loop(s); + if (options->control + && options->gamepad_input_mode != SC_GAMEPAD_INPUT_MODE_DISABLED) { + 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, options->window); + terminate_event_loop(); LOGD("quit..."); - // Close the window immediately on closing, because screen_destroy() may - // only be called once the video demuxer thread is joined (it may take time) - sc_screen_hide_window(&s->screen); + if (options->video_playback) { + // Close the window immediately on closing, because screen_destroy() + // may only be called once the video demuxer thread is joined (it may + // take time) + sc_screen_hide_window(&s->screen); + } end: if (timeout_started) { @@ -806,11 +964,14 @@ end: // end-of-stream #ifdef HAVE_USB if (aoa_hid_initialized) { - if (hid_keyboard_initialized) { - sc_hid_keyboard_destroy(&s->keyboard_hid); + if (keyboard_aoa_initialized) { + sc_keyboard_aoa_destroy(&s->keyboard_aoa); } - if (hid_mouse_initialized) { - sc_hid_mouse_destroy(&s->mouse_hid); + if (mouse_aoa_initialized) { + sc_mouse_aoa_destroy(&s->mouse_aoa); + } + if (gamepad_aoa_initialized) { + sc_gamepad_aoa_destroy(&s->gamepad_aoa); } sc_aoa_stop(&s->aoa); sc_usb_stop(&s->usb); diff --git a/app/src/scrcpy.h b/app/src/scrcpy.h index d4d494a3..7f6a0fb2 100644 --- a/app/src/scrcpy.h +++ b/app/src/scrcpy.h @@ -3,7 +3,6 @@ #include "common.h" -#include #include "options.h" enum scrcpy_exit_code { diff --git a/app/src/screen.c b/app/src/screen.c index 091001bc..1d694f12 100644 --- a/app/src/screen.c +++ b/app/src/screen.c @@ -162,49 +162,10 @@ sc_screen_is_relative_mode(struct sc_screen *screen) { return screen->im.mp && screen->im.mp->relative_mode; } -static void -sc_screen_set_mouse_capture(struct sc_screen *screen, bool capture) { -#ifdef __APPLE__ - // Workaround for SDL bug on macOS: - // - if (capture) { - int mouse_x, mouse_y; - SDL_GetGlobalMouseState(&mouse_x, &mouse_y); - - int x, y, w, h; - SDL_GetWindowPosition(screen->window, &x, &y); - SDL_GetWindowSize(screen->window, &w, &h); - - bool outside_window = mouse_x < x || mouse_x >= x + w - || mouse_y < y || mouse_y >= y + h; - if (outside_window) { - SDL_WarpMouseInWindow(screen->window, w / 2, h / 2); - } - } -#else - (void) screen; -#endif - if (SDL_SetRelativeMouseMode(capture)) { - LOGE("Could not set relative mouse mode to %s: %s", - capture ? "true" : "false", SDL_GetError()); - } -} - -static inline bool -sc_screen_get_mouse_capture(struct sc_screen *screen) { - (void) screen; - return SDL_GetRelativeMouseMode(); -} - -static inline void -sc_screen_toggle_mouse_capture(struct sc_screen *screen) { - (void) screen; - bool new_value = !sc_screen_get_mouse_capture(screen); - sc_screen_set_mouse_capture(screen, new_value); -} - static void sc_screen_update_content_rect(struct sc_screen *screen) { + assert(screen->video); + int dw; int dh; SDL_GL_GetDrawableSize(screen->window, &dw, &dh); @@ -246,6 +207,8 @@ sc_screen_update_content_rect(struct sc_screen *screen) { // changed, so that the content rectangle is recomputed static void sc_screen_render(struct sc_screen *screen, bool update_content_rect) { + assert(screen->video); + if (update_content_rect) { sc_screen_update_content_rect(screen); } @@ -255,6 +218,13 @@ sc_screen_render(struct sc_screen *screen, bool update_content_rect) { (void) res; // any error already logged } +static void +sc_screen_render_novideo(struct sc_screen *screen) { + enum sc_display_result res = + sc_display_render(&screen->display, NULL, SC_ORIENTATION_0); + (void) res; // any error already logged +} + #if defined(__APPLE__) || defined(__WINDOWS__) # define CONTINUOUS_RESIZING_WORKAROUND #endif @@ -268,6 +238,8 @@ sc_screen_render(struct sc_screen *screen, bool update_content_rect) { static int event_watcher(void *data, SDL_Event *event) { struct sc_screen *screen = data; + assert(screen->video); + if (event->type == SDL_WINDOWEVENT && event->window.event == SDL_WINDOWEVENT_RESIZED) { // In practice, it seems to always be called from the same thread in @@ -286,6 +258,12 @@ sc_screen_frame_sink_open(struct sc_frame_sink *sink, struct sc_screen *screen = DOWNCAST(sink); + if (ctx->width <= 0 || ctx->width > 0xFFFF + || ctx->height <= 0 || ctx->height > 0xFFFF) { + LOGE("Invalid video size: %dx%d", ctx->width, ctx->height); + return false; + } + assert(ctx->width > 0 && ctx->width <= 0xFFFF); assert(ctx->height > 0 && ctx->height <= 0xFFFF); // screen->frame_size is never used before the event is pushed, and the @@ -293,14 +271,9 @@ sc_screen_frame_sink_open(struct sc_frame_sink *sink, screen->frame_size.width = ctx->width; screen->frame_size.height = ctx->height; - static SDL_Event event = { - .type = SC_EVENT_SCREEN_INIT_SIZE, - }; - // Post the event on the UI thread (the texture must be created from there) - int ret = SDL_PushEvent(&event); - if (ret < 0) { - LOGW("Could not post init size event: %s", SDL_GetError()); + bool ok = sc_push_event(SC_EVENT_SCREEN_INIT_SIZE); + if (!ok) { return false; } @@ -326,6 +299,7 @@ sc_screen_frame_sink_close(struct sc_frame_sink *sink) { static bool sc_screen_frame_sink_push(struct sc_frame_sink *sink, const AVFrame *frame) { struct sc_screen *screen = DOWNCAST(sink); + assert(screen->video); bool previous_skipped; bool ok = sc_frame_buffer_push(&screen->fb, frame, &previous_skipped); @@ -338,14 +312,9 @@ sc_screen_frame_sink_push(struct sc_frame_sink *sink, const AVFrame *frame) { // The SC_EVENT_NEW_FRAME triggered for the previous frame will consume // this new frame instead } else { - static SDL_Event new_frame_event = { - .type = SC_EVENT_NEW_FRAME, - }; - // Post the event on the UI thread - int ret = SDL_PushEvent(&new_frame_event); - if (ret < 0) { - LOGW("Could not post new frame event: %s", SDL_GetError()); + bool ok = sc_push_event(SC_EVENT_NEW_FRAME); + if (!ok) { return false; } } @@ -361,7 +330,11 @@ sc_screen_init(struct sc_screen *screen, screen->fullscreen = false; screen->maximized = false; screen->minimized = false; - screen->mouse_capture_key_pressed = 0; + screen->paused = false; + screen->resume_frame = NULL; + screen->orientation = SC_ORIENTATION_0; + + screen->video = params->video; screen->req.x = params->window_x; screen->req.y = params->window_y; @@ -379,41 +352,75 @@ sc_screen_init(struct sc_screen *screen, goto error_destroy_frame_buffer; } - screen->orientation = params->orientation; - if (screen->orientation != SC_ORIENTATION_0) { - LOGI("Initial display orientation set to %s", - sc_orientation_get_name(screen->orientation)); + if (screen->video) { + screen->orientation = params->orientation; + if (screen->orientation != SC_ORIENTATION_0) { + LOGI("Initial display orientation set to %s", + sc_orientation_get_name(screen->orientation)); + } } - uint32_t window_flags = SDL_WINDOW_HIDDEN - | SDL_WINDOW_RESIZABLE - | SDL_WINDOW_ALLOW_HIGHDPI; + uint32_t window_flags = SDL_WINDOW_ALLOW_HIGHDPI; if (params->always_on_top) { window_flags |= SDL_WINDOW_ALWAYS_ON_TOP; } if (params->window_borderless) { window_flags |= SDL_WINDOW_BORDERLESS; } + if (params->video) { + // The window will be shown on first frame + window_flags |= SDL_WINDOW_HIDDEN + | SDL_WINDOW_RESIZABLE; + } + + const char *title = params->window_title; + assert(title); + + int x = SDL_WINDOWPOS_UNDEFINED; + int y = SDL_WINDOWPOS_UNDEFINED; + int width = 256; + int height = 256; + if (params->window_x != SC_WINDOW_POSITION_UNDEFINED) { + x = params->window_x; + } + if (params->window_y != SC_WINDOW_POSITION_UNDEFINED) { + y = params->window_y; + } + if (params->window_width) { + width = params->window_width; + } + if (params->window_height) { + height = params->window_height; + } // The window will be positioned and sized on first video frame - screen->window = - SDL_CreateWindow(params->window_title, 0, 0, 0, 0, window_flags); + screen->window = SDL_CreateWindow(title, x, y, width, height, window_flags); if (!screen->window) { LOGE("Could not create window: %s", SDL_GetError()); goto error_destroy_fps_counter; } - ok = sc_display_init(&screen->display, screen->window, params->mipmaps); - if (!ok) { - goto error_destroy_window; - } - SDL_Surface *icon = scrcpy_icon_load(); if (icon) { SDL_SetWindowIcon(screen->window, icon); - scrcpy_icon_destroy(icon); - } else { + } else if (params->video) { + // just a warning LOGW("Could not load icon"); + } else { + // without video, the icon is used as window content, it must be present + LOGE("Could not load icon"); + goto error_destroy_fps_counter; + } + + SDL_Surface *icon_novideo = params->video ? NULL : icon; + bool mipmaps = params->video && params->mipmaps; + ok = sc_display_init(&screen->display, screen->window, icon_novideo, + mipmaps); + if (icon) { + scrcpy_icon_destroy(icon); + } + if (!ok) { + goto error_destroy_window; } screen->frame = av_frame_alloc(); @@ -428,7 +435,8 @@ sc_screen_init(struct sc_screen *screen, .screen = screen, .kp = params->kp, .mp = params->mp, - .forward_all_clicks = params->forward_all_clicks, + .gp = params->gp, + .mouse_bindings = params->mouse_bindings, .legacy_paste = params->legacy_paste, .clipboard_autosync = params->clipboard_autosync, .shortcut_mods = params->shortcut_mods, @@ -436,8 +444,13 @@ sc_screen_init(struct sc_screen *screen, sc_input_manager_init(&screen->im, &im_params); + // Initialize even if not used for simplicity + sc_mouse_capture_init(&screen->mc, screen->window, params->shortcut_mods); + #ifdef CONTINUOUS_RESIZING_WORKAROUND - SDL_AddEventWatch(event_watcher, screen); + if (screen->video) { + SDL_AddEventWatch(event_watcher, screen); + } #endif static const struct sc_frame_sink_ops ops = { @@ -452,6 +465,11 @@ sc_screen_init(struct sc_screen *screen, screen->open = false; #endif + if (!screen->video && sc_screen_is_relative_mode(screen)) { + // Capture mouse immediately if video mirroring is disabled + sc_mouse_capture_set_active(&screen->mc, true); + } + return true; error_destroy_display: @@ -481,7 +499,7 @@ sc_screen_show_initial_window(struct sc_screen *screen) { SDL_SetWindowPosition(screen->window, x, y); if (screen->req.fullscreen) { - sc_screen_switch_fullscreen(screen); + sc_screen_toggle_fullscreen(screen); } if (screen->req.start_fps_counter) { @@ -522,6 +540,8 @@ sc_screen_destroy(struct sc_screen *screen) { static void resize_for_content(struct sc_screen *screen, struct sc_size old_content_size, struct sc_size new_content_size) { + assert(screen->video); + struct sc_size window_size = get_window_size(screen); struct sc_size target_size = { .width = (uint32_t) window_size.width * new_content_size.width @@ -535,6 +555,8 @@ resize_for_content(struct sc_screen *screen, struct sc_size old_content_size, static void set_content_size(struct sc_screen *screen, struct sc_size new_content_size) { + assert(screen->video); + if (!screen->fullscreen && !screen->maximized && !screen->minimized) { resize_for_content(screen, screen->content_size, new_content_size); } else if (!screen->resize_pending) { @@ -549,6 +571,8 @@ set_content_size(struct sc_screen *screen, struct sc_size new_content_size) { static void apply_pending_resize(struct sc_screen *screen) { + assert(screen->video); + assert(!screen->fullscreen); assert(!screen->maximized); assert(!screen->minimized); @@ -562,6 +586,8 @@ apply_pending_resize(struct sc_screen *screen) { void sc_screen_set_orientation(struct sc_screen *screen, enum sc_orientation orientation) { + assert(screen->video); + if (orientation == screen->orientation) { return; } @@ -596,6 +622,8 @@ sc_screen_init_size(struct sc_screen *screen) { // recreate the texture and resize the window if the frame size has changed static enum sc_display_result prepare_for_frame(struct sc_screen *screen, struct sc_size new_frame_size) { + assert(screen->video); + if (screen->frame_size.width == new_frame_size.width && screen->frame_size.height == new_frame_size.height) { return SC_DISPLAY_RESULT_OK; @@ -614,13 +642,12 @@ prepare_for_frame(struct sc_screen *screen, struct sc_size new_frame_size) { } static bool -sc_screen_update_frame(struct sc_screen *screen) { - av_frame_unref(screen->frame); - sc_frame_buffer_consume(&screen->fb, screen->frame); - AVFrame *frame = screen->frame; +sc_screen_apply_frame(struct sc_screen *screen) { + assert(screen->video); sc_fps_counter_add_rendered_frame(&screen->fps_counter); + AVFrame *frame = screen->frame; struct sc_size new_frame_size = {frame->width, frame->height}; enum sc_display_result res = prepare_for_frame(screen, new_frame_size); if (res == SC_DISPLAY_RESULT_ERROR) { @@ -647,7 +674,7 @@ sc_screen_update_frame(struct sc_screen *screen) { if (sc_screen_is_relative_mode(screen)) { // Capture mouse on start - sc_screen_set_mouse_capture(screen, true); + sc_mouse_capture_set_active(&screen->mc, true); } } @@ -655,8 +682,62 @@ sc_screen_update_frame(struct sc_screen *screen) { return true; } +static bool +sc_screen_update_frame(struct sc_screen *screen) { + assert(screen->video); + + if (screen->paused) { + if (!screen->resume_frame) { + screen->resume_frame = av_frame_alloc(); + if (!screen->resume_frame) { + LOG_OOM(); + return false; + } + } else { + av_frame_unref(screen->resume_frame); + } + sc_frame_buffer_consume(&screen->fb, screen->resume_frame); + return true; + } + + av_frame_unref(screen->frame); + sc_frame_buffer_consume(&screen->fb, screen->frame); + return sc_screen_apply_frame(screen); +} + void -sc_screen_switch_fullscreen(struct sc_screen *screen) { +sc_screen_set_paused(struct sc_screen *screen, bool paused) { + assert(screen->video); + + if (!paused && !screen->paused) { + // nothing to do + return; + } + + if (screen->paused && screen->resume_frame) { + // If display screen was paused, refresh the frame immediately, even if + // the new state is also paused. + av_frame_free(&screen->frame); + screen->frame = screen->resume_frame; + screen->resume_frame = NULL; + sc_screen_apply_frame(screen); + } + + if (!paused) { + LOGI("Display screen unpaused"); + } else if (!screen->paused) { + LOGI("Display screen paused"); + } else { + LOGI("Display screen re-paused"); + } + + screen->paused = paused; +} + +void +sc_screen_toggle_fullscreen(struct sc_screen *screen) { + assert(screen->video); + uint32_t new_mode = screen->fullscreen ? 0 : SDL_WINDOW_FULLSCREEN_DESKTOP; if (SDL_SetWindowFullscreen(screen->window, new_mode)) { LOGW("Could not switch fullscreen mode: %s", SDL_GetError()); @@ -674,6 +755,8 @@ sc_screen_switch_fullscreen(struct sc_screen *screen) { void sc_screen_resize_to_fit(struct sc_screen *screen) { + assert(screen->video); + if (screen->fullscreen || screen->maximized || screen->minimized) { return; } @@ -698,6 +781,8 @@ sc_screen_resize_to_fit(struct sc_screen *screen) { void sc_screen_resize_to_pixel_perfect(struct sc_screen *screen) { + assert(screen->video); + if (screen->fullscreen || screen->minimized) { return; } @@ -713,15 +798,8 @@ sc_screen_resize_to_pixel_perfect(struct sc_screen *screen) { content_size.height); } -static inline bool -sc_screen_is_mouse_capture_key(SDL_Keycode key) { - return key == SDLK_LALT || key == SDLK_LGUI || key == SDLK_RGUI; -} - bool sc_screen_handle_event(struct sc_screen *screen, const SDL_Event *event) { - bool relative_mode = sc_screen_is_relative_mode(screen); - switch (event->type) { case SC_EVENT_SCREEN_INIT_SIZE: { // The initial size is passed via screen->frame_size @@ -741,6 +819,13 @@ sc_screen_handle_event(struct sc_screen *screen, const SDL_Event *event) { return true; } case SDL_WINDOWEVENT: + if (!screen->video + && event->window.event == SDL_WINDOWEVENT_EXPOSED) { + sc_screen_render_novideo(screen); + } + + // !video implies !has_frame + assert(screen->video || !screen->has_frame); if (!screen->has_frame) { // Do nothing return true; @@ -772,69 +857,14 @@ sc_screen_handle_event(struct sc_screen *screen, const SDL_Event *event) { apply_pending_resize(screen); sc_screen_render(screen, true); break; - case SDL_WINDOWEVENT_FOCUS_LOST: - if (relative_mode) { - sc_screen_set_mouse_capture(screen, false); - } - break; } return true; - case SDL_KEYDOWN: - if (relative_mode) { - SDL_Keycode key = event->key.keysym.sym; - if (sc_screen_is_mouse_capture_key(key)) { - if (!screen->mouse_capture_key_pressed) { - screen->mouse_capture_key_pressed = key; - } else { - // Another mouse capture key has been pressed, cancel - // mouse (un)capture - screen->mouse_capture_key_pressed = 0; - } - // Mouse capture keys are never forwarded to the device - return true; - } - } - break; - case SDL_KEYUP: - if (relative_mode) { - SDL_Keycode key = event->key.keysym.sym; - SDL_Keycode cap = screen->mouse_capture_key_pressed; - screen->mouse_capture_key_pressed = 0; - if (sc_screen_is_mouse_capture_key(key)) { - if (key == cap) { - // A mouse capture key has been pressed then released: - // toggle the capture mouse mode - sc_screen_toggle_mouse_capture(screen); - } - // Mouse capture keys are never forwarded to the device - return true; - } - } - break; - case SDL_MOUSEWHEEL: - case SDL_MOUSEMOTION: - case SDL_MOUSEBUTTONDOWN: - if (relative_mode && !sc_screen_get_mouse_capture(screen)) { - // Do not forward to input manager, the mouse will be captured - // on SDL_MOUSEBUTTONUP - return true; - } - break; - case SDL_FINGERMOTION: - case SDL_FINGERDOWN: - case SDL_FINGERUP: - if (relative_mode) { - // Touch events are not compatible with relative mode - // (coordinates are not relative) - return true; - } - break; - case SDL_MOUSEBUTTONUP: - if (relative_mode && !sc_screen_get_mouse_capture(screen)) { - sc_screen_set_mouse_capture(screen, true); - return true; - } - break; + } + + if (sc_screen_is_relative_mode(screen) + && sc_mouse_capture_handle_event(&screen->mc, event)) { + // The mouse capture handler consumed the event + return true; } sc_input_manager_handle_event(&screen->im, event); @@ -844,6 +874,8 @@ sc_screen_handle_event(struct sc_screen *screen, const SDL_Event *event) { struct sc_point sc_screen_convert_drawable_to_frame_coords(struct sc_screen *screen, int32_t x, int32_t y) { + assert(screen->video); + enum sc_orientation orientation = screen->orientation; int32_t w = screen->content_size.width; diff --git a/app/src/screen.h b/app/src/screen.h index 46591be5..6621b2d2 100644 --- a/app/src/screen.h +++ b/app/src/screen.h @@ -1,11 +1,14 @@ -#ifndef SCREEN_H -#define SCREEN_H +#ifndef SC_SCREEN_H +#define SC_SCREEN_H #include "common.h" #include +#include #include -#include +#include +#include +#include #include "controller.h" #include "coords.h" @@ -13,7 +16,7 @@ #include "fps_counter.h" #include "frame_buffer.h" #include "input_manager.h" -#include "opengl.h" +#include "mouse_capture.h" #include "options.h" #include "trait/key_processor.h" #include "trait/frame_sink.h" @@ -26,8 +29,11 @@ struct sc_screen { bool open; // track the open/close state to assert correct behavior #endif + bool video; + struct sc_display display; struct sc_input_manager im; + struct sc_mouse_capture mc; // only used in mouse relative mode struct sc_frame_buffer fb; struct sc_fps_counter fps_counter; @@ -59,23 +65,25 @@ struct sc_screen { bool maximized; bool minimized; - // To enable/disable mouse capture, a mouse capture key (LALT, LGUI or - // RGUI) must be pressed. This variable tracks the pressed capture key. - SDL_Keycode mouse_capture_key_pressed; - AVFrame *frame; + + bool paused; + AVFrame *resume_frame; }; struct sc_screen_params { + bool video; + struct sc_controller *controller; struct sc_file_pusher *fp; struct sc_key_processor *kp; struct sc_mouse_processor *mp; + struct sc_gamepad_processor *gp; - bool forward_all_clicks; + struct sc_mouse_bindings mouse_bindings; bool legacy_paste; bool clipboard_autosync; - const struct sc_shortcut_mods *shortcut_mods; + uint8_t shortcut_mods; // OR of enum sc_shortcut_mod values const char *window_title; bool always_on_top; @@ -118,9 +126,9 @@ sc_screen_destroy(struct sc_screen *screen); void sc_screen_hide_window(struct sc_screen *screen); -// switch the fullscreen mode +// toggle the fullscreen mode void -sc_screen_switch_fullscreen(struct sc_screen *screen); +sc_screen_toggle_fullscreen(struct sc_screen *screen); // resize window to optimal size (remove black borders) void @@ -135,6 +143,10 @@ void sc_screen_set_orientation(struct sc_screen *screen, enum sc_orientation orientation); +// set the display pause state +void +sc_screen_set_paused(struct sc_screen *screen, bool paused); + // react to SDL events // If this function returns false, scrcpy must exit with an error. bool diff --git a/app/src/server.c b/app/src/server.c index d4726c2a..153219c3 100644 --- a/app/src/server.c +++ b/app/src/server.c @@ -1,18 +1,18 @@ #include "server.h" #include -#include #include #include -#include -#include +#include +#include +#include #include "adb/adb.h" -#include "util/binary.h" +#include "util/env.h" #include "util/file.h" #include "util/log.h" #include "util/net_intr.h" -#include "util/process_intr.h" +#include "util/process.h" #include "util/str.h" #define SC_SERVER_FILENAME "scrcpy-server" @@ -25,35 +25,22 @@ static char * get_server_path(void) { -#ifdef __WINDOWS__ - const wchar_t *server_path_env = _wgetenv(L"SCRCPY_SERVER_PATH"); -#else - const char *server_path_env = getenv("SCRCPY_SERVER_PATH"); -#endif - if (server_path_env) { + char *server_path = sc_get_env("SCRCPY_SERVER_PATH"); + if (server_path) { // if the envvar is set, use it -#ifdef __WINDOWS__ - char *server_path = sc_str_from_wchars(server_path_env); -#else - char *server_path = strdup(server_path_env); -#endif - if (!server_path) { - LOG_OOM(); - return NULL; - } LOGD("Using SCRCPY_SERVER_PATH: %s", server_path); return server_path; } #ifndef PORTABLE LOGD("Using server: " SC_SERVER_PATH_DEFAULT); - char *server_path = strdup(SC_SERVER_PATH_DEFAULT); + server_path = strdup(SC_SERVER_PATH_DEFAULT); if (!server_path) { LOG_OOM(); return NULL; } #else - char *server_path = sc_file_get_local_path(SC_SERVER_FILENAME); + server_path = sc_file_get_local_path(SC_SERVER_FILENAME); if (!server_path) { LOGE("Could not get local file path, " "using " SC_SERVER_FILENAME " from current directory"); @@ -66,56 +53,6 @@ get_server_path(void) { return server_path; } -static void -sc_server_params_destroy(struct sc_server_params *params) { - // The server stores a copy of the params provided by the user - free((char *) params->req_serial); - free((char *) params->crop); - free((char *) params->video_codec_options); - free((char *) params->audio_codec_options); - free((char *) params->video_encoder); - free((char *) params->audio_encoder); - free((char *) params->tcpip_dst); - free((char *) params->camera_id); - free((char *) params->camera_ar); -} - -static bool -sc_server_params_copy(struct sc_server_params *dst, - const struct sc_server_params *src) { - *dst = *src; - - // The params reference user-allocated memory, so we must copy them to - // handle them from another thread - -#define COPY(FIELD) do { \ - dst->FIELD = NULL; \ - if (src->FIELD) { \ - dst->FIELD = strdup(src->FIELD); \ - if (!dst->FIELD) { \ - goto error; \ - } \ - } \ -} while(0) - - COPY(req_serial); - COPY(crop); - COPY(video_codec_options); - COPY(audio_codec_options); - COPY(video_encoder); - COPY(audio_encoder); - COPY(tcpip_dst); - COPY(camera_id); - COPY(camera_ar); -#undef COPY - - return true; - -error: - sc_server_params_destroy(dst); - return false; -} - static bool push_server(struct sc_intr *intr, const char *serial) { char *server_path = get_server_path(); @@ -147,7 +84,7 @@ log_level_to_server_string(enum sc_log_level level) { return "error"; default: assert(!"unexpected log level"); - return "(unknown)"; + return NULL; } } @@ -183,6 +120,7 @@ sc_server_get_codec_name(enum sc_codec codec) { case SC_CODEC_RAW: return "raw"; default: + assert(!"unexpected codec"); return NULL; } } @@ -197,10 +135,72 @@ sc_server_get_camera_facing_name(enum sc_camera_facing camera_facing) { case SC_CAMERA_FACING_EXTERNAL: return "external"; default: + assert(!"unexpected camera facing"); return NULL; } } +static const char * +sc_server_get_audio_source_name(enum sc_audio_source audio_source) { + switch (audio_source) { + case SC_AUDIO_SOURCE_OUTPUT: + return "output"; + case SC_AUDIO_SOURCE_MIC: + return "mic"; + case SC_AUDIO_SOURCE_PLAYBACK: + return "playback"; + case SC_AUDIO_SOURCE_MIC_UNPROCESSED: + return "mic-unprocessed"; + case SC_AUDIO_SOURCE_MIC_CAMCORDER: + return "mic-camcorder"; + case SC_AUDIO_SOURCE_MIC_VOICE_RECOGNITION: + return "mic-voice-recognition"; + case SC_AUDIO_SOURCE_MIC_VOICE_COMMUNICATION: + return "mic-voice-communication"; + case SC_AUDIO_SOURCE_VOICE_CALL: + return "voice-call"; + case SC_AUDIO_SOURCE_VOICE_CALL_UPLINK: + return "voice-call-uplink"; + case SC_AUDIO_SOURCE_VOICE_CALL_DOWNLINK: + return "voice-call-downlink"; + case SC_AUDIO_SOURCE_VOICE_PERFORMANCE: + return "voice-performance"; + default: + assert(!"unexpected audio source"); + return NULL; + } +} + +static const char * +sc_server_get_display_ime_policy_name(enum sc_display_ime_policy policy) { + switch (policy) { + case SC_DISPLAY_IME_POLICY_LOCAL: + return "local"; + case SC_DISPLAY_IME_POLICY_FALLBACK: + return "fallback"; + case SC_DISPLAY_IME_POLICY_HIDE: + return "hide"; + default: + assert(!"unexpected display IME policy"); + return NULL; + } +} + +static bool +validate_string(const char *s) { + // The parameters values are passed as command line arguments to adb, so + // they must either be properly escaped, or they must not contain any + // special shell characters. + // Since they are not properly escaped on Windows anyway (see + // sys/win/process.c), just forbid special shell characters. + if (strpbrk(s, " ;'\"*$?&`#\\|<>[]{}()!~\r\n")) { + LOGE("Invalid server param: [%s]", s); + return false; + } + + return true; +} + static sc_pid execute_server(struct sc_server *server, const struct sc_server_params *params) { @@ -219,18 +219,31 @@ execute_server(struct sc_server *server, cmd[count++] = "app_process"; #ifdef SERVER_DEBUGGER + uint16_t sdk_version = sc_adb_get_device_sdk_version(&server->intr, serial); + 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 if (sdk_version < 30) { + // Android >= 9 && Android < 11 + dbg = "-XjdwpProvider:internal -XjdwpOptions:transport=dt_socket," + "suspend=y,server=y,address=" SERVER_DEBUGGER_PORT; + } else { + // Android >= 11 + // Contrary to the other methods, this does not suspend on start. + // + dbg = "-XjdwpProvider:adbconnection"; + } + cmd[count++] = dbg; #endif + cmd[count++] = "/"; // unused cmd[count++] = "com.genymobile.scrcpy.Server"; cmd[count++] = SCRCPY_VERSION; @@ -243,6 +256,11 @@ execute_server(struct sc_server *server, } \ cmd[count++] = p; \ } while(0) +#define VALIDATE_STRING(s) do { \ + if (!validate_string(s)) { \ + goto end; \ + } \ + } while(0) ADD_PARAM("scid=%08x", params->scid); ADD_PARAM("log_level=%s", log_level_to_server_string(params->log_level)); @@ -271,23 +289,43 @@ execute_server(struct sc_server *server, assert(params->video_source == SC_VIDEO_SOURCE_CAMERA); ADD_PARAM("video_source=camera"); } - if (params->audio_source == SC_AUDIO_SOURCE_MIC) { - ADD_PARAM("audio_source=mic"); + // If audio is enabled, an "auto" audio source must have been resolved + assert(params->audio_source != SC_AUDIO_SOURCE_AUTO || !params->audio); + if (params->audio_source != SC_AUDIO_SOURCE_OUTPUT && params->audio) { + ADD_PARAM("audio_source=%s", + sc_server_get_audio_source_name(params->audio_source)); + } + if (params->audio_dup) { + ADD_PARAM("audio_dup=true"); } if (params->max_size) { ADD_PARAM("max_size=%" PRIu16, params->max_size); } if (params->max_fps) { - ADD_PARAM("max_fps=%" PRIu16, params->max_fps); + VALIDATE_STRING(params->max_fps); + ADD_PARAM("max_fps=%s", params->max_fps); } - if (params->lock_video_orientation != SC_LOCK_VIDEO_ORIENTATION_UNLOCKED) { - ADD_PARAM("lock_video_orientation=%" PRIi8, - params->lock_video_orientation); + if (params->angle) { + VALIDATE_STRING(params->angle); + ADD_PARAM("angle=%s", params->angle); + } + if (params->capture_orientation_lock != SC_ORIENTATION_UNLOCKED + || params->capture_orientation != SC_ORIENTATION_0) { + if (params->capture_orientation_lock == SC_ORIENTATION_LOCKED_INITIAL) { + ADD_PARAM("capture_orientation=@"); + } else { + const char *orient = + sc_orientation_get_name(params->capture_orientation); + bool locked = + params->capture_orientation_lock != SC_ORIENTATION_UNLOCKED; + ADD_PARAM("capture_orientation=%s%s", locked ? "@" : "", orient); + } } if (server->tunnel.forward) { ADD_PARAM("tunnel_forward=true"); } if (params->crop) { + VALIDATE_STRING(params->crop); ADD_PARAM("crop=%s", params->crop); } if (!params->control) { @@ -298,9 +336,11 @@ execute_server(struct sc_server *server, ADD_PARAM("display_id=%" PRIu32, params->display_id); } if (params->camera_id) { + VALIDATE_STRING(params->camera_id); ADD_PARAM("camera_id=%s", params->camera_id); } if (params->camera_size) { + VALIDATE_STRING(params->camera_size); ADD_PARAM("camera_size=%s", params->camera_size); } if (params->camera_facing != SC_CAMERA_FACING_ANY) { @@ -308,6 +348,7 @@ execute_server(struct sc_server *server, sc_server_get_camera_facing_name(params->camera_facing)); } if (params->camera_ar) { + VALIDATE_STRING(params->camera_ar); ADD_PARAM("camera_ar=%s", params->camera_ar); } if (params->camera_fps) { @@ -322,16 +363,25 @@ 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); } if (params->audio_codec_options) { + VALIDATE_STRING(params->audio_codec_options); ADD_PARAM("audio_codec_options=%s", params->audio_codec_options); } if (params->video_encoder) { + VALIDATE_STRING(params->video_encoder); ADD_PARAM("video_encoder=%s", params->video_encoder); } if (params->audio_encoder) { + VALIDATE_STRING(params->audio_encoder); ADD_PARAM("audio_encoder=%s", params->audio_encoder); } if (params->power_off_on_close) { @@ -353,6 +403,20 @@ 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->display_ime_policy != SC_DISPLAY_IME_POLICY_UNDEFINED) { + ADD_PARAM("display_ime_policy=%s", + sc_server_get_display_ime_policy_name(params->display_ime_policy)); + } + if (!params->vd_destroy_content) { + ADD_PARAM("vd_destroy_content=false"); + } + if (!params->vd_system_decorations) { + ADD_PARAM("vd_system_decorations=false"); + } if (params->list & SC_OPTION_LIST_ENCODERS) { ADD_PARAM("list_encoders=true"); } @@ -365,16 +429,23 @@ 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 cmd[count++] = NULL; #ifdef SERVER_DEBUGGER - LOGI("Server debugger waiting for a client on device port " - SERVER_DEBUGGER_PORT "..."); - // From the computer, run - // adb forward tcp:5005 tcp:5005 + LOGI("Server debugger listening%s...", + sdk_version < 30 ? " on port " SERVER_DEBUGGER_PORT : ""); + // For Android < 11, from the computer: + // - run `adb forward tcp:5005 tcp:5005` + // For Android >= 11: + // - execute `adb jdwp` to get the jdwp port + // - run `adb forward tcp:5005 jdwp:XXXX` (replace XXXX) + // // Then, from Android Studio: Run > Debug > Edit configurations... // On the left, click on '+', "Remote", with: // Host: localhost @@ -447,22 +518,25 @@ connect_to_server(struct sc_server *server, unsigned attempts, sc_tick delay, bool sc_server_init(struct sc_server *server, const struct sc_server_params *params, const struct sc_server_callbacks *cbs, void *cbs_userdata) { - bool ok = sc_server_params_copy(&server->params, params); + // The allocated data in params (const char *) must remain valid until the + // end of the program + server->params = *params; + + bool ok = sc_adb_init(); if (!ok) { - LOG_OOM(); return false; } ok = sc_mutex_init(&server->mutex); if (!ok) { - sc_server_params_destroy(&server->params); + sc_adb_destroy(); return false; } ok = sc_cond_init(&server->cond_stopped); if (!ok) { sc_mutex_destroy(&server->mutex); - sc_server_params_destroy(&server->params); + sc_adb_destroy(); return false; } @@ -470,7 +544,7 @@ sc_server_init(struct sc_server *server, const struct sc_server_params *params, if (!ok) { sc_cond_destroy(&server->cond_stopped); sc_mutex_destroy(&server->mutex); - sc_server_params_destroy(&server->params); + sc_adb_destroy(); return false; } @@ -498,7 +572,7 @@ sc_server_init(struct sc_server *server, const struct sc_server_params *params, static bool device_read_info(struct sc_intr *intr, sc_socket device_socket, struct sc_server_info *info) { - unsigned char buf[SC_DEVICE_NAME_FIELD_LENGTH]; + uint8_t buf[SC_DEVICE_NAME_FIELD_LENGTH]; ssize_t r = net_recv_all_intr(intr, device_socket, buf, sizeof(buf)); if (r < SC_DEVICE_NAME_FIELD_LENGTH) { LOGE("Could not retrieve device information"); @@ -607,6 +681,14 @@ sc_server_connect_to(struct sc_server *server, struct sc_server_info *info) { } } + if (control_socket != SC_SOCKET_NONE) { + // Disable Nagle's algorithm for the control socket + // (it only impacts the sending side, so it is useless to set it + // for the other sockets) + bool ok = net_set_tcp_nodelay(control_socket, true); + (void) ok; // error already logged + } + // we don't need the adb tunnel anymore sc_adb_tunnel_close(tunnel, &server->intr, serial, server->device_socket_name); @@ -784,11 +866,14 @@ sc_server_switch_to_tcpip(struct sc_server *server, const char *serial) { } static bool -sc_server_connect_to_tcpip(struct sc_server *server, const char *ip_port) { +sc_server_connect_to_tcpip(struct sc_server *server, const char *ip_port, + bool disconnect) { struct sc_intr *intr = &server->intr; - // Error expected if not connected, do not report any error - sc_adb_disconnect(intr, ip_port, SC_ADB_SILENT); + if (disconnect) { + // Error expected if not connected, do not report any error + sc_adb_disconnect(intr, ip_port, SC_ADB_SILENT); + } LOGI("Connecting to %s...", ip_port); @@ -804,7 +889,7 @@ sc_server_connect_to_tcpip(struct sc_server *server, const char *ip_port) { static bool sc_server_configure_tcpip_known_address(struct sc_server *server, - const char *addr) { + const char *addr, bool disconnect) { // Append ":5555" if no port is present bool contains_port = strchr(addr, ':'); char *ip_port = contains_port ? strdup(addr) @@ -815,7 +900,7 @@ sc_server_configure_tcpip_known_address(struct sc_server *server, } server->serial = ip_port; - return sc_server_connect_to_tcpip(server, ip_port); + return sc_server_connect_to_tcpip(server, ip_port, disconnect); } static bool @@ -840,7 +925,7 @@ sc_server_configure_tcpip_unknown_address(struct sc_server *server, } server->serial = ip_port; - return sc_server_connect_to_tcpip(server, ip_port); + return sc_server_connect_to_tcpip(server, ip_port, false); } static void @@ -927,7 +1012,13 @@ run_server(void *data) { sc_adb_device_destroy(&device); } } else { - ok = sc_server_configure_tcpip_known_address(server, params->tcpip_dst); + // If the user passed a '+' (--tcpip=+ip), then disconnect first + const char *tcpip_dst = params->tcpip_dst; + bool plus = tcpip_dst[0] == '+'; + if (plus) { + ++tcpip_dst; + } + ok = sc_server_configure_tcpip_known_address(server, tcpip_dst, plus); if (!ok) { goto error_connection_failed; } @@ -1101,8 +1192,9 @@ sc_server_destroy(struct sc_server *server) { free(server->serial); free(server->device_socket_name); - sc_server_params_destroy(&server->params); sc_intr_destroy(&server->intr); sc_cond_destroy(&server->cond_stopped); sc_mutex_destroy(&server->mutex); + + sc_adb_destroy(); } diff --git a/app/src/server.h b/app/src/server.h index 062af0a9..5f4592de 100644 --- a/app/src/server.h +++ b/app/src/server.h @@ -1,19 +1,17 @@ -#ifndef SERVER_H -#define SERVER_H +#ifndef SC_SERVER_H +#define SC_SERVER_H #include "common.h" -#include #include #include #include "adb/adb_tunnel.h" -#include "coords.h" #include "options.h" #include "util/intr.h" -#include "util/log.h" #include "util/net.h" #include "util/thread.h" +#include "util/tick.h" #define SC_DEVICE_NAME_FIELD_LENGTH 64 struct sc_server_info { @@ -44,12 +42,18 @@ struct sc_server_params { uint16_t max_size; uint32_t video_bit_rate; uint32_t audio_bit_rate; - uint16_t max_fps; - int8_t lock_video_orientation; + const char *max_fps; // float to be parsed by the server + const char *angle; // float to be parsed by the server + sc_tick screen_off_timeout; + enum sc_orientation capture_orientation; + enum sc_orientation_lock capture_orientation_lock; bool control; uint32_t display_id; + const char *new_display; + enum sc_display_ime_policy display_ime_policy; bool video; bool audio; + bool audio_dup; bool show_touches; bool stay_awake; bool force_adb_forward; @@ -64,6 +68,8 @@ struct sc_server_params { bool power_on; bool kill_adb_on_close; bool camera_high_speed; + bool vd_destroy_content; + bool vd_system_decorations; uint8_t list; }; diff --git a/app/src/shortcut_mod.h b/app/src/shortcut_mod.h new file mode 100644 index 00000000..f6c13f03 --- /dev/null +++ b/app/src/shortcut_mod.h @@ -0,0 +1,61 @@ +#ifndef SC_SHORTCUT_MOD_H +#define SC_SHORTCUT_MOD_H + +#include "common.h" + +#include +#include +#include +#include + +#include "options.h" + +#define SC_SDL_SHORTCUT_MODS_MASK (KMOD_CTRL | KMOD_ALT | KMOD_GUI) + +// input: OR of enum sc_shortcut_mod +// output: OR of SDL_Keymod +static inline uint16_t +sc_shortcut_mods_to_sdl(uint8_t shortcut_mods) { + uint16_t sdl_mod = 0; + if (shortcut_mods & SC_SHORTCUT_MOD_LCTRL) { + sdl_mod |= KMOD_LCTRL; + } + if (shortcut_mods & SC_SHORTCUT_MOD_RCTRL) { + sdl_mod |= KMOD_RCTRL; + } + if (shortcut_mods & SC_SHORTCUT_MOD_LALT) { + sdl_mod |= KMOD_LALT; + } + if (shortcut_mods & SC_SHORTCUT_MOD_RALT) { + sdl_mod |= KMOD_RALT; + } + if (shortcut_mods & SC_SHORTCUT_MOD_LSUPER) { + sdl_mod |= KMOD_LGUI; + } + if (shortcut_mods & SC_SHORTCUT_MOD_RSUPER) { + sdl_mod |= KMOD_RGUI; + } + return sdl_mod; +} + +static inline bool +sc_shortcut_mods_is_shortcut_mod(uint16_t sdl_shortcut_mods, uint16_t sdl_mod) { + // sdl_shortcut_mods must be within the mask + assert(!(sdl_shortcut_mods & ~SC_SDL_SHORTCUT_MODS_MASK)); + + // at least one shortcut mod pressed? + return sdl_mod & sdl_shortcut_mods; +} + +static inline bool +sc_shortcut_mods_is_shortcut_key(uint16_t sdl_shortcut_mods, + SDL_Keycode keycode) { + return (sdl_shortcut_mods & KMOD_LCTRL && keycode == SDLK_LCTRL) + || (sdl_shortcut_mods & KMOD_RCTRL && keycode == SDLK_RCTRL) + || (sdl_shortcut_mods & KMOD_LALT && keycode == SDLK_LALT) + || (sdl_shortcut_mods & KMOD_RALT && keycode == SDLK_RALT) + || (sdl_shortcut_mods & KMOD_LGUI && keycode == SDLK_LGUI) + || (sdl_shortcut_mods & KMOD_RGUI && keycode == SDLK_RGUI); +} + +#endif diff --git a/app/src/sys/unix/file.c b/app/src/sys/unix/file.c index 9c3f7333..8f7fb074 100644 --- a/app/src/sys/unix/file.c +++ b/app/src/sys/unix/file.c @@ -1,11 +1,15 @@ #include "util/file.h" #include -#include #include +#include #include #include +#include #include +#ifdef __APPLE__ +# include // for _NSGetExecutablePath() +#endif #include "util/log.h" @@ -60,11 +64,22 @@ sc_file_get_executable_path(void) { } buf[len] = '\0'; return strdup(buf); +#elif defined(__APPLE__) + char buf[PATH_MAX]; + uint32_t bufsize = PATH_MAX; + if (_NSGetExecutablePath(buf, &bufsize) != 0) { + LOGE("Executable path buffer too small; need %u bytes", bufsize); + return NULL; + } + return realpath(buf, NULL); #else - // in practice, we only need this feature for portable builds, only used on - // Windows, so we don't care implementing it for every platform - // (it's useful to have a working version on Linux for debugging though) - return NULL; + // "_" is often used to store the full path of the command being executed + char *path = getenv("_"); + if (!path) { + LOGE("Could not determine executable path"); + return NULL; + } + return strdup(path); #endif } diff --git a/app/src/sys/unix/process.c b/app/src/sys/unix/process.c index 8c4a53c3..36d1ff7d 100644 --- a/app/src/sys/unix/process.c +++ b/app/src/sys/unix/process.c @@ -4,6 +4,8 @@ #include #include #include +#include +#include #include #include #include diff --git a/app/src/sys/win/process.c b/app/src/sys/win/process.c index 6e9da09c..6ae33d86 100644 --- a/app/src/sys/win/process.c +++ b/app/src/sys/win/process.c @@ -176,6 +176,8 @@ sc_process_execute_p(const char *const argv[], HANDLE *handle, unsigned flags, free(lpAttributeList); } + CloseHandle(pi.hThread); + // These handles are used by the child process, close them for this process if (pin) { CloseHandle(stdin_read_handle); diff --git a/app/src/trait/frame_sink.h b/app/src/trait/frame_sink.h index 8ef248b6..67be4d46 100644 --- a/app/src/trait/frame_sink.h +++ b/app/src/trait/frame_sink.h @@ -3,7 +3,6 @@ #include "common.h" -#include #include #include diff --git a/app/src/trait/frame_source.c b/app/src/trait/frame_source.c index 416eccd9..56848309 100644 --- a/app/src/trait/frame_source.c +++ b/app/src/trait/frame_source.c @@ -1,5 +1,7 @@ #include "frame_source.h" +#include + void sc_frame_source_init(struct sc_frame_source *source) { source->sink_count = 0; diff --git a/app/src/trait/frame_source.h b/app/src/trait/frame_source.h index 94222af0..cb1ef905 100644 --- a/app/src/trait/frame_source.h +++ b/app/src/trait/frame_source.h @@ -3,7 +3,9 @@ #include "common.h" -#include "frame_sink.h" +#include + +#include "trait/frame_sink.h" #define SC_FRAME_SOURCE_MAX_SINKS 2 diff --git a/app/src/trait/gamepad_processor.h b/app/src/trait/gamepad_processor.h new file mode 100644 index 00000000..5e8dc2a4 --- /dev/null +++ b/app/src/trait/gamepad_processor.h @@ -0,0 +1,56 @@ +#ifndef SC_GAMEPAD_PROCESSOR_H +#define SC_GAMEPAD_PROCESSOR_H + +#include "common.h" + +#include "input_events.h" + +/** + * Gamepad processor trait. + * + * Component able to handle gamepads devices and inject buttons and axis events. + */ +struct sc_gamepad_processor { + const struct sc_gamepad_processor_ops *ops; +}; + +struct sc_gamepad_processor_ops { + + /** + * Process a gamepad device added event + * + * This function is mandatory. + */ + void + (*process_gamepad_added)(struct sc_gamepad_processor *gp, + const struct sc_gamepad_device_event *event); + + /** + * Process a gamepad device removed event + * + * This function is mandatory. + */ + void + (*process_gamepad_removed)(struct sc_gamepad_processor *gp, + const struct sc_gamepad_device_event *event); + + /** + * Process a gamepad axis event + * + * This function is mandatory. + */ + void + (*process_gamepad_axis)(struct sc_gamepad_processor *gp, + const struct sc_gamepad_axis_event *event); + + /** + * Process a gamepad button event + * + * This function is mandatory. + */ + void + (*process_gamepad_button)(struct sc_gamepad_processor *gp, + const struct sc_gamepad_button_event *event); +}; + +#endif diff --git a/app/src/trait/key_processor.h b/app/src/trait/key_processor.h index 8c51b11d..9e9bb86e 100644 --- a/app/src/trait/key_processor.h +++ b/app/src/trait/key_processor.h @@ -3,7 +3,6 @@ #include "common.h" -#include #include #include "input_events.h" @@ -23,6 +22,13 @@ struct sc_key_processor { */ bool async_paste; + /** + * Set by the implementation to indicate that the keyboard is HID. In + * practice, it is used to react on a shortcut to open the hard keyboard + * settings only if the keyboard is HID. + */ + bool hid; + const struct sc_key_processor_ops *ops; }; diff --git a/app/src/trait/mouse_processor.h b/app/src/trait/mouse_processor.h index 6e0b596e..d0a96e7c 100644 --- a/app/src/trait/mouse_processor.h +++ b/app/src/trait/mouse_processor.h @@ -3,7 +3,6 @@ #include "common.h" -#include #include #include "input_events.h" diff --git a/app/src/trait/packet_sink.h b/app/src/trait/packet_sink.h index 84cfe814..e12dea12 100644 --- a/app/src/trait/packet_sink.h +++ b/app/src/trait/packet_sink.h @@ -3,7 +3,6 @@ #include "common.h" -#include #include #include diff --git a/app/src/trait/packet_source.c b/app/src/trait/packet_source.c index c0836f1d..0a2c6c4d 100644 --- a/app/src/trait/packet_source.c +++ b/app/src/trait/packet_source.c @@ -1,5 +1,7 @@ #include "packet_source.h" +#include + void sc_packet_source_init(struct sc_packet_source *source) { source->sink_count = 0; diff --git a/app/src/trait/packet_source.h b/app/src/trait/packet_source.h index 16d56e86..8788021a 100644 --- a/app/src/trait/packet_source.h +++ b/app/src/trait/packet_source.h @@ -3,7 +3,9 @@ #include "common.h" -#include "packet_sink.h" +#include + +#include "trait/packet_sink.h" #define SC_PACKET_SOURCE_MAX_SINKS 2 diff --git a/app/src/uhid/gamepad_uhid.c b/app/src/uhid/gamepad_uhid.c new file mode 100644 index 00000000..c64feb18 --- /dev/null +++ b/app/src/uhid/gamepad_uhid.c @@ -0,0 +1,146 @@ +#include "gamepad_uhid.h" + +#include +#include +#include +#include + +#include "hid/hid_gamepad.h" +#include "input_events.h" +#include "util/log.h" + +/** Downcast gamepad processor to sc_gamepad_uhid */ +#define DOWNCAST(GP) container_of(GP, struct sc_gamepad_uhid, gamepad_processor) + +// Xbox 360 +#define SC_GAMEPAD_UHID_VENDOR_ID UINT16_C(0x045e) +#define SC_GAMEPAD_UHID_PRODUCT_ID UINT16_C(0x028e) +#define SC_GAMEPAD_UHID_NAME "Microsoft X-Box 360 Pad" + +static void +sc_gamepad_uhid_send_input(struct sc_gamepad_uhid *gamepad, + const struct sc_hid_input *hid_input, + const char *name) { + struct sc_control_msg msg; + msg.type = SC_CONTROL_MSG_TYPE_UHID_INPUT; + msg.uhid_input.id = hid_input->hid_id; + + assert(hid_input->size <= SC_HID_MAX_SIZE); + memcpy(msg.uhid_input.data, hid_input->data, hid_input->size); + msg.uhid_input.size = hid_input->size; + + if (!sc_controller_push_msg(gamepad->controller, &msg)) { + LOGE("Could not push UHID_INPUT message (%s)", name); + } +} + +static void +sc_gamepad_uhid_send_open(struct sc_gamepad_uhid *gamepad, + const struct sc_hid_open *hid_open) { + struct sc_control_msg msg; + msg.type = SC_CONTROL_MSG_TYPE_UHID_CREATE; + msg.uhid_create.id = hid_open->hid_id; + msg.uhid_create.vendor_id = SC_GAMEPAD_UHID_VENDOR_ID; + msg.uhid_create.product_id = SC_GAMEPAD_UHID_PRODUCT_ID; + msg.uhid_create.name = SC_GAMEPAD_UHID_NAME; + msg.uhid_create.report_desc = hid_open->report_desc; + msg.uhid_create.report_desc_size = hid_open->report_desc_size; + + if (!sc_controller_push_msg(gamepad->controller, &msg)) { + LOGE("Could not push UHID_CREATE message (gamepad)"); + } +} + +static void +sc_gamepad_uhid_send_close(struct sc_gamepad_uhid *gamepad, + const struct sc_hid_close *hid_close) { + struct sc_control_msg msg; + msg.type = SC_CONTROL_MSG_TYPE_UHID_DESTROY; + msg.uhid_create.id = hid_close->hid_id; + + if (!sc_controller_push_msg(gamepad->controller, &msg)) { + LOGE("Could not push UHID_DESTROY message (gamepad)"); + } +} + +static void +sc_gamepad_processor_process_gamepad_added(struct sc_gamepad_processor *gp, + const struct sc_gamepad_device_event *event) { + struct sc_gamepad_uhid *gamepad = DOWNCAST(gp); + + struct sc_hid_open hid_open; + if (!sc_hid_gamepad_generate_open(&gamepad->hid, &hid_open, + event->gamepad_id)) { + return; + } + + SDL_GameController* game_controller = + SDL_GameControllerFromInstanceID(event->gamepad_id); + assert(game_controller); + const char *name = SDL_GameControllerName(game_controller); + LOGI("Gamepad added: [%" PRIu32 "] %s", event->gamepad_id, name); + + sc_gamepad_uhid_send_open(gamepad, &hid_open); +} + +static void +sc_gamepad_processor_process_gamepad_removed(struct sc_gamepad_processor *gp, + const struct sc_gamepad_device_event *event) { + struct sc_gamepad_uhid *gamepad = DOWNCAST(gp); + + struct sc_hid_close hid_close; + if (!sc_hid_gamepad_generate_close(&gamepad->hid, &hid_close, + event->gamepad_id)) { + return; + } + + LOGI("Gamepad removed: [%" PRIu32 "]", event->gamepad_id); + + sc_gamepad_uhid_send_close(gamepad, &hid_close); +} + +static void +sc_gamepad_processor_process_gamepad_axis(struct sc_gamepad_processor *gp, + const struct sc_gamepad_axis_event *event) { + struct sc_gamepad_uhid *gamepad = DOWNCAST(gp); + + struct sc_hid_input hid_input; + if (!sc_hid_gamepad_generate_input_from_axis(&gamepad->hid, &hid_input, + event)) { + return; + } + + sc_gamepad_uhid_send_input(gamepad, &hid_input, "gamepad axis"); +} + +static void +sc_gamepad_processor_process_gamepad_button(struct sc_gamepad_processor *gp, + const struct sc_gamepad_button_event *event) { + struct sc_gamepad_uhid *gamepad = DOWNCAST(gp); + + struct sc_hid_input hid_input; + if (!sc_hid_gamepad_generate_input_from_button(&gamepad->hid, &hid_input, + event)) { + return; + } + + sc_gamepad_uhid_send_input(gamepad, &hid_input, "gamepad button"); + +} + +void +sc_gamepad_uhid_init(struct sc_gamepad_uhid *gamepad, + struct sc_controller *controller) { + sc_hid_gamepad_init(&gamepad->hid); + + gamepad->controller = controller; + + static const struct sc_gamepad_processor_ops ops = { + .process_gamepad_added = sc_gamepad_processor_process_gamepad_added, + .process_gamepad_removed = sc_gamepad_processor_process_gamepad_removed, + .process_gamepad_axis = sc_gamepad_processor_process_gamepad_axis, + .process_gamepad_button = sc_gamepad_processor_process_gamepad_button, + }; + + gamepad->gamepad_processor.ops = &ops; +} diff --git a/app/src/uhid/gamepad_uhid.h b/app/src/uhid/gamepad_uhid.h new file mode 100644 index 00000000..ad747604 --- /dev/null +++ b/app/src/uhid/gamepad_uhid.h @@ -0,0 +1,21 @@ +#ifndef SC_GAMEPAD_UHID_H +#define SC_GAMEPAD_UHID_H + +#include "common.h" + +#include "controller.h" +#include "hid/hid_gamepad.h" +#include "trait/gamepad_processor.h" + +struct sc_gamepad_uhid { + struct sc_gamepad_processor gamepad_processor; // gamepad processor trait + + struct sc_hid_gamepad hid; + struct sc_controller *controller; +}; + +void +sc_gamepad_uhid_init(struct sc_gamepad_uhid *mouse, + struct sc_controller *controller); + +#endif diff --git a/app/src/uhid/keyboard_uhid.c b/app/src/uhid/keyboard_uhid.c new file mode 100644 index 00000000..70082990 --- /dev/null +++ b/app/src/uhid/keyboard_uhid.c @@ -0,0 +1,161 @@ +#include "keyboard_uhid.h" + +#include +#include +#include +#include + +#include "util/log.h" +#include "util/thread.h" + +/** Downcast key processor to keyboard_uhid */ +#define DOWNCAST(KP) container_of(KP, struct sc_keyboard_uhid, key_processor) + +/** Downcast uhid_receiver to keyboard_uhid */ +#define DOWNCAST_RECEIVER(UR) \ + container_of(UR, struct sc_keyboard_uhid, uhid_receiver) + +static void +sc_keyboard_uhid_send_input(struct sc_keyboard_uhid *kb, + const struct sc_hid_input *hid_input) { + struct sc_control_msg msg; + msg.type = SC_CONTROL_MSG_TYPE_UHID_INPUT; + msg.uhid_input.id = hid_input->hid_id; + + assert(hid_input->size <= SC_HID_MAX_SIZE); + memcpy(msg.uhid_input.data, hid_input->data, hid_input->size); + msg.uhid_input.size = hid_input->size; + + if (!sc_controller_push_msg(kb->controller, &msg)) { + LOGE("Could not push UHID_INPUT message (key)"); + } +} + +static void +sc_keyboard_uhid_synchronize_mod(struct sc_keyboard_uhid *kb) { + SDL_Keymod sdl_mod = SDL_GetModState(); + uint16_t mod = sc_mods_state_from_sdl(sdl_mod) & (SC_MOD_CAPS | SC_MOD_NUM); + uint16_t diff = mod ^ kb->device_mod; + + if (diff) { + // Inherently racy (the HID output reports arrive asynchronously in + // response to key presses), but will re-synchronize on next key press + // or HID output anyway + kb->device_mod = mod; + + struct sc_hid_input hid_input; + if (!sc_hid_keyboard_generate_input_from_mods(&hid_input, diff)) { + return; + } + + LOGV("HID keyboard state synchronized"); + + sc_keyboard_uhid_send_input(kb, &hid_input); + } +} + +static void +sc_key_processor_process_key(struct sc_key_processor *kp, + const struct sc_key_event *event, + uint64_t ack_to_wait) { + (void) ack_to_wait; + + assert(sc_thread_get_id() == SC_MAIN_THREAD_ID); + + if (event->repeat) { + // In USB HID protocol, key repeat is handled by the host (Android), so + // just ignore key repeat here. + return; + } + + struct sc_keyboard_uhid *kb = DOWNCAST(kp); + + struct sc_hid_input hid_input; + + // Not all keys are supported, just ignore unsupported keys + if (sc_hid_keyboard_generate_input_from_key(&kb->hid, &hid_input, event)) { + if (event->scancode == SC_SCANCODE_CAPSLOCK) { + kb->device_mod ^= SC_MOD_CAPS; + } else if (event->scancode == SC_SCANCODE_NUMLOCK) { + kb->device_mod ^= SC_MOD_NUM; + } else { + // Synchronize modifiers (only if the scancode itself does not + // change the modifiers) + sc_keyboard_uhid_synchronize_mod(kb); + } + sc_keyboard_uhid_send_input(kb, &hid_input); + } +} + +static unsigned +sc_keyboard_uhid_to_sc_mod(uint8_t hid_led) { + // + // (chapter 11: LED page) + unsigned mod = 0; + if (hid_led & 0x01) { + mod |= SC_MOD_NUM; + } + if (hid_led & 0x02) { + mod |= SC_MOD_CAPS; + } + return mod; +} + +void +sc_keyboard_uhid_process_hid_output(struct sc_keyboard_uhid *kb, + const uint8_t *data, size_t size) { + assert(sc_thread_get_id() == SC_MAIN_THREAD_ID); + + assert(size); + + // Also check at runtime (do not trust the server) + if (!size) { + LOGE("Unexpected empty HID output message"); + return; + } + + uint8_t hid_led = data[0]; + uint16_t device_mod = sc_keyboard_uhid_to_sc_mod(hid_led); + kb->device_mod = device_mod; +} + +bool +sc_keyboard_uhid_init(struct sc_keyboard_uhid *kb, + struct sc_controller *controller) { + sc_hid_keyboard_init(&kb->hid); + + kb->controller = controller; + kb->device_mod = 0; + + static const struct sc_key_processor_ops ops = { + .process_key = sc_key_processor_process_key, + // Never forward text input via HID (all the keys are injected + // separately) + .process_text = NULL, + }; + + // Clipboard synchronization is requested over the same control socket, so + // there is no need for a specific synchronization mechanism + kb->key_processor.async_paste = false; + kb->key_processor.hid = true; + kb->key_processor.ops = &ops; + + struct sc_hid_open hid_open; + sc_hid_keyboard_generate_open(&hid_open); + assert(hid_open.hid_id == SC_HID_ID_KEYBOARD); + + struct sc_control_msg msg; + msg.type = SC_CONTROL_MSG_TYPE_UHID_CREATE; + msg.uhid_create.id = SC_HID_ID_KEYBOARD; + msg.uhid_create.vendor_id = 0; + msg.uhid_create.product_id = 0; + msg.uhid_create.name = NULL; + msg.uhid_create.report_desc = hid_open.report_desc; + msg.uhid_create.report_desc_size = hid_open.report_desc_size; + if (!sc_controller_push_msg(controller, &msg)) { + LOGE("Could not send UHID_CREATE message (keyboard)"); + return false; + } + + return true; +} diff --git a/app/src/uhid/keyboard_uhid.h b/app/src/uhid/keyboard_uhid.h new file mode 100644 index 00000000..1628a678 --- /dev/null +++ b/app/src/uhid/keyboard_uhid.h @@ -0,0 +1,28 @@ +#ifndef SC_KEYBOARD_UHID_H +#define SC_KEYBOARD_UHID_H + +#include "common.h" + +#include + +#include "controller.h" +#include "hid/hid_keyboard.h" +#include "trait/key_processor.h" + +struct sc_keyboard_uhid { + struct sc_key_processor key_processor; // key processor trait + + struct sc_hid_keyboard hid; + struct sc_controller *controller; + uint16_t device_mod; +}; + +bool +sc_keyboard_uhid_init(struct sc_keyboard_uhid *kb, + struct sc_controller *controller); + +void +sc_keyboard_uhid_process_hid_output(struct sc_keyboard_uhid *kb, + const uint8_t *data, size_t size); + +#endif diff --git a/app/src/uhid/mouse_uhid.c b/app/src/uhid/mouse_uhid.c new file mode 100644 index 00000000..869e48a4 --- /dev/null +++ b/app/src/uhid/mouse_uhid.c @@ -0,0 +1,100 @@ +#include "mouse_uhid.h" + +#include +#include + +#include "hid/hid_mouse.h" +#include "input_events.h" +#include "util/log.h" + +/** Downcast mouse processor to mouse_uhid */ +#define DOWNCAST(MP) container_of(MP, struct sc_mouse_uhid, mouse_processor) + +static void +sc_mouse_uhid_send_input(struct sc_mouse_uhid *mouse, + const struct sc_hid_input *hid_input, + const char *name) { + struct sc_control_msg msg; + msg.type = SC_CONTROL_MSG_TYPE_UHID_INPUT; + msg.uhid_input.id = hid_input->hid_id; + + assert(hid_input->size <= SC_HID_MAX_SIZE); + memcpy(msg.uhid_input.data, hid_input->data, hid_input->size); + msg.uhid_input.size = hid_input->size; + + if (!sc_controller_push_msg(mouse->controller, &msg)) { + LOGE("Could not push UHID_INPUT message (%s)", name); + } +} + +static void +sc_mouse_processor_process_mouse_motion(struct sc_mouse_processor *mp, + const struct sc_mouse_motion_event *event) { + struct sc_mouse_uhid *mouse = DOWNCAST(mp); + + struct sc_hid_input hid_input; + sc_hid_mouse_generate_input_from_motion(&hid_input, event); + + sc_mouse_uhid_send_input(mouse, &hid_input, "mouse motion"); +} + +static void +sc_mouse_processor_process_mouse_click(struct sc_mouse_processor *mp, + const struct sc_mouse_click_event *event) { + struct sc_mouse_uhid *mouse = DOWNCAST(mp); + + struct sc_hid_input hid_input; + sc_hid_mouse_generate_input_from_click(&hid_input, event); + + sc_mouse_uhid_send_input(mouse, &hid_input, "mouse click"); +} + +static void +sc_mouse_processor_process_mouse_scroll(struct sc_mouse_processor *mp, + const struct sc_mouse_scroll_event *event) { + struct sc_mouse_uhid *mouse = DOWNCAST(mp); + + struct sc_hid_input hid_input; + if (!sc_hid_mouse_generate_input_from_scroll(&hid_input, event)) { + return; + } + + sc_mouse_uhid_send_input(mouse, &hid_input, "mouse scroll"); +} + +bool +sc_mouse_uhid_init(struct sc_mouse_uhid *mouse, + struct sc_controller *controller) { + mouse->controller = controller; + + static const struct sc_mouse_processor_ops ops = { + .process_mouse_motion = sc_mouse_processor_process_mouse_motion, + .process_mouse_click = sc_mouse_processor_process_mouse_click, + .process_mouse_scroll = sc_mouse_processor_process_mouse_scroll, + // Touch events not supported (coordinates are not relative) + .process_touch = NULL, + }; + + mouse->mouse_processor.ops = &ops; + + mouse->mouse_processor.relative_mode = true; + + struct sc_hid_open hid_open; + sc_hid_mouse_generate_open(&hid_open); + assert(hid_open.hid_id == SC_HID_ID_MOUSE); + + struct sc_control_msg msg; + msg.type = SC_CONTROL_MSG_TYPE_UHID_CREATE; + msg.uhid_create.id = SC_HID_ID_MOUSE; + msg.uhid_create.vendor_id = 0; + msg.uhid_create.product_id = 0; + msg.uhid_create.name = NULL; + msg.uhid_create.report_desc = hid_open.report_desc; + msg.uhid_create.report_desc_size = hid_open.report_desc_size; + if (!sc_controller_push_msg(controller, &msg)) { + LOGE("Could not push UHID_CREATE message (mouse)"); + return false; + } + + return true; +} diff --git a/app/src/uhid/mouse_uhid.h b/app/src/uhid/mouse_uhid.h new file mode 100644 index 00000000..f117ba97 --- /dev/null +++ b/app/src/uhid/mouse_uhid.h @@ -0,0 +1,19 @@ +#ifndef SC_MOUSE_UHID_H +#define SC_MOUSE_UHID_H + +#include + +#include "controller.h" +#include "trait/mouse_processor.h" + +struct sc_mouse_uhid { + struct sc_mouse_processor mouse_processor; // mouse processor trait + + struct sc_controller *controller; +}; + +bool +sc_mouse_uhid_init(struct sc_mouse_uhid *mouse, + struct sc_controller *controller); + +#endif diff --git a/app/src/uhid/uhid_output.c b/app/src/uhid/uhid_output.c new file mode 100644 index 00000000..e743a73c --- /dev/null +++ b/app/src/uhid/uhid_output.c @@ -0,0 +1,26 @@ +#include "uhid_output.h" + +#include + +#include "uhid/keyboard_uhid.h" +#include "util/log.h" + +void +sc_uhid_devices_init(struct sc_uhid_devices *devices, + struct sc_keyboard_uhid *keyboard) { + devices->keyboard = keyboard; +} + +void +sc_uhid_devices_process_hid_output(struct sc_uhid_devices *devices, uint16_t id, + const uint8_t *data, size_t size) { + if (id == SC_HID_ID_KEYBOARD) { + if (devices->keyboard) { + sc_keyboard_uhid_process_hid_output(devices->keyboard, data, size); + } else { + LOGW("Unexpected keyboard HID output without UHID keyboard"); + } + } else { + LOGW("HID output ignored for id %" PRIu16, id); + } +} diff --git a/app/src/uhid/uhid_output.h b/app/src/uhid/uhid_output.h new file mode 100644 index 00000000..ed028b58 --- /dev/null +++ b/app/src/uhid/uhid_output.h @@ -0,0 +1,27 @@ +#ifndef SC_UHID_OUTPUT_H +#define SC_UHID_OUTPUT_H + +#include "common.h" + +#include +#include + +/** + * The communication with UHID devices is bidirectional. + * + * This component dispatches HID outputs to the expected processor. + */ + +struct sc_uhid_devices { + struct sc_keyboard_uhid *keyboard; +}; + +void +sc_uhid_devices_init(struct sc_uhid_devices *devices, + struct sc_keyboard_uhid *keyboard); + +void +sc_uhid_devices_process_hid_output(struct sc_uhid_devices *devices, uint16_t id, + const uint8_t *data, size_t size); + +#endif diff --git a/app/src/usb/aoa_hid.c b/app/src/usb/aoa_hid.c index fb64e57c..8cb62bfd 100644 --- a/app/src/usb/aoa_hid.c +++ b/app/src/usb/aoa_hid.c @@ -1,10 +1,17 @@ -#include "util/log.h" +#include "aoa_hid.h" #include +#include #include +#include +#include +#include -#include "aoa_hid.h" +#include "events.h" #include "util/log.h" +#include "util/str.h" +#include "util/tick.h" +#include "util/vector.h" // See . #define ACCESSORY_REGISTER_HID 54 @@ -14,37 +21,40 @@ #define DEFAULT_TIMEOUT 1000 -#define SC_HID_EVENT_QUEUE_MAX 64 +// Drop droppable events above this limit +#define SC_AOA_EVENT_QUEUE_LIMIT 60 + +struct sc_vec_hid_ids SC_VECTOR(uint16_t); static void -sc_hid_event_log(const struct sc_hid_event *event) { - // HID Event: [00] FF FF FF FF... - assert(event->size); - unsigned buffer_size = event->size * 3 + 1; - char *buffer = malloc(buffer_size); - if (!buffer) { - LOG_OOM(); +sc_hid_input_log(const struct sc_hid_input *hid_input) { + // HID input: [00] FF FF FF FF... + assert(hid_input->size); + char *hex = sc_str_to_hex_string(hid_input->data, hid_input->size); + if (!hex) { return; } - for (unsigned i = 0; i < event->size; ++i) { - snprintf(buffer + i * 3, 4, " %02x", event->buffer[i]); + LOGV("HID input: [%" PRIu16 "] %s", hid_input->hid_id, hex); + free(hex); +} + +static void +sc_hid_open_log(const struct sc_hid_open *hid_open) { + // HID open: [00] FF FF FF FF... + assert(hid_open->report_desc_size); + char *hex = sc_str_to_hex_string(hid_open->report_desc, + hid_open->report_desc_size); + if (!hex) { + return; } - LOGV("HID Event: [%d]%s", event->accessory_id, buffer); - free(buffer); + LOGV("HID open: [%" PRIu16 "] %s", hid_open->hid_id, hex); + free(hex); } -void -sc_hid_event_init(struct sc_hid_event *hid_event, uint16_t accessory_id, - unsigned char *buffer, uint16_t buffer_size) { - hid_event->accessory_id = accessory_id; - hid_event->buffer = buffer; - hid_event->size = buffer_size; - hid_event->ack_to_wait = SC_SEQUENCE_INVALID; -} - -void -sc_hid_event_destroy(struct sc_hid_event *hid_event) { - free(hid_event->buffer); +static void +sc_hid_close_log(const struct sc_hid_close *hid_close) { + // HID close: [00] + LOGV("HID close: [%" PRIu16 "]", hid_close->hid_id); } bool @@ -52,7 +62,8 @@ sc_aoa_init(struct sc_aoa *aoa, struct sc_usb *usb, struct sc_acksync *acksync) { sc_vecdeque_init(&aoa->queue); - if (!sc_vecdeque_reserve(&aoa->queue, SC_HID_EVENT_QUEUE_MAX)) { + // Add 4 to support 4 non-droppable events without re-allocation + if (!sc_vecdeque_reserve(&aoa->queue, SC_AOA_EVENT_QUEUE_LIMIT + 4)) { return false; } @@ -76,12 +87,7 @@ sc_aoa_init(struct sc_aoa *aoa, struct sc_usb *usb, void sc_aoa_destroy(struct sc_aoa *aoa) { - // Destroy remaining events - while (!sc_vecdeque_is_empty(&aoa->queue)) { - struct sc_hid_event *event = sc_vecdeque_popref(&aoa->queue); - assert(event); - sc_hid_event_destroy(event); - } + sc_vecdeque_destroy(&aoa->queue); sc_cond_destroy(&aoa->event_cond); sc_mutex_destroy(&aoa->mutex); @@ -97,10 +103,10 @@ sc_aoa_register_hid(struct sc_aoa *aoa, uint16_t accessory_id, // index (arg1): total length of the HID report descriptor uint16_t value = accessory_id; uint16_t index = report_desc_size; - unsigned char *buffer = NULL; + unsigned char *data = NULL; uint16_t length = 0; int result = libusb_control_transfer(aoa->usb->handle, request_type, - request, value, index, buffer, length, + request, value, index, data, length, DEFAULT_TIMEOUT); if (result < 0) { LOGE("REGISTER_HID: libusb error: %s", libusb_strerror(result)); @@ -113,7 +119,7 @@ sc_aoa_register_hid(struct sc_aoa *aoa, uint16_t accessory_id, static bool sc_aoa_set_hid_report_desc(struct sc_aoa *aoa, uint16_t accessory_id, - const unsigned char *report_desc, + const uint8_t *report_desc, uint16_t report_desc_size) { uint8_t request_type = LIBUSB_ENDPOINT_OUT | LIBUSB_REQUEST_TYPE_VENDOR; uint8_t request = ACCESSORY_SET_HID_REPORT_DESC; @@ -130,14 +136,14 @@ sc_aoa_set_hid_report_desc(struct sc_aoa *aoa, uint16_t accessory_id, * See */ // value (arg0): accessory assigned ID for the HID device - // index (arg1): offset of data (buffer) in descriptor + // index (arg1): offset of data in descriptor uint16_t value = accessory_id; uint16_t index = 0; // libusb_control_transfer expects a pointer to non-const - unsigned char *buffer = (unsigned char *) report_desc; + unsigned char *data = (unsigned char *) report_desc; uint16_t length = report_desc_size; int result = libusb_control_transfer(aoa->usb->handle, request_type, - request, value, index, buffer, length, + request, value, index, data, length, DEFAULT_TIMEOUT); if (result < 0) { LOGE("SET_HID_REPORT_DESC: libusb error: %s", libusb_strerror(result)); @@ -148,9 +154,56 @@ sc_aoa_set_hid_report_desc(struct sc_aoa *aoa, uint16_t accessory_id, return true; } -bool +static bool +sc_aoa_send_hid_event(struct sc_aoa *aoa, + const struct sc_hid_input *hid_input) { + uint8_t request_type = LIBUSB_ENDPOINT_OUT | LIBUSB_REQUEST_TYPE_VENDOR; + uint8_t request = ACCESSORY_SEND_HID_EVENT; + // + // value (arg0): accessory assigned ID for the HID device + // index (arg1): 0 (unused) + uint16_t value = hid_input->hid_id; + uint16_t index = 0; + unsigned char *data = (uint8_t *) hid_input->data; // discard const + uint16_t length = hid_input->size; + int result = libusb_control_transfer(aoa->usb->handle, request_type, + request, value, index, data, length, + DEFAULT_TIMEOUT); + if (result < 0) { + LOGE("SEND_HID_EVENT: libusb error: %s", libusb_strerror(result)); + sc_usb_check_disconnected(aoa->usb, result); + return false; + } + + return true; +} + +static bool +sc_aoa_unregister_hid(struct sc_aoa *aoa, uint16_t accessory_id) { + uint8_t request_type = LIBUSB_ENDPOINT_OUT | LIBUSB_REQUEST_TYPE_VENDOR; + uint8_t request = ACCESSORY_UNREGISTER_HID; + // + // value (arg0): accessory assigned ID for the HID device + // index (arg1): 0 + uint16_t value = accessory_id; + uint16_t index = 0; + unsigned char *data = NULL; + uint16_t length = 0; + int result = libusb_control_transfer(aoa->usb->handle, request_type, + request, value, index, data, length, + DEFAULT_TIMEOUT); + if (result < 0) { + LOGE("UNREGISTER_HID: libusb error: %s", libusb_strerror(result)); + sc_usb_check_disconnected(aoa->usb, result); + return false; + } + + return true; +} + +static bool sc_aoa_setup_hid(struct sc_aoa *aoa, uint16_t accessory_id, - const unsigned char *report_desc, uint16_t report_desc_size) { + const uint8_t *report_desc, uint16_t report_desc_size) { bool ok = sc_aoa_register_hid(aoa, accessory_id, report_desc_size); if (!ok) { return false; @@ -168,78 +221,193 @@ sc_aoa_setup_hid(struct sc_aoa *aoa, uint16_t accessory_id, return true; } -static bool -sc_aoa_send_hid_event(struct sc_aoa *aoa, const struct sc_hid_event *event) { - uint8_t request_type = LIBUSB_ENDPOINT_OUT | LIBUSB_REQUEST_TYPE_VENDOR; - uint8_t request = ACCESSORY_SEND_HID_EVENT; - // - // value (arg0): accessory assigned ID for the HID device - // index (arg1): 0 (unused) - uint16_t value = event->accessory_id; - uint16_t index = 0; - unsigned char *buffer = event->buffer; - uint16_t length = event->size; - int result = libusb_control_transfer(aoa->usb->handle, request_type, - request, value, index, buffer, length, - DEFAULT_TIMEOUT); - if (result < 0) { - LOGE("SEND_HID_EVENT: libusb error: %s", libusb_strerror(result)); - sc_usb_check_disconnected(aoa->usb, result); - return false; - } - - return true; -} - bool -sc_aoa_unregister_hid(struct sc_aoa *aoa, const uint16_t accessory_id) { - uint8_t request_type = LIBUSB_ENDPOINT_OUT | LIBUSB_REQUEST_TYPE_VENDOR; - uint8_t request = ACCESSORY_UNREGISTER_HID; - // - // value (arg0): accessory assigned ID for the HID device - // index (arg1): 0 - uint16_t value = accessory_id; - uint16_t index = 0; - unsigned char *buffer = NULL; - uint16_t length = 0; - int result = libusb_control_transfer(aoa->usb->handle, request_type, - request, value, index, buffer, length, - DEFAULT_TIMEOUT); - if (result < 0) { - LOGE("UNREGISTER_HID: libusb error: %s", libusb_strerror(result)); - sc_usb_check_disconnected(aoa->usb, result); - return false; - } - - return true; -} - -bool -sc_aoa_push_hid_event(struct sc_aoa *aoa, const struct sc_hid_event *event) { +sc_aoa_push_input_with_ack_to_wait(struct sc_aoa *aoa, + const struct sc_hid_input *hid_input, + uint64_t ack_to_wait) { if (sc_get_log_level() <= SC_LOG_LEVEL_VERBOSE) { - sc_hid_event_log(event); + sc_hid_input_log(hid_input); } sc_mutex_lock(&aoa->mutex); - bool full = sc_vecdeque_is_full(&aoa->queue); - if (!full) { + + bool pushed = false; + + size_t size = sc_vecdeque_size(&aoa->queue); + if (size < SC_AOA_EVENT_QUEUE_LIMIT) { bool was_empty = sc_vecdeque_is_empty(&aoa->queue); - sc_vecdeque_push_noresize(&aoa->queue, *event); + + struct sc_aoa_event *aoa_event = + sc_vecdeque_push_hole_noresize(&aoa->queue); + aoa_event->type = SC_AOA_EVENT_TYPE_INPUT; + aoa_event->input.hid = *hid_input; + aoa_event->input.ack_to_wait = ack_to_wait; + pushed = true; + if (was_empty) { sc_cond_signal(&aoa->event_cond); } } - // Otherwise (if the queue is full), the event is discarded + // Otherwise, the event is discarded sc_mutex_unlock(&aoa->mutex); - return !full; + return pushed; +} + +bool +sc_aoa_push_open(struct sc_aoa *aoa, const struct sc_hid_open *hid_open, + bool exit_on_open_error) { + if (sc_get_log_level() <= SC_LOG_LEVEL_VERBOSE) { + sc_hid_open_log(hid_open); + } + + sc_mutex_lock(&aoa->mutex); + bool was_empty = sc_vecdeque_is_empty(&aoa->queue); + + // an OPEN event is non-droppable, so push it to the queue even above the + // SC_AOA_EVENT_QUEUE_LIMIT + struct sc_aoa_event *aoa_event = sc_vecdeque_push_hole(&aoa->queue); + if (!aoa_event) { + LOG_OOM(); + sc_mutex_unlock(&aoa->mutex); + return false; + } + + aoa_event->type = SC_AOA_EVENT_TYPE_OPEN; + aoa_event->open.hid = *hid_open; + aoa_event->open.exit_on_error = exit_on_open_error; + + if (was_empty) { + sc_cond_signal(&aoa->event_cond); + } + + sc_mutex_unlock(&aoa->mutex); + + return true; +} + +bool +sc_aoa_push_close(struct sc_aoa *aoa, const struct sc_hid_close *hid_close) { + if (sc_get_log_level() <= SC_LOG_LEVEL_VERBOSE) { + sc_hid_close_log(hid_close); + } + + sc_mutex_lock(&aoa->mutex); + bool was_empty = sc_vecdeque_is_empty(&aoa->queue); + + // a CLOSE event is non-droppable, so push it to the queue even above the + // SC_AOA_EVENT_QUEUE_LIMIT + struct sc_aoa_event *aoa_event = sc_vecdeque_push_hole(&aoa->queue); + if (!aoa_event) { + LOG_OOM(); + sc_mutex_unlock(&aoa->mutex); + return false; + } + + aoa_event->type = SC_AOA_EVENT_TYPE_CLOSE; + aoa_event->close.hid = *hid_close; + + if (was_empty) { + sc_cond_signal(&aoa->event_cond); + } + + sc_mutex_unlock(&aoa->mutex); + + return true; +} + +static bool +sc_aoa_process_event(struct sc_aoa *aoa, struct sc_aoa_event *event, + struct sc_vec_hid_ids *vec_open) { + switch (event->type) { + case SC_AOA_EVENT_TYPE_INPUT: { + uint64_t ack_to_wait = event->input.ack_to_wait; + if (ack_to_wait != SC_SEQUENCE_INVALID) { + LOGD("Waiting ack from server sequence=%" PRIu64_, ack_to_wait); + + // If some events have ack_to_wait set, then sc_aoa must have + // been initialized with a non NULL acksync + assert(aoa->acksync); + + // Do not block the loop indefinitely if the ack never comes (it + // should never happen) + sc_tick deadline = sc_tick_now() + SC_TICK_FROM_MS(500); + enum sc_acksync_wait_result result = + sc_acksync_wait(aoa->acksync, ack_to_wait, deadline); + + if (result == SC_ACKSYNC_WAIT_TIMEOUT) { + LOGW("Ack not received after 500ms, discarding HID event"); + // continue to process events + return true; + } else if (result == SC_ACKSYNC_WAIT_INTR) { + // stopped + return false; + } + } + + struct sc_hid_input *hid_input = &event->input.hid; + bool ok = sc_aoa_send_hid_event(aoa, hid_input); + if (!ok) { + LOGW("Could not send HID event to USB device: %" PRIu16, + hid_input->hid_id); + } + + break; + } + case SC_AOA_EVENT_TYPE_OPEN: { + struct sc_hid_open *hid_open = &event->open.hid; + bool ok = sc_aoa_setup_hid(aoa, hid_open->hid_id, + hid_open->report_desc, + hid_open->report_desc_size); + if (ok) { + // The device is now open, add it to the list of devices to + // close automatically on exit + bool pushed = sc_vector_push(vec_open, hid_open->hid_id); + if (!pushed) { + LOG_OOM(); + // this is not fatal, the HID device will just not be + // explicitly unregistered + } + } else { + LOGW("Could not open AOA device: %" PRIu16, hid_open->hid_id); + if (event->open.exit_on_error) { + // Notify the error to the main thread, which will exit + sc_push_event(SC_EVENT_AOA_OPEN_ERROR); + } + } + + break; + } + case SC_AOA_EVENT_TYPE_CLOSE: { + struct sc_hid_close *hid_close = &event->close.hid; + bool ok = sc_aoa_unregister_hid(aoa, hid_close->hid_id); + if (ok) { + // The device is not open anymore, remove it from the list of + // devices to close automatically on exit + ssize_t idx = sc_vector_index_of(vec_open, hid_close->hid_id); + if (idx >= 0) { + sc_vector_remove(vec_open, idx); + } + } else { + LOGW("Could not close AOA device: %" PRIu16, hid_close->hid_id); + } + + break; + } + } + + // continue to process events + return true; } static int run_aoa_thread(void *data) { struct sc_aoa *aoa = data; + // Store the HID ids of opened devices to unregister them all before exiting + struct sc_vec_hid_ids vec_open = SC_VECTOR_INITIALIZER; + for (;;) { sc_mutex_lock(&aoa->mutex); while (!aoa->stopped && sc_vecdeque_is_empty(&aoa->queue)) { @@ -252,40 +420,27 @@ run_aoa_thread(void *data) { } assert(!sc_vecdeque_is_empty(&aoa->queue)); - struct sc_hid_event event = sc_vecdeque_pop(&aoa->queue); - uint64_t ack_to_wait = event.ack_to_wait; + struct sc_aoa_event event = sc_vecdeque_pop(&aoa->queue); sc_mutex_unlock(&aoa->mutex); - if (ack_to_wait != SC_SEQUENCE_INVALID) { - LOGD("Waiting ack from server sequence=%" PRIu64_, ack_to_wait); - - // If some events have ack_to_wait set, then sc_aoa must have been - // initialized with a non NULL acksync - assert(aoa->acksync); - - // Do not block the loop indefinitely if the ack never comes (it - // should never happen) - sc_tick deadline = sc_tick_now() + SC_TICK_FROM_MS(500); - enum sc_acksync_wait_result result = - sc_acksync_wait(aoa->acksync, ack_to_wait, deadline); - - if (result == SC_ACKSYNC_WAIT_TIMEOUT) { - LOGW("Ack not received after 500ms, discarding HID event"); - sc_hid_event_destroy(&event); - continue; - } else if (result == SC_ACKSYNC_WAIT_INTR) { - // stopped - sc_hid_event_destroy(&event); - break; - } - } - - bool ok = sc_aoa_send_hid_event(aoa, &event); - sc_hid_event_destroy(&event); - if (!ok) { - LOGW("Could not send HID event to USB device"); + bool cont = sc_aoa_process_event(aoa, &event, &vec_open); + if (!cont) { + // stopped + break; } } + + // Explicitly unregister all registered HID ids before exiting + for (size_t i = 0; i < vec_open.size; ++i) { + uint16_t hid_id = vec_open.data[i]; + LOGD("Unregistering AOA device %" PRIu16 "...", hid_id); + bool ok = sc_aoa_unregister_hid(aoa, hid_id); + if (!ok) { + LOGW("Could not close AOA device: %" PRIu16, hid_id); + } + } + sc_vector_destroy(&vec_open); + return 0; } diff --git a/app/src/usb/aoa_hid.h b/app/src/usb/aoa_hid.h index 8803c1d9..2755c957 100644 --- a/app/src/usb/aoa_hid.h +++ b/app/src/usb/aoa_hid.h @@ -1,33 +1,41 @@ #ifndef SC_AOA_HID_H #define SC_AOA_HID_H -#include +#include "common.h" + #include +#include -#include - -#include "usb.h" +#include "hid/hid_event.h" +#include "usb/usb.h" #include "util/acksync.h" #include "util/thread.h" -#include "util/tick.h" #include "util/vecdeque.h" -struct sc_hid_event { - uint16_t accessory_id; - unsigned char *buffer; - uint16_t size; - uint64_t ack_to_wait; +enum sc_aoa_event_type { + SC_AOA_EVENT_TYPE_OPEN, + SC_AOA_EVENT_TYPE_INPUT, + SC_AOA_EVENT_TYPE_CLOSE, }; -// Takes ownership of buffer -void -sc_hid_event_init(struct sc_hid_event *hid_event, uint16_t accessory_id, - unsigned char *buffer, uint16_t buffer_size); +struct sc_aoa_event { + enum sc_aoa_event_type type; + union { + struct { + struct sc_hid_open hid; + bool exit_on_error; + } open; + struct { + struct sc_hid_close hid; + } close; + struct { + struct sc_hid_input hid; + uint64_t ack_to_wait; + } input; + }; +}; -void -sc_hid_event_destroy(struct sc_hid_event *hid_event); - -struct sc_hid_event_queue SC_VECDEQUE(struct sc_hid_event); +struct sc_aoa_event_queue SC_VECDEQUE(struct sc_aoa_event); struct sc_aoa { struct sc_usb *usb; @@ -35,7 +43,7 @@ struct sc_aoa { sc_mutex mutex; sc_cond event_cond; bool stopped; - struct sc_hid_event_queue queue; + struct sc_aoa_event_queue queue; struct sc_acksync *acksync; }; @@ -55,14 +63,31 @@ sc_aoa_stop(struct sc_aoa *aoa); void sc_aoa_join(struct sc_aoa *aoa); +//bool +//sc_aoa_setup_hid(struct sc_aoa *aoa, uint16_t accessory_id, +// const uint8_t *report_desc, uint16_t report_desc_size); +// +//bool +//sc_aoa_unregister_hid(struct sc_aoa *aoa, uint16_t accessory_id); + +// report_desc must be a pointer to static memory, accessed at any time from +// another thread bool -sc_aoa_setup_hid(struct sc_aoa *aoa, uint16_t accessory_id, - const unsigned char *report_desc, uint16_t report_desc_size); +sc_aoa_push_open(struct sc_aoa *aoa, const struct sc_hid_open *hid_open, + bool exit_on_open_error); bool -sc_aoa_unregister_hid(struct sc_aoa *aoa, uint16_t accessory_id); +sc_aoa_push_close(struct sc_aoa *aoa, const struct sc_hid_close *hid_close); bool -sc_aoa_push_hid_event(struct sc_aoa *aoa, const struct sc_hid_event *event); +sc_aoa_push_input_with_ack_to_wait(struct sc_aoa *aoa, + const struct sc_hid_input *hid_input, + uint64_t ack_to_wait); + +static inline bool +sc_aoa_push_input(struct sc_aoa *aoa, const struct sc_hid_input *hid_input) { + return sc_aoa_push_input_with_ack_to_wait(aoa, hid_input, + SC_SEQUENCE_INVALID); +} #endif diff --git a/app/src/usb/gamepad_aoa.c b/app/src/usb/gamepad_aoa.c new file mode 100644 index 00000000..d29b1a78 --- /dev/null +++ b/app/src/usb/gamepad_aoa.c @@ -0,0 +1,96 @@ +#include "gamepad_aoa.h" + +#include + +#include "input_events.h" +#include "util/log.h" + +/** Downcast gamepad processor to gamepad_aoa */ +#define DOWNCAST(GP) container_of(GP, struct sc_gamepad_aoa, gamepad_processor) + +static void +sc_gamepad_processor_process_gamepad_added(struct sc_gamepad_processor *gp, + const struct sc_gamepad_device_event *event) { + struct sc_gamepad_aoa *gamepad = DOWNCAST(gp); + + struct sc_hid_open hid_open; + if (!sc_hid_gamepad_generate_open(&gamepad->hid, &hid_open, + event->gamepad_id)) { + return; + } + + // exit_on_error: false (a gamepad open failure should not exit scrcpy) + if (!sc_aoa_push_open(gamepad->aoa, &hid_open, false)) { + LOGW("Could not push AOA HID open (gamepad)"); + } +} + +static void +sc_gamepad_processor_process_gamepad_removed(struct sc_gamepad_processor *gp, + const struct sc_gamepad_device_event *event) { + struct sc_gamepad_aoa *gamepad = DOWNCAST(gp); + + struct sc_hid_close hid_close; + if (!sc_hid_gamepad_generate_close(&gamepad->hid, &hid_close, + event->gamepad_id)) { + return; + } + + if (!sc_aoa_push_close(gamepad->aoa, &hid_close)) { + LOGW("Could not push AOA HID close (gamepad)"); + } +} + +static void +sc_gamepad_processor_process_gamepad_axis(struct sc_gamepad_processor *gp, + const struct sc_gamepad_axis_event *event) { + struct sc_gamepad_aoa *gamepad = DOWNCAST(gp); + + struct sc_hid_input hid_input; + if (!sc_hid_gamepad_generate_input_from_axis(&gamepad->hid, &hid_input, + event)) { + return; + } + + if (!sc_aoa_push_input(gamepad->aoa, &hid_input)) { + LOGW("Could not push AOA HID input (gamepad axis)"); + } +} + +static void +sc_gamepad_processor_process_gamepad_button(struct sc_gamepad_processor *gp, + const struct sc_gamepad_button_event *event) { + struct sc_gamepad_aoa *gamepad = DOWNCAST(gp); + + struct sc_hid_input hid_input; + if (!sc_hid_gamepad_generate_input_from_button(&gamepad->hid, &hid_input, + event)) { + return; + } + + if (!sc_aoa_push_input(gamepad->aoa, &hid_input)) { + LOGW("Could not push AOA HID input (gamepad button)"); + } +} + +void +sc_gamepad_aoa_init(struct sc_gamepad_aoa *gamepad, struct sc_aoa *aoa) { + gamepad->aoa = aoa; + + sc_hid_gamepad_init(&gamepad->hid); + + static const struct sc_gamepad_processor_ops ops = { + .process_gamepad_added = sc_gamepad_processor_process_gamepad_added, + .process_gamepad_removed = sc_gamepad_processor_process_gamepad_removed, + .process_gamepad_axis = sc_gamepad_processor_process_gamepad_axis, + .process_gamepad_button = sc_gamepad_processor_process_gamepad_button, + }; + + gamepad->gamepad_processor.ops = &ops; +} + +void +sc_gamepad_aoa_destroy(struct sc_gamepad_aoa *gamepad) { + (void) gamepad; + // Do nothing, gamepad->aoa will automatically unregister all devices +} diff --git a/app/src/usb/gamepad_aoa.h b/app/src/usb/gamepad_aoa.h new file mode 100644 index 00000000..0297a365 --- /dev/null +++ b/app/src/usb/gamepad_aoa.h @@ -0,0 +1,23 @@ +#ifndef SC_GAMEPAD_AOA_H +#define SC_GAMEPAD_AOA_H + +#include "common.h" + +#include "hid/hid_gamepad.h" +#include "usb/aoa_hid.h" +#include "trait/gamepad_processor.h" + +struct sc_gamepad_aoa { + struct sc_gamepad_processor gamepad_processor; // gamepad processor trait + + struct sc_hid_gamepad hid; + struct sc_aoa *aoa; +}; + +void +sc_gamepad_aoa_init(struct sc_gamepad_aoa *gamepad, struct sc_aoa *aoa); + +void +sc_gamepad_aoa_destroy(struct sc_gamepad_aoa *gamepad); + +#endif diff --git a/app/src/usb/hid_mouse.c b/app/src/usb/hid_mouse.c deleted file mode 100644 index bab89940..00000000 --- a/app/src/usb/hid_mouse.c +++ /dev/null @@ -1,267 +0,0 @@ -#include "hid_mouse.h" - -#include - -#include "input_events.h" -#include "util/log.h" - -/** Downcast mouse processor to hid_mouse */ -#define DOWNCAST(MP) container_of(MP, struct sc_hid_mouse, mouse_processor) - -#define HID_MOUSE_ACCESSORY_ID 2 - -// 1 byte for buttons + padding, 1 byte for X position, 1 byte for Y position -#define HID_MOUSE_EVENT_SIZE 4 - -/** - * Mouse descriptor from the specification: - * - * - * Appendix E (p71): §E.10 Report Descriptor (Mouse) - * - * The usage tags (like Wheel) are listed in "HID Usage Tables": - * - * §4 Generic Desktop Page (0x01) (p26) - */ -static const unsigned char mouse_report_desc[] = { - // Usage Page (Generic Desktop) - 0x05, 0x01, - // Usage (Mouse) - 0x09, 0x02, - - // Collection (Application) - 0xA1, 0x01, - - // Usage (Pointer) - 0x09, 0x01, - - // Collection (Physical) - 0xA1, 0x00, - - // Usage Page (Buttons) - 0x05, 0x09, - - // Usage Minimum (1) - 0x19, 0x01, - // Usage Maximum (5) - 0x29, 0x05, - // Logical Minimum (0) - 0x15, 0x00, - // Logical Maximum (1) - 0x25, 0x01, - // Report Count (5) - 0x95, 0x05, - // Report Size (1) - 0x75, 0x01, - // Input (Data, Variable, Absolute): 5 buttons bits - 0x81, 0x02, - - // Report Count (1) - 0x95, 0x01, - // Report Size (3) - 0x75, 0x03, - // Input (Constant): 3 bits padding - 0x81, 0x01, - - // Usage Page (Generic Desktop) - 0x05, 0x01, - // Usage (X) - 0x09, 0x30, - // Usage (Y) - 0x09, 0x31, - // Usage (Wheel) - 0x09, 0x38, - // Local Minimum (-127) - 0x15, 0x81, - // Local Maximum (127) - 0x25, 0x7F, - // Report Size (8) - 0x75, 0x08, - // Report Count (3) - 0x95, 0x03, - // Input (Data, Variable, Relative): 3 position bytes (X, Y, Wheel) - 0x81, 0x06, - - // End Collection - 0xC0, - - // End Collection - 0xC0, -}; - -/** - * A mouse HID event is 3 bytes long: - * - * - byte 0: buttons state - * - byte 1: relative x motion (signed byte from -127 to 127) - * - byte 2: relative y motion (signed byte from -127 to 127) - * - * 7 6 5 4 3 2 1 0 - * +---------------+ - * byte 0: |0 0 0 . . . . .| buttons state - * +---------------+ - * ^ ^ ^ ^ ^ - * | | | | `- left button - * | | | `--- right button - * | | `----- middle button - * | `------- button 4 - * `--------- button 5 - * - * +---------------+ - * byte 1: |. . . . . . . .| relative x motion - * +---------------+ - * byte 2: |. . . . . . . .| relative y motion - * +---------------+ - * byte 3: |. . . . . . . .| wheel motion (-1, 0 or 1) - * +---------------+ - * - * As an example, here is the report for a motion of (x=5, y=-4) with left - * button pressed: - * - * +---------------+ - * |0 0 0 0 0 0 0 1| left button pressed - * +---------------+ - * |0 0 0 0 0 1 0 1| horizontal motion (x = 5) - * +---------------+ - * |1 1 1 1 1 1 0 0| relative y motion (y = -4) - * +---------------+ - * |0 0 0 0 0 0 0 0| wheel motion - * +---------------+ - */ - -static bool -sc_hid_mouse_event_init(struct sc_hid_event *hid_event) { - unsigned char *buffer = calloc(1, HID_MOUSE_EVENT_SIZE); - if (!buffer) { - LOG_OOM(); - return false; - } - - sc_hid_event_init(hid_event, HID_MOUSE_ACCESSORY_ID, buffer, - HID_MOUSE_EVENT_SIZE); - return true; -} - -static unsigned char -buttons_state_to_hid_buttons(uint8_t buttons_state) { - unsigned char c = 0; - if (buttons_state & SC_MOUSE_BUTTON_LEFT) { - c |= 1 << 0; - } - if (buttons_state & SC_MOUSE_BUTTON_RIGHT) { - c |= 1 << 1; - } - if (buttons_state & SC_MOUSE_BUTTON_MIDDLE) { - c |= 1 << 2; - } - if (buttons_state & SC_MOUSE_BUTTON_X1) { - c |= 1 << 3; - } - if (buttons_state & SC_MOUSE_BUTTON_X2) { - c |= 1 << 4; - } - return c; -} - -static void -sc_mouse_processor_process_mouse_motion(struct sc_mouse_processor *mp, - const struct sc_mouse_motion_event *event) { - struct sc_hid_mouse *mouse = DOWNCAST(mp); - - struct sc_hid_event hid_event; - if (!sc_hid_mouse_event_init(&hid_event)) { - return; - } - - unsigned char *buffer = hid_event.buffer; - buffer[0] = buttons_state_to_hid_buttons(event->buttons_state); - buffer[1] = CLAMP(event->xrel, -127, 127); - buffer[2] = CLAMP(event->yrel, -127, 127); - buffer[3] = 0; // wheel coordinates only used for scrolling - - if (!sc_aoa_push_hid_event(mouse->aoa, &hid_event)) { - sc_hid_event_destroy(&hid_event); - LOGW("Could not request HID event (mouse motion)"); - } -} - -static void -sc_mouse_processor_process_mouse_click(struct sc_mouse_processor *mp, - const struct sc_mouse_click_event *event) { - struct sc_hid_mouse *mouse = DOWNCAST(mp); - - struct sc_hid_event hid_event; - if (!sc_hid_mouse_event_init(&hid_event)) { - return; - } - - unsigned char *buffer = hid_event.buffer; - buffer[0] = buttons_state_to_hid_buttons(event->buttons_state); - buffer[1] = 0; // no x motion - buffer[2] = 0; // no y motion - buffer[3] = 0; // wheel coordinates only used for scrolling - - if (!sc_aoa_push_hid_event(mouse->aoa, &hid_event)) { - sc_hid_event_destroy(&hid_event); - LOGW("Could not request HID event (mouse click)"); - } -} - -static void -sc_mouse_processor_process_mouse_scroll(struct sc_mouse_processor *mp, - const struct sc_mouse_scroll_event *event) { - struct sc_hid_mouse *mouse = DOWNCAST(mp); - - struct sc_hid_event hid_event; - if (!sc_hid_mouse_event_init(&hid_event)) { - return; - } - - unsigned char *buffer = hid_event.buffer; - buffer[0] = 0; // buttons state irrelevant (and unknown) - buffer[1] = 0; // no x motion - buffer[2] = 0; // no y motion - // In practice, vscroll is always -1, 0 or 1, but in theory other values - // are possible - buffer[3] = CLAMP(event->vscroll, -127, 127); - // Horizontal scrolling ignored - - if (!sc_aoa_push_hid_event(mouse->aoa, &hid_event)) { - sc_hid_event_destroy(&hid_event); - LOGW("Could not request HID event (mouse scroll)"); - } -} - -bool -sc_hid_mouse_init(struct sc_hid_mouse *mouse, struct sc_aoa *aoa) { - mouse->aoa = aoa; - - bool ok = sc_aoa_setup_hid(aoa, HID_MOUSE_ACCESSORY_ID, mouse_report_desc, - ARRAY_LEN(mouse_report_desc)); - if (!ok) { - LOGW("Register HID mouse failed"); - return false; - } - - static const struct sc_mouse_processor_ops ops = { - .process_mouse_motion = sc_mouse_processor_process_mouse_motion, - .process_mouse_click = sc_mouse_processor_process_mouse_click, - .process_mouse_scroll = sc_mouse_processor_process_mouse_scroll, - // Touch events not supported (coordinates are not relative) - .process_touch = NULL, - }; - - mouse->mouse_processor.ops = &ops; - - mouse->mouse_processor.relative_mode = true; - - return true; -} - -void -sc_hid_mouse_destroy(struct sc_hid_mouse *mouse) { - bool ok = sc_aoa_unregister_hid(mouse->aoa, HID_MOUSE_ACCESSORY_ID); - if (!ok) { - LOGW("Could not unregister HID mouse"); - } -} diff --git a/app/src/usb/hid_mouse.h b/app/src/usb/hid_mouse.h deleted file mode 100644 index b89f7795..00000000 --- a/app/src/usb/hid_mouse.h +++ /dev/null @@ -1,23 +0,0 @@ -#ifndef SC_HID_MOUSE_H -#define SC_HID_MOUSE_H - -#include "common.h" - -#include - -#include "aoa_hid.h" -#include "trait/mouse_processor.h" - -struct sc_hid_mouse { - struct sc_mouse_processor mouse_processor; // mouse processor trait - - struct sc_aoa *aoa; -}; - -bool -sc_hid_mouse_init(struct sc_hid_mouse *mouse, struct sc_aoa *aoa); - -void -sc_hid_mouse_destroy(struct sc_hid_mouse *mouse); - -#endif diff --git a/app/src/usb/keyboard_aoa.c b/app/src/usb/keyboard_aoa.c new file mode 100644 index 00000000..8f5cb755 --- /dev/null +++ b/app/src/usb/keyboard_aoa.c @@ -0,0 +1,103 @@ +#include "keyboard_aoa.h" + +#include + +#include "input_events.h" +#include "util/log.h" + +/** Downcast key processor to keyboard_aoa */ +#define DOWNCAST(KP) container_of(KP, struct sc_keyboard_aoa, key_processor) + +static bool +push_mod_lock_state(struct sc_keyboard_aoa *kb, uint16_t mods_state) { + struct sc_hid_input hid_input; + if (!sc_hid_keyboard_generate_input_from_mods(&hid_input, mods_state)) { + // Nothing to do + return true; + } + + if (!sc_aoa_push_input(kb->aoa, &hid_input)) { + LOGW("Could not push AOA HID input (mod lock state)"); + return false; + } + + LOGD("HID keyboard state synchronized"); + + return true; +} + +static void +sc_key_processor_process_key(struct sc_key_processor *kp, + const struct sc_key_event *event, + uint64_t ack_to_wait) { + if (event->repeat) { + // In USB HID protocol, key repeat is handled by the host (Android), so + // just ignore key repeat here. + return; + } + + struct sc_keyboard_aoa *kb = DOWNCAST(kp); + + struct sc_hid_input hid_input; + + // Not all keys are supported, just ignore unsupported keys + if (sc_hid_keyboard_generate_input_from_key(&kb->hid, &hid_input, event)) { + if (!kb->mod_lock_synchronized) { + // Inject CAPSLOCK and/or NUMLOCK if necessary to synchronize + // keyboard state + if (push_mod_lock_state(kb, event->mods_state)) { + kb->mod_lock_synchronized = true; + } + } + + // If ack_to_wait is != SC_SEQUENCE_INVALID, then Ctrl+v is pressed, so + // clipboard synchronization has been requested. Wait until clipboard + // synchronization is acknowledged by the server, otherwise it could + // paste the old clipboard content. + + if (!sc_aoa_push_input_with_ack_to_wait(kb->aoa, &hid_input, + ack_to_wait)) { + LOGW("Could not push AOA HID input (key)"); + } + } +} + +bool +sc_keyboard_aoa_init(struct sc_keyboard_aoa *kb, struct sc_aoa *aoa) { + kb->aoa = aoa; + + struct sc_hid_open hid_open; + sc_hid_keyboard_generate_open(&hid_open); + + bool ok = sc_aoa_push_open(aoa, &hid_open, true); + if (!ok) { + LOGW("Could not push AOA HID open (keyboard)"); + return false; + } + + sc_hid_keyboard_init(&kb->hid); + + kb->mod_lock_synchronized = false; + + static const struct sc_key_processor_ops ops = { + .process_key = sc_key_processor_process_key, + // Never forward text input via HID (all the keys are injected + // separately) + .process_text = NULL, + }; + + // Clipboard synchronization is requested over the control socket, while HID + // events are sent over AOA, so it must wait for clipboard synchronization + // to be acknowledged by the device before injecting Ctrl+v. + kb->key_processor.async_paste = true; + kb->key_processor.hid = true; + kb->key_processor.ops = &ops; + + return true; +} + +void +sc_keyboard_aoa_destroy(struct sc_keyboard_aoa *kb) { + (void) kb; + // Do nothing, kb->aoa will automatically unregister all devices +} diff --git a/app/src/usb/keyboard_aoa.h b/app/src/usb/keyboard_aoa.h new file mode 100644 index 00000000..9e9500a3 --- /dev/null +++ b/app/src/usb/keyboard_aoa.h @@ -0,0 +1,27 @@ +#ifndef SC_KEYBOARD_AOA_H +#define SC_KEYBOARD_AOA_H + +#include "common.h" + +#include + +#include "hid/hid_keyboard.h" +#include "usb/aoa_hid.h" +#include "trait/key_processor.h" + +struct sc_keyboard_aoa { + struct sc_key_processor key_processor; // key processor trait + + struct sc_hid_keyboard hid; + struct sc_aoa *aoa; + + bool mod_lock_synchronized; +}; + +bool +sc_keyboard_aoa_init(struct sc_keyboard_aoa *kb, struct sc_aoa *aoa); + +void +sc_keyboard_aoa_destroy(struct sc_keyboard_aoa *kb); + +#endif diff --git a/app/src/usb/mouse_aoa.c b/app/src/usb/mouse_aoa.c new file mode 100644 index 00000000..fd5fa5e0 --- /dev/null +++ b/app/src/usb/mouse_aoa.c @@ -0,0 +1,86 @@ +#include "mouse_aoa.h" + +#include +#include + +#include "hid/hid_mouse.h" +#include "input_events.h" +#include "util/log.h" + +/** Downcast mouse processor to mouse_aoa */ +#define DOWNCAST(MP) container_of(MP, struct sc_mouse_aoa, mouse_processor) + +static void +sc_mouse_processor_process_mouse_motion(struct sc_mouse_processor *mp, + const struct sc_mouse_motion_event *event) { + struct sc_mouse_aoa *mouse = DOWNCAST(mp); + + struct sc_hid_input hid_input; + sc_hid_mouse_generate_input_from_motion(&hid_input, event); + + if (!sc_aoa_push_input(mouse->aoa, &hid_input)) { + LOGW("Could not push AOA HID input (mouse motion)"); + } +} + +static void +sc_mouse_processor_process_mouse_click(struct sc_mouse_processor *mp, + const struct sc_mouse_click_event *event) { + struct sc_mouse_aoa *mouse = DOWNCAST(mp); + + struct sc_hid_input hid_input; + sc_hid_mouse_generate_input_from_click(&hid_input, event); + + if (!sc_aoa_push_input(mouse->aoa, &hid_input)) { + LOGW("Could not push AOA HID input (mouse click)"); + } +} + +static void +sc_mouse_processor_process_mouse_scroll(struct sc_mouse_processor *mp, + const struct sc_mouse_scroll_event *event) { + struct sc_mouse_aoa *mouse = DOWNCAST(mp); + + struct sc_hid_input hid_input; + if (!sc_hid_mouse_generate_input_from_scroll(&hid_input, event)) { + return; + } + + if (!sc_aoa_push_input(mouse->aoa, &hid_input)) { + LOGW("Could not push AOA HID input (mouse scroll)"); + } +} + +bool +sc_mouse_aoa_init(struct sc_mouse_aoa *mouse, struct sc_aoa *aoa) { + mouse->aoa = aoa; + + struct sc_hid_open hid_open; + sc_hid_mouse_generate_open(&hid_open); + + bool ok = sc_aoa_push_open(aoa, &hid_open, true); + if (!ok) { + LOGW("Could not push AOA HID open (mouse)"); + return false; + } + + static const struct sc_mouse_processor_ops ops = { + .process_mouse_motion = sc_mouse_processor_process_mouse_motion, + .process_mouse_click = sc_mouse_processor_process_mouse_click, + .process_mouse_scroll = sc_mouse_processor_process_mouse_scroll, + // Touch events not supported (coordinates are not relative) + .process_touch = NULL, + }; + + mouse->mouse_processor.ops = &ops; + + mouse->mouse_processor.relative_mode = true; + + return true; +} + +void +sc_mouse_aoa_destroy(struct sc_mouse_aoa *mouse) { + (void) mouse; + // Do nothing, mouse->aoa will automatically unregister all devices +} diff --git a/app/src/usb/mouse_aoa.h b/app/src/usb/mouse_aoa.h new file mode 100644 index 00000000..506286ba --- /dev/null +++ b/app/src/usb/mouse_aoa.h @@ -0,0 +1,23 @@ +#ifndef SC_MOUSE_AOA_H +#define SC_MOUSE_AOA_H + +#include "common.h" + +#include + +#include "usb/aoa_hid.h" +#include "trait/mouse_processor.h" + +struct sc_mouse_aoa { + struct sc_mouse_processor mouse_processor; // mouse processor trait + + struct sc_aoa *aoa; +}; + +bool +sc_mouse_aoa_init(struct sc_mouse_aoa *mouse, struct sc_aoa *aoa); + +void +sc_mouse_aoa_destroy(struct sc_mouse_aoa *mouse); + +#endif diff --git a/app/src/usb/scrcpy_otg.c b/app/src/usb/scrcpy_otg.c index 6a7fd79b..1a9cc46e 100644 --- a/app/src/usb/scrcpy_otg.c +++ b/app/src/usb/scrcpy_otg.c @@ -1,17 +1,27 @@ #include "scrcpy_otg.h" +#include +#include +#include #include -#include "adb/adb.h" +#ifdef _WIN32 +# include "adb/adb.h" +#endif #include "events.h" -#include "screen_otg.h" +#include "usb/screen_otg.h" +#include "usb/aoa_hid.h" +#include "usb/gamepad_aoa.h" +#include "usb/keyboard_aoa.h" +#include "usb/mouse_aoa.h" #include "util/log.h" struct scrcpy_otg { struct sc_usb usb; struct sc_aoa aoa; - struct sc_hid_keyboard keyboard; - struct sc_hid_mouse mouse; + struct sc_keyboard_aoa keyboard; + struct sc_mouse_aoa mouse; + struct sc_gamepad_aoa gamepad; struct sc_screen_otg screen_otg; }; @@ -21,12 +31,7 @@ sc_usb_on_disconnected(struct sc_usb *usb, void *userdata) { (void) usb; (void) userdata; - SDL_Event event; - event.type = SC_EVENT_USB_DEVICE_DISCONNECTED; - int ret = SDL_PushEvent(&event); - if (ret < 0) { - LOGE("Could not post USB disconnection event: %s", SDL_GetError()); - } + sc_push_event(SC_EVENT_USB_DEVICE_DISCONNECTED); } static enum scrcpy_exit_code @@ -37,6 +42,9 @@ event_loop(struct scrcpy_otg *s) { case SC_EVENT_USB_DEVICE_DISCONNECTED: LOGW("Device disconnected"); return SCRCPY_EXIT_DISCONNECTED; + case SC_EVENT_AOA_OPEN_ERROR: + LOGE("AOA open error"); + return SCRCPY_EXIT_FAILURE; case SDL_QUIT: LOGD("User requested to quit"); return SCRCPY_EXIT_SUCCESS; @@ -59,10 +67,21 @@ scrcpy_otg(struct scrcpy_options *options) { LOGW("Could not enable linear filtering"); } + if (!SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1")) { + LOGW("Could not allow joystick background events"); + } + // Minimal SDL initialization if (SDL_Init(SDL_INIT_EVENTS)) { LOGE("Could not initialize SDL: %s", SDL_GetError()); - return false; + return SCRCPY_EXIT_FAILURE; + } + + if (options->gamepad_input_mode != SC_GAMEPAD_INPUT_MODE_DISABLED) { + if (SDL_Init(SDL_INIT_GAMECONTROLLER)) { + LOGE("Could not initialize SDL controller: %s", SDL_GetError()); + // Not fatal, keyboard/mouse should still work + } } atexit(SDL_Quit); @@ -73,8 +92,9 @@ scrcpy_otg(struct scrcpy_options *options) { enum scrcpy_exit_code ret = SCRCPY_EXIT_FAILURE; - struct sc_hid_keyboard *keyboard = NULL; - struct sc_hid_mouse *mouse = NULL; + struct sc_keyboard_aoa *keyboard = NULL; + struct sc_mouse_aoa *mouse = NULL; + struct sc_gamepad_aoa *gamepad = NULL; bool usb_device_initialized = false; bool usb_connected = false; bool aoa_started = false; @@ -84,9 +104,14 @@ scrcpy_otg(struct scrcpy_options *options) { // On Windows, only one process could open a USB device // LOGI("Killing adb server (if any)..."); - unsigned flags = SC_ADB_NO_STDOUT | SC_ADB_NO_STDERR | SC_ADB_NO_LOGERR; - // uninterruptible (intr == NULL), but in practice it's very quick - sc_adb_kill_server(NULL, flags); + if (sc_adb_init()) { + unsigned flags = SC_ADB_NO_STDOUT | SC_ADB_NO_STDERR | SC_ADB_NO_LOGERR; + // uninterruptible (intr == NULL), but in practice it's very quick + sc_adb_kill_server(NULL, flags); + sc_adb_destroy(); + } else { + LOGW("Could not call adb executable, adb server not killed"); + } #endif static const struct sc_usb_callbacks cbs = { @@ -117,19 +142,22 @@ scrcpy_otg(struct scrcpy_options *options) { } aoa_initialized = true; - bool enable_keyboard = - options->keyboard_input_mode == SC_KEYBOARD_INPUT_MODE_HID; - bool enable_mouse = - options->mouse_input_mode == SC_MOUSE_INPUT_MODE_HID; + assert(options->keyboard_input_mode == SC_KEYBOARD_INPUT_MODE_AOA + || options->keyboard_input_mode == SC_KEYBOARD_INPUT_MODE_DISABLED); + assert(options->mouse_input_mode == SC_MOUSE_INPUT_MODE_AOA + || options->mouse_input_mode == SC_MOUSE_INPUT_MODE_DISABLED); + assert(options->gamepad_input_mode == SC_GAMEPAD_INPUT_MODE_AOA + || options->gamepad_input_mode == SC_GAMEPAD_INPUT_MODE_DISABLED); - // If neither --hid-keyboard or --hid-mouse is passed, enable both - if (!enable_keyboard && !enable_mouse) { - enable_keyboard = true; - enable_mouse = true; - } + bool enable_keyboard = + options->keyboard_input_mode == SC_KEYBOARD_INPUT_MODE_AOA; + bool enable_mouse = + options->mouse_input_mode == SC_MOUSE_INPUT_MODE_AOA; + bool enable_gamepad = + options->gamepad_input_mode == SC_GAMEPAD_INPUT_MODE_AOA; if (enable_keyboard) { - ok = sc_hid_keyboard_init(&s->keyboard, &s->aoa); + ok = sc_keyboard_aoa_init(&s->keyboard, &s->aoa); if (!ok) { goto end; } @@ -137,13 +165,18 @@ scrcpy_otg(struct scrcpy_options *options) { } if (enable_mouse) { - ok = sc_hid_mouse_init(&s->mouse, &s->aoa); + ok = sc_mouse_aoa_init(&s->mouse, &s->aoa); if (!ok) { goto end; } mouse = &s->mouse; } + if (enable_gamepad) { + sc_gamepad_aoa_init(&s->gamepad, &s->aoa); + gamepad = &s->gamepad; + } + ok = sc_aoa_start(&s->aoa); if (!ok) { goto end; @@ -158,6 +191,7 @@ scrcpy_otg(struct scrcpy_options *options) { struct sc_screen_otg_params params = { .keyboard = keyboard, .mouse = mouse, + .gamepad = gamepad, .window_title = window_title, .always_on_top = options->always_on_top, .window_x = options->window_x, @@ -165,6 +199,7 @@ scrcpy_otg(struct scrcpy_options *options) { .window_width = options->window_width, .window_height = options->window_height, .window_borderless = options->window_borderless, + .shortcut_mods = options->shortcut_mods, }; ok = sc_screen_otg_init(&s->screen_otg, ¶ms); @@ -186,10 +221,13 @@ end: sc_usb_stop(&s->usb); if (mouse) { - sc_hid_mouse_destroy(&s->mouse); + sc_mouse_aoa_destroy(&s->mouse); } if (keyboard) { - sc_hid_keyboard_destroy(&s->keyboard); + sc_keyboard_aoa_destroy(&s->keyboard); + } + if (gamepad) { + sc_gamepad_aoa_destroy(&s->gamepad); } if (aoa_initialized) { diff --git a/app/src/usb/screen_otg.c b/app/src/usb/screen_otg.c index e1d5cb01..5c580df9 100644 --- a/app/src/usb/screen_otg.c +++ b/app/src/usb/screen_otg.c @@ -1,50 +1,13 @@ #include "screen_otg.h" +#include +#include + #include "icon.h" #include "options.h" +#include "util/acksync.h" #include "util/log.h" -static void -sc_screen_otg_set_mouse_capture(struct sc_screen_otg *screen, bool capture) { -#ifdef __APPLE__ - // Workaround for SDL bug on macOS: - // - if (capture) { - int mouse_x, mouse_y; - SDL_GetGlobalMouseState(&mouse_x, &mouse_y); - - int x, y, w, h; - SDL_GetWindowPosition(screen->window, &x, &y); - SDL_GetWindowSize(screen->window, &w, &h); - - bool outside_window = mouse_x < x || mouse_x >= x + w - || mouse_y < y || mouse_y >= y + h; - if (outside_window) { - SDL_WarpMouseInWindow(screen->window, w / 2, h / 2); - } - } -#else - (void) screen; -#endif - if (SDL_SetRelativeMouseMode(capture)) { - LOGE("Could not set relative mouse mode to %s: %s", - capture ? "true" : "false", SDL_GetError()); - } -} - -static inline bool -sc_screen_otg_get_mouse_capture(struct sc_screen_otg *screen) { - (void) screen; - return SDL_GetRelativeMouseMode(); -} - -static inline void -sc_screen_otg_toggle_mouse_capture(struct sc_screen_otg *screen) { - (void) screen; - bool new_value = !sc_screen_otg_get_mouse_capture(screen); - sc_screen_otg_set_mouse_capture(screen, new_value); -} - static void sc_screen_otg_render(struct sc_screen_otg *screen) { SDL_RenderClear(screen->renderer); @@ -59,8 +22,7 @@ sc_screen_otg_init(struct sc_screen_otg *screen, const struct sc_screen_otg_params *params) { screen->keyboard = params->keyboard; screen->mouse = params->mouse; - - screen->mouse_capture_key_pressed = 0; + screen->gamepad = params->gamepad; const char *title = params->window_title; assert(title); @@ -112,9 +74,11 @@ sc_screen_otg_init(struct sc_screen_otg *screen, LOGW("Could not load icon"); } + sc_mouse_capture_init(&screen->mc, screen->window, params->shortcut_mods); + if (screen->mouse) { // Capture mouse on start - sc_screen_otg_set_mouse_capture(screen, true); + sc_mouse_capture_set_active(&screen->mc, true); } return true; @@ -136,11 +100,6 @@ sc_screen_otg_destroy(struct sc_screen_otg *screen) { SDL_DestroyWindow(screen->window); } -static inline bool -sc_screen_otg_is_mouse_capture_key(SDL_Keycode key) { - return key == SDLK_LALT || key == SDLK_LGUI || key == SDLK_RGUI; -} - static void sc_screen_otg_process_key(struct sc_screen_otg *screen, const SDL_KeyboardEvent *event) { @@ -169,7 +128,7 @@ sc_screen_otg_process_mouse_motion(struct sc_screen_otg *screen, // .position not used for HID events .xrel = event->xrel, .yrel = event->yrel, - .buttons_state = sc_mouse_buttons_state_from_sdl(event->state, true), + .buttons_state = sc_mouse_buttons_state_from_sdl(event->state), }; assert(mp->ops->process_mouse_motion); @@ -188,8 +147,7 @@ sc_screen_otg_process_mouse_button(struct sc_screen_otg *screen, // .position not used for HID events .action = sc_action_from_sdl_mousebutton_type(event->type), .button = sc_mouse_button_from_sdl(event->button), - .buttons_state = - sc_mouse_buttons_state_from_sdl(sdl_buttons_state, true), + .buttons_state = sc_mouse_buttons_state_from_sdl(sdl_buttons_state), }; assert(mp->ops->process_mouse_click); @@ -206,94 +164,163 @@ sc_screen_otg_process_mouse_wheel(struct sc_screen_otg *screen, struct sc_mouse_scroll_event evt = { // .position not used for HID events +#if SDL_VERSION_ATLEAST(2, 0, 18) + .hscroll = event->preciseX, + .vscroll = event->preciseY, +#else .hscroll = event->x, .vscroll = event->y, - .buttons_state = - sc_mouse_buttons_state_from_sdl(sdl_buttons_state, true), +#endif + .hscroll_int = event->x, + .vscroll_int = event->y, + .buttons_state = sc_mouse_buttons_state_from_sdl(sdl_buttons_state), }; assert(mp->ops->process_mouse_scroll); mp->ops->process_mouse_scroll(mp, &evt); } +static void +sc_screen_otg_process_gamepad_device(struct sc_screen_otg *screen, + const SDL_ControllerDeviceEvent *event) { + assert(screen->gamepad); + struct sc_gamepad_processor *gp = &screen->gamepad->gamepad_processor; + + if (event->type == SDL_CONTROLLERDEVICEADDED) { + SDL_GameController *gc = SDL_GameControllerOpen(event->which); + if (!gc) { + LOGW("Could not open game controller"); + return; + } + + SDL_Joystick *joystick = SDL_GameControllerGetJoystick(gc); + if (!joystick) { + LOGW("Could not get controller joystick"); + SDL_GameControllerClose(gc); + return; + } + + struct sc_gamepad_device_event evt = { + .gamepad_id = SDL_JoystickInstanceID(joystick), + }; + gp->ops->process_gamepad_added(gp, &evt); + } else if (event->type == SDL_CONTROLLERDEVICEREMOVED) { + SDL_JoystickID id = event->which; + + SDL_GameController *gc = SDL_GameControllerFromInstanceID(id); + if (gc) { + SDL_GameControllerClose(gc); + } else { + LOGW("Unknown gamepad device removed"); + } + + struct sc_gamepad_device_event evt = { + .gamepad_id = id, + }; + gp->ops->process_gamepad_removed(gp, &evt); + } +} + +static void +sc_screen_otg_process_gamepad_axis(struct sc_screen_otg *screen, + const SDL_ControllerAxisEvent *event) { + assert(screen->gamepad); + struct sc_gamepad_processor *gp = &screen->gamepad->gamepad_processor; + + enum sc_gamepad_axis axis = sc_gamepad_axis_from_sdl(event->axis); + if (axis == SC_GAMEPAD_AXIS_UNKNOWN) { + return; + } + + struct sc_gamepad_axis_event evt = { + .gamepad_id = event->which, + .axis = axis, + .value = event->value, + }; + gp->ops->process_gamepad_axis(gp, &evt); +} + +static void +sc_screen_otg_process_gamepad_button(struct sc_screen_otg *screen, + const SDL_ControllerButtonEvent *event) { + assert(screen->gamepad); + struct sc_gamepad_processor *gp = &screen->gamepad->gamepad_processor; + + enum sc_gamepad_button button = sc_gamepad_button_from_sdl(event->button); + if (button == SC_GAMEPAD_BUTTON_UNKNOWN) { + return; + } + + struct sc_gamepad_button_event evt = { + .gamepad_id = event->which, + .action = sc_action_from_sdl_controllerbutton_type(event->type), + .button = button, + }; + gp->ops->process_gamepad_button(gp, &evt); +} + void sc_screen_otg_handle_event(struct sc_screen_otg *screen, SDL_Event *event) { + if (sc_mouse_capture_handle_event(&screen->mc, event)) { + // The mouse capture handler consumed the event + return; + } + switch (event->type) { case SDL_WINDOWEVENT: switch (event->window.event) { case SDL_WINDOWEVENT_EXPOSED: sc_screen_otg_render(screen); break; - case SDL_WINDOWEVENT_FOCUS_LOST: - if (screen->mouse) { - sc_screen_otg_set_mouse_capture(screen, false); - } - break; } return; case SDL_KEYDOWN: - if (screen->mouse) { - SDL_Keycode key = event->key.keysym.sym; - if (sc_screen_otg_is_mouse_capture_key(key)) { - if (!screen->mouse_capture_key_pressed) { - screen->mouse_capture_key_pressed = key; - } else { - // Another mouse capture key has been pressed, cancel - // mouse (un)capture - screen->mouse_capture_key_pressed = 0; - } - // Mouse capture keys are never forwarded to the device - return; - } - } - if (screen->keyboard) { sc_screen_otg_process_key(screen, &event->key); } break; case SDL_KEYUP: - if (screen->mouse) { - SDL_Keycode key = event->key.keysym.sym; - SDL_Keycode cap = screen->mouse_capture_key_pressed; - screen->mouse_capture_key_pressed = 0; - if (sc_screen_otg_is_mouse_capture_key(key)) { - if (key == cap) { - // A mouse capture key has been pressed then released: - // toggle the capture mouse mode - sc_screen_otg_toggle_mouse_capture(screen); - } - // Mouse capture keys are never forwarded to the device - return; - } - } - if (screen->keyboard) { sc_screen_otg_process_key(screen, &event->key); } break; case SDL_MOUSEMOTION: - if (screen->mouse && sc_screen_otg_get_mouse_capture(screen)) { + if (screen->mouse) { sc_screen_otg_process_mouse_motion(screen, &event->motion); } break; case SDL_MOUSEBUTTONDOWN: - if (screen->mouse && sc_screen_otg_get_mouse_capture(screen)) { + if (screen->mouse) { sc_screen_otg_process_mouse_button(screen, &event->button); } break; case SDL_MOUSEBUTTONUP: if (screen->mouse) { - if (sc_screen_otg_get_mouse_capture(screen)) { - sc_screen_otg_process_mouse_button(screen, &event->button); - } else { - sc_screen_otg_set_mouse_capture(screen, true); - } + sc_screen_otg_process_mouse_button(screen, &event->button); } break; case SDL_MOUSEWHEEL: - if (screen->mouse && sc_screen_otg_get_mouse_capture(screen)) { + if (screen->mouse) { sc_screen_otg_process_mouse_wheel(screen, &event->wheel); } break; + case SDL_CONTROLLERDEVICEADDED: + case SDL_CONTROLLERDEVICEREMOVED: + // Handle device added or removed even if paused + if (screen->gamepad) { + sc_screen_otg_process_gamepad_device(screen, &event->cdevice); + } + break; + case SDL_CONTROLLERAXISMOTION: + if (screen->gamepad) { + sc_screen_otg_process_gamepad_axis(screen, &event->caxis); + } + break; + case SDL_CONTROLLERBUTTONDOWN: + case SDL_CONTROLLERBUTTONUP: + if (screen->gamepad) { + sc_screen_otg_process_gamepad_button(screen, &event->cbutton); + } + break; } } diff --git a/app/src/usb/screen_otg.h b/app/src/usb/screen_otg.h index a0acf40b..08b76ae7 100644 --- a/app/src/usb/screen_otg.h +++ b/app/src/usb/screen_otg.h @@ -4,26 +4,30 @@ #include "common.h" #include +#include #include -#include "hid_keyboard.h" -#include "hid_mouse.h" +#include "mouse_capture.h" +#include "usb/gamepad_aoa.h" +#include "usb/keyboard_aoa.h" +#include "usb/mouse_aoa.h" struct sc_screen_otg { - struct sc_hid_keyboard *keyboard; - struct sc_hid_mouse *mouse; + struct sc_keyboard_aoa *keyboard; + struct sc_mouse_aoa *mouse; + struct sc_gamepad_aoa *gamepad; SDL_Window *window; SDL_Renderer *renderer; SDL_Texture *texture; - // See equivalent mechanism in screen.h - SDL_Keycode mouse_capture_key_pressed; + struct sc_mouse_capture mc; }; struct sc_screen_otg_params { - struct sc_hid_keyboard *keyboard; - struct sc_hid_mouse *mouse; + struct sc_keyboard_aoa *keyboard; + struct sc_mouse_aoa *mouse; + struct sc_gamepad_aoa *gamepad; const char *window_title; bool always_on_top; @@ -32,6 +36,7 @@ struct sc_screen_otg_params { uint16_t window_width; uint16_t window_height; bool window_borderless; + uint8_t shortcut_mods; // OR of enum sc_shortcut_mod values }; bool diff --git a/app/src/util/acksync.c b/app/src/util/acksync.c index 2899cdcb..76ecee0d 100644 --- a/app/src/util/acksync.c +++ b/app/src/util/acksync.c @@ -1,7 +1,6 @@ #include "acksync.h" #include -#include "util/log.h" bool sc_acksync_init(struct sc_acksync *as) { diff --git a/app/src/util/acksync.h b/app/src/util/acksync.h index 58ab1b35..3d9c9b2f 100644 --- a/app/src/util/acksync.h +++ b/app/src/util/acksync.h @@ -3,7 +3,10 @@ #include "common.h" -#include "thread.h" +#include +#include +#include "util/thread.h" +#include "util/tick.h" #define SC_SEQUENCE_INVALID 0 diff --git a/app/src/util/audiobuf.c b/app/src/util/audiobuf.c new file mode 100644 index 00000000..eeb27514 --- /dev/null +++ b/app/src/util/audiobuf.c @@ -0,0 +1,153 @@ +#include "audiobuf.h" + +#include +#include +#include +#include + +bool +sc_audiobuf_init(struct sc_audiobuf *buf, size_t sample_size, + uint32_t capacity) { + assert(sample_size); + assert(capacity); + + // The actual capacity is (alloc_size - 1) so that head == tail is + // non-ambiguous + buf->alloc_size = capacity + 1; + buf->data = sc_allocarray(buf->alloc_size, sample_size); + if (!buf->data) { + LOG_OOM(); + return false; + } + + buf->sample_size = sample_size; + atomic_init(&buf->head, 0); + atomic_init(&buf->tail, 0); + + return true; +} + +void +sc_audiobuf_destroy(struct sc_audiobuf *buf) { + free(buf->data); +} + +uint32_t +sc_audiobuf_read(struct sc_audiobuf *buf, void *to_, uint32_t samples_count) { + assert(samples_count); + + uint8_t *to = to_; + + // Only the reader thread can write tail without synchronization, so + // memory_order_relaxed is sufficient + uint32_t tail = atomic_load_explicit(&buf->tail, memory_order_relaxed); + + // The head cursor is updated after the data is written to the array + uint32_t head = atomic_load_explicit(&buf->head, memory_order_acquire); + + uint32_t can_read = (buf->alloc_size + head - tail) % buf->alloc_size; + if (!can_read) { + return 0; + } + if (samples_count > can_read) { + samples_count = can_read; + } + + if (to) { + uint32_t right_count = buf->alloc_size - tail; + if (right_count > samples_count) { + right_count = samples_count; + } + memcpy(to, + buf->data + (tail * buf->sample_size), + right_count * buf->sample_size); + + if (samples_count > right_count) { + uint32_t left_count = samples_count - right_count; + memcpy(to + (right_count * buf->sample_size), + buf->data, + left_count * buf->sample_size); + } + } + + uint32_t new_tail = (tail + samples_count) % buf->alloc_size; + atomic_store_explicit(&buf->tail, new_tail, memory_order_release); + + return samples_count; +} + +uint32_t +sc_audiobuf_write(struct sc_audiobuf *buf, const void *from_, + uint32_t samples_count) { + const uint8_t *from = from_; + + // Only the writer thread can write head, so memory_order_relaxed is + // sufficient + uint32_t head = atomic_load_explicit(&buf->head, memory_order_relaxed); + + // The tail cursor is updated after the data is consumed by the reader + uint32_t tail = atomic_load_explicit(&buf->tail, memory_order_acquire); + + uint32_t can_write = (buf->alloc_size + tail - head - 1) % buf->alloc_size; + if (!can_write) { + return 0; + } + if (samples_count > can_write) { + samples_count = can_write; + } + + uint32_t right_count = buf->alloc_size - head; + if (right_count > samples_count) { + right_count = samples_count; + } + memcpy(buf->data + (head * buf->sample_size), + from, + right_count * buf->sample_size); + + if (samples_count > right_count) { + uint32_t left_count = samples_count - right_count; + memcpy(buf->data, + from + (right_count * buf->sample_size), + left_count * buf->sample_size); + } + + uint32_t new_head = (head + samples_count) % buf->alloc_size; + atomic_store_explicit(&buf->head, new_head, memory_order_release); + + return samples_count; +} + +uint32_t +sc_audiobuf_write_silence(struct sc_audiobuf *buf, uint32_t samples_count) { + // Only the writer thread can write head, so memory_order_relaxed is + // sufficient + uint32_t head = atomic_load_explicit(&buf->head, memory_order_relaxed); + + // The tail cursor is updated after the data is consumed by the reader + uint32_t tail = atomic_load_explicit(&buf->tail, memory_order_acquire); + + uint32_t can_write = (buf->alloc_size + tail - head - 1) % buf->alloc_size; + if (!can_write) { + return 0; + } + if (samples_count > can_write) { + samples_count = can_write; + } + + uint32_t right_count = buf->alloc_size - head; + if (right_count > samples_count) { + right_count = samples_count; + } + memset(buf->data + (head * buf->sample_size), 0, + right_count * buf->sample_size); + + if (samples_count > right_count) { + uint32_t left_count = samples_count - right_count; + memset(buf->data, 0, left_count * buf->sample_size); + } + + uint32_t new_head = (head + samples_count) % buf->alloc_size; + atomic_store_explicit(&buf->head, new_head, memory_order_release); + + return samples_count; +} diff --git a/app/src/util/audiobuf.h b/app/src/util/audiobuf.h index 8616d539..b55a5a59 100644 --- a/app/src/util/audiobuf.h +++ b/app/src/util/audiobuf.h @@ -3,19 +3,26 @@ #include "common.h" +#include +#include #include +#include #include -#include "util/bytebuf.h" - /** * Wrapper around bytebuf to read and write samples * * Each sample takes sample_size bytes. */ struct sc_audiobuf { - struct sc_bytebuf buf; + uint8_t *data; + uint32_t alloc_size; // in samples size_t sample_size; + + atomic_uint_least32_t head; // writer cursor, in samples + atomic_uint_least32_t tail; // reader cursor, in samples + // empty: tail == head + // full: ((tail + 1) % alloc_size) == head }; static inline uint32_t @@ -29,66 +36,34 @@ sc_audiobuf_to_bytes(struct sc_audiobuf *buf, uint32_t samples) { return samples * buf->sample_size; } -static inline bool +bool sc_audiobuf_init(struct sc_audiobuf *buf, size_t sample_size, - uint32_t capacity) { - buf->sample_size = sample_size; - return sc_bytebuf_init(&buf->buf, capacity * sample_size + 1); -} + uint32_t capacity); -static inline void -sc_audiobuf_read(struct sc_audiobuf *buf, uint8_t *to, uint32_t samples) { - size_t bytes = sc_audiobuf_to_bytes(buf, samples); - sc_bytebuf_read(&buf->buf, to, bytes); -} +void +sc_audiobuf_destroy(struct sc_audiobuf *buf); -static inline void -sc_audiobuf_skip(struct sc_audiobuf *buf, uint32_t samples) { - size_t bytes = sc_audiobuf_to_bytes(buf, samples); - sc_bytebuf_skip(&buf->buf, bytes); -} +uint32_t +sc_audiobuf_read(struct sc_audiobuf *buf, void *to, uint32_t samples_count); -static inline void -sc_audiobuf_write(struct sc_audiobuf *buf, const uint8_t *from, - uint32_t samples) { - size_t bytes = sc_audiobuf_to_bytes(buf, samples); - sc_bytebuf_write(&buf->buf, from, bytes); -} +uint32_t +sc_audiobuf_write(struct sc_audiobuf *buf, const void *from, + uint32_t samples_count); -static inline void -sc_audiobuf_prepare_write(struct sc_audiobuf *buf, const uint8_t *from, - uint32_t samples) { - size_t bytes = sc_audiobuf_to_bytes(buf, samples); - sc_bytebuf_prepare_write(&buf->buf, from, bytes); -} +uint32_t +sc_audiobuf_write_silence(struct sc_audiobuf *buf, uint32_t samples); -static inline void -sc_audiobuf_commit_write(struct sc_audiobuf *buf, uint32_t samples) { - size_t bytes = sc_audiobuf_to_bytes(buf, samples); - sc_bytebuf_commit_write(&buf->buf, bytes); +static inline uint32_t +sc_audiobuf_capacity(struct sc_audiobuf *buf) { + assert(buf->alloc_size); + return buf->alloc_size - 1; } static inline uint32_t sc_audiobuf_can_read(struct sc_audiobuf *buf) { - size_t bytes = sc_bytebuf_can_read(&buf->buf); - return sc_audiobuf_to_samples(buf, bytes); -} - -static inline uint32_t -sc_audiobuf_can_write(struct sc_audiobuf *buf) { - size_t bytes = sc_bytebuf_can_write(&buf->buf); - return sc_audiobuf_to_samples(buf, bytes); -} - -static inline uint32_t -sc_audiobuf_capacity(struct sc_audiobuf *buf) { - size_t bytes = sc_bytebuf_capacity(&buf->buf); - return sc_audiobuf_to_samples(buf, bytes); -} - -static inline void -sc_audiobuf_destroy(struct sc_audiobuf *buf) { - sc_bytebuf_destroy(&buf->buf); + uint32_t head = atomic_load_explicit(&buf->head, memory_order_acquire); + uint32_t tail = atomic_load_explicit(&buf->tail, memory_order_acquire); + return (buf->alloc_size + head - tail) % buf->alloc_size; } #endif diff --git a/app/src/util/average.h b/app/src/util/average.h index 59fae7d1..eded9987 100644 --- a/app/src/util/average.h +++ b/app/src/util/average.h @@ -3,9 +3,6 @@ #include "common.h" -#include -#include - struct sc_average { // Current average value float avg; diff --git a/app/src/util/binary.h b/app/src/util/binary.h index 6dc1b58e..b6ce3201 100644 --- a/app/src/util/binary.h +++ b/app/src/util/binary.h @@ -4,7 +4,6 @@ #include "common.h" #include -#include #include static inline void @@ -13,6 +12,12 @@ sc_write16be(uint8_t *buf, uint16_t value) { buf[1] = value; } +static inline void +sc_write16le(uint8_t *buf, uint16_t value) { + buf[0] = value; + buf[1] = value >> 8; +} + static inline void sc_write32be(uint8_t *buf, uint32_t value) { buf[0] = value >> 24; @@ -21,12 +26,26 @@ sc_write32be(uint8_t *buf, uint32_t value) { buf[3] = value; } +static inline void +sc_write32le(uint8_t *buf, uint32_t value) { + buf[0] = value; + buf[1] = value >> 8; + buf[2] = value >> 16; + buf[3] = value >> 24; +} + static inline void sc_write64be(uint8_t *buf, uint64_t value) { sc_write32be(buf, value >> 32); sc_write32be(&buf[4], (uint32_t) value); } +static inline void +sc_write64le(uint8_t *buf, uint64_t value) { + sc_write32le(buf, (uint32_t) value); + sc_write32le(&buf[4], value >> 32); +} + static inline uint16_t sc_read16be(const uint8_t *buf) { return (buf[0] << 8) | buf[1]; diff --git a/app/src/util/bytebuf.c b/app/src/util/bytebuf.c deleted file mode 100644 index 93544d72..00000000 --- a/app/src/util/bytebuf.c +++ /dev/null @@ -1,104 +0,0 @@ -#include "bytebuf.h" - -#include -#include -#include - -#include "util/log.h" - -bool -sc_bytebuf_init(struct sc_bytebuf *buf, size_t alloc_size) { - assert(alloc_size); - buf->data = malloc(alloc_size); - if (!buf->data) { - LOG_OOM(); - return false; - } - - buf->alloc_size = alloc_size; - buf->head = 0; - buf->tail = 0; - - return true; -} - -void -sc_bytebuf_destroy(struct sc_bytebuf *buf) { - free(buf->data); -} - -void -sc_bytebuf_read(struct sc_bytebuf *buf, uint8_t *to, size_t len) { - assert(len); - assert(len <= sc_bytebuf_can_read(buf)); - assert(buf->tail != buf->head); // the buffer could not be empty - - size_t right_limit = buf->tail < buf->head ? buf->head : buf->alloc_size; - size_t right_len = right_limit - buf->tail; - if (len < right_len) { - right_len = len; - } - memcpy(to, buf->data + buf->tail, right_len); - - if (len > right_len) { - memcpy(to + right_len, buf->data, len - right_len); - } - - buf->tail = (buf->tail + len) % buf->alloc_size; -} - -void -sc_bytebuf_skip(struct sc_bytebuf *buf, size_t len) { - assert(len); - assert(len <= sc_bytebuf_can_read(buf)); - assert(buf->tail != buf->head); // the buffer could not be empty - - buf->tail = (buf->tail + len) % buf->alloc_size; -} - -static inline void -sc_bytebuf_write_step0(struct sc_bytebuf *buf, const uint8_t *from, - size_t len) { - size_t right_len = buf->alloc_size - buf->head; - if (len < right_len) { - right_len = len; - } - memcpy(buf->data + buf->head, from, right_len); - - if (len > right_len) { - memcpy(buf->data, from + right_len, len - right_len); - } -} - -static inline void -sc_bytebuf_write_step1(struct sc_bytebuf *buf, size_t len) { - buf->head = (buf->head + len) % buf->alloc_size; -} - -void -sc_bytebuf_write(struct sc_bytebuf *buf, const uint8_t *from, size_t len) { - assert(len); - assert(len <= sc_bytebuf_can_write(buf)); - - sc_bytebuf_write_step0(buf, from, len); - sc_bytebuf_write_step1(buf, len); -} - -void -sc_bytebuf_prepare_write(struct sc_bytebuf *buf, const uint8_t *from, - size_t len) { - // *This function MUST NOT access buf->tail (even in assert()).* - // The purpose of this function is to allow a reader and a writer to access - // different parts of the buffer in parallel simultaneously. It is intended - // to be called without lock (only sc_bytebuf_commit_write() is intended to - // be called with lock held). - - assert(len < buf->alloc_size - 1); - sc_bytebuf_write_step0(buf, from, len); -} - -void -sc_bytebuf_commit_write(struct sc_bytebuf *buf, size_t len) { - assert(len <= sc_bytebuf_can_write(buf)); - sc_bytebuf_write_step1(buf, len); -} diff --git a/app/src/util/bytebuf.h b/app/src/util/bytebuf.h deleted file mode 100644 index 1448f752..00000000 --- a/app/src/util/bytebuf.h +++ /dev/null @@ -1,114 +0,0 @@ -#ifndef SC_BYTEBUF_H -#define SC_BYTEBUF_H - -#include "common.h" - -#include -#include - -struct sc_bytebuf { - uint8_t *data; - // The actual capacity is (allocated - 1) so that head == tail is - // non-ambiguous - size_t alloc_size; - size_t head; // writter cursor - size_t tail; // reader cursor - // empty: tail == head - // full: ((tail + 1) % alloc_size) == head -}; - -bool -sc_bytebuf_init(struct sc_bytebuf *buf, size_t alloc_size); - -/** - * Copy from the bytebuf to a user-provided array - * - * The caller must check that len <= sc_bytebuf_read_available() (it is an - * error to attempt to read more bytes than available). - * - * This function is guaranteed not to write to buf->head. - */ -void -sc_bytebuf_read(struct sc_bytebuf *buf, uint8_t *to, size_t len); - -/** - * Drop len bytes from the buffer - * - * The caller must check that len <= sc_bytebuf_read_available() (it is an - * error to attempt to skip more bytes than available). - * - * This function is guaranteed not to write to buf->head. - * - * It is equivalent to call sc_bytebuf_read() to some array and discard the - * array (but this function is more efficient since there is no copy). - */ -void -sc_bytebuf_skip(struct sc_bytebuf *buf, size_t len); - -/** - * Copy the user-provided array to the bytebuf - * - * The caller must check that len <= sc_bytebuf_write_available() (it is an - * error to write more bytes than the remaining available space). - * - * This function is guaranteed not to write to buf->tail. - */ -void -sc_bytebuf_write(struct sc_bytebuf *buf, const uint8_t *from, size_t len); - -/** - * Copy the user-provided array to the bytebuf, but do not advance the cursor - * - * The caller must check that len <= sc_bytebuf_write_available() (it is an - * error to write more bytes than the remaining available space). - * - * After this function is called, the write must be committed with - * sc_bytebuf_commit_write(). - * - * The purpose of this mechanism is to acquire a lock only to commit the write, - * but not to perform the actual copy. - * - * This function is guaranteed not to access buf->tail. - */ -void -sc_bytebuf_prepare_write(struct sc_bytebuf *buf, const uint8_t *from, - size_t len); - -/** - * Commit a prepared write - */ -void -sc_bytebuf_commit_write(struct sc_bytebuf *buf, size_t len); - -/** - * Return the number of bytes which can be read - * - * It is an error to read more bytes than available. - */ -static inline size_t -sc_bytebuf_can_read(struct sc_bytebuf *buf) { - return (buf->alloc_size + buf->head - buf->tail) % buf->alloc_size; -} - -/** - * Return the number of bytes which can be written - * - * It is an error to write more bytes than available. - */ -static inline size_t -sc_bytebuf_can_write(struct sc_bytebuf *buf) { - return (buf->alloc_size + buf->tail - buf->head - 1) % buf->alloc_size; -} - -/** - * Return the actual capacity of the buffer (can_read() + can_write()) - */ -static inline size_t -sc_bytebuf_capacity(struct sc_bytebuf *buf) { - return buf->alloc_size - 1; -} - -void -sc_bytebuf_destroy(struct sc_bytebuf *buf); - -#endif diff --git a/app/src/util/env.c b/app/src/util/env.c new file mode 100644 index 00000000..127f5a1f --- /dev/null +++ b/app/src/util/env.c @@ -0,0 +1,31 @@ +#include "env.h" + +#include +#include +#ifdef _WIN32 +# include "util/str.h" +#endif + +char * +sc_get_env(const char *varname) { +#ifdef _WIN32 + wchar_t *w_varname = sc_str_to_wchars(varname); + if (!w_varname) { + return NULL; + } + const wchar_t *value = _wgetenv(w_varname); + free(w_varname); + if (!value) { + return NULL; + } + + return sc_str_from_wchars(value); +#else + const char *value = getenv(varname); + if (!value) { + return NULL; + } + + return strdup(value); +#endif +} diff --git a/app/src/util/env.h b/app/src/util/env.h new file mode 100644 index 00000000..50a31165 --- /dev/null +++ b/app/src/util/env.h @@ -0,0 +1,12 @@ +#ifndef SC_ENV_H +#define SC_ENV_H + +#include "common.h" + +// Return the value of the environment variable (may be NULL). +// +// The returned value must be freed by the caller. +char * +sc_get_env(const char *varname); + +#endif diff --git a/app/src/util/intmap.h b/app/src/util/intmap.h index 2898c461..7ab903ca 100644 --- a/app/src/util/intmap.h +++ b/app/src/util/intmap.h @@ -3,6 +3,7 @@ #include "common.h" +#include #include struct sc_intmap_entry { diff --git a/app/src/util/intr.c b/app/src/util/intr.c index 22bd121a..ddf4839f 100644 --- a/app/src/util/intr.c +++ b/app/src/util/intr.c @@ -1,9 +1,9 @@ #include "intr.h" -#include "util/log.h" - #include +#include "util/log.h" + bool sc_intr_init(struct sc_intr *intr) { bool ok = sc_mutex_init(&intr->mutex); diff --git a/app/src/util/intr.h b/app/src/util/intr.h index 1c20f6df..35bd3375 100644 --- a/app/src/util/intr.h +++ b/app/src/util/intr.h @@ -6,9 +6,9 @@ #include #include -#include "net.h" -#include "process.h" -#include "thread.h" +#include "util/net.h" +#include "util/process.h" +#include "util/thread.h" /** * Interruptor to wake up a blocking call from another thread diff --git a/app/src/util/log.c b/app/src/util/log.c index 8a347c84..9114a258 100644 --- a/app/src/util/log.c +++ b/app/src/util/log.c @@ -4,7 +4,10 @@ # include #endif #include -#include +#include +#include +#include +#include static SDL_LogPriority log_level_sc_to_sdl(enum sc_log_level level) { diff --git a/app/src/util/net.c b/app/src/util/net.c index 67317ead..9562ff6b 100644 --- a/app/src/util/net.c +++ b/app/src/util/net.c @@ -1,31 +1,27 @@ #include "net.h" #include -#include #include -#include "log.h" - #ifdef _WIN32 # include typedef int socklen_t; - typedef SOCKET sc_raw_socket; -# define SC_RAW_SOCKET_NONE INVALID_SOCKET #else -# include -# include -# include # include -# include # include +# include +# include +# include +# include +# include # define SOCKET_ERROR -1 typedef struct sockaddr_in SOCKADDR_IN; typedef struct sockaddr SOCKADDR; typedef struct in_addr IN_ADDR; - typedef int sc_raw_socket; -# define SC_RAW_SOCKET_NONE -1 #endif +#include "util/log.h" + bool net_init(void) { #ifdef _WIN32 @@ -46,17 +42,26 @@ net_cleanup(void) { #endif } +static inline bool +sc_raw_socket_close(sc_raw_socket raw_sock) { +#ifndef _WIN32 + return !close(raw_sock); +#else + return !closesocket(raw_sock); +#endif +} + static inline sc_socket wrap(sc_raw_socket sock) { -#ifdef _WIN32 - if (sock == INVALID_SOCKET) { +#ifdef SC_SOCKET_CLOSE_ON_INTERRUPT + if (sock == SC_RAW_SOCKET_NONE) { return SC_SOCKET_NONE; } - struct sc_socket_windows *socket = malloc(sizeof(*socket)); + struct sc_socket_wrapper *socket = malloc(sizeof(*socket)); if (!socket) { LOG_OOM(); - closesocket(sock); + sc_raw_socket_close(sock); return SC_SOCKET_NONE; } @@ -71,9 +76,9 @@ wrap(sc_raw_socket sock) { static inline sc_raw_socket unwrap(sc_socket socket) { -#ifdef _WIN32 +#ifdef SC_SOCKET_CLOSE_ON_INTERRUPT if (socket == SC_SOCKET_NONE) { - return INVALID_SOCKET; + return SC_RAW_SOCKET_NONE; } return socket->socket; @@ -82,17 +87,6 @@ unwrap(sc_socket socket) { #endif } -#ifndef HAVE_SOCK_CLOEXEC // avoid unused-function warning -static inline bool -sc_raw_socket_close(sc_raw_socket raw_sock) { -#ifndef _WIN32 - return !close(raw_sock); -#else - return !closesocket(raw_sock); -#endif -} -#endif - #ifndef HAVE_SOCK_CLOEXEC // If SOCK_CLOEXEC does not exist, the flag must be set manually once the // socket is created @@ -247,9 +241,9 @@ net_interrupt(sc_socket socket) { sc_raw_socket raw_sock = unwrap(socket); -#ifdef _WIN32 +#ifdef SC_SOCKET_CLOSE_ON_INTERRUPT if (!atomic_flag_test_and_set(&socket->closed)) { - return !closesocket(raw_sock); + return sc_raw_socket_close(raw_sock); } return true; #else @@ -261,18 +255,34 @@ bool net_close(sc_socket socket) { sc_raw_socket raw_sock = unwrap(socket); -#ifdef _WIN32 +#ifdef SC_SOCKET_CLOSE_ON_INTERRUPT bool ret = true; if (!atomic_flag_test_and_set(&socket->closed)) { - ret = !closesocket(raw_sock); + ret = sc_raw_socket_close(raw_sock); } free(socket); return ret; #else - return !close(raw_sock); + return sc_raw_socket_close(raw_sock); #endif } +bool +net_set_tcp_nodelay(sc_socket socket, bool tcp_nodelay) { + sc_raw_socket raw_sock = unwrap(socket); + + int value = tcp_nodelay ? 1 : 0; + int ret = setsockopt(raw_sock, IPPROTO_TCP, TCP_NODELAY, + (const void *) &value, sizeof(value)); + if (ret == -1) { + net_perror("setsockopt(TCP_NODELAY)"); + return false; + } + + assert(ret == 0); + return true; +} + bool net_parse_ipv4(const char *s, uint32_t *ipv4) { struct in_addr addr; diff --git a/app/src/util/net.h b/app/src/util/net.h index 21396882..aa99bbc4 100644 --- a/app/src/util/net.h +++ b/app/src/util/net.h @@ -4,24 +4,40 @@ #include "common.h" #include +#include #include +#include #ifdef _WIN32 - # include + typedef SOCKET sc_raw_socket; +# define SC_RAW_SOCKET_NONE INVALID_SOCKET +#else // not _WIN32 + typedef int sc_raw_socket; +# define SC_RAW_SOCKET_NONE -1 +#endif + +#if defined(_WIN32) || defined(__APPLE__) +// On Windows and macOS, shutdown() does not interrupt accept() or read() +// calls, so net_interrupt() must call close() instead, and net_close() must +// behave accordingly. +// This causes a small race condition (once the socket is closed, its +// handle becomes invalid and may in theory be reassigned before another +// thread calls accept() or read()), but it is deemed acceptable as a +// workaround. +# define SC_SOCKET_CLOSE_ON_INTERRUPT +#endif + +#ifdef SC_SOCKET_CLOSE_ON_INTERRUPT # include # define SC_SOCKET_NONE NULL - typedef struct sc_socket_windows { - SOCKET socket; + typedef struct sc_socket_wrapper { + sc_raw_socket socket; atomic_flag closed; } *sc_socket; - -#else // not _WIN32 - -# include +#else # define SC_SOCKET_NONE -1 - typedef int sc_socket; - + typedef sc_raw_socket sc_socket; #endif #define IPV4_LOCALHOST 0x7F000001 @@ -67,6 +83,10 @@ net_interrupt(sc_socket socket); bool net_close(sc_socket socket); +// Disable Nagle's algorithm (if tcp_nodelay is true) +bool +net_set_tcp_nodelay(sc_socket socket, bool tcp_nodelay); + /** * Parse `ip` "xxx.xxx.xxx.xxx" to an IPv4 host representation */ diff --git a/app/src/util/net_intr.h b/app/src/util/net_intr.h index dbef528d..e2bbee88 100644 --- a/app/src/util/net_intr.h +++ b/app/src/util/net_intr.h @@ -3,8 +3,13 @@ #include "common.h" -#include "intr.h" -#include "net.h" +#include +#include +#include +#include + +#include "util/intr.h" +#include "util/net.h" bool net_connect_intr(struct sc_intr *intr, sc_socket socket, uint32_t addr, diff --git a/app/src/util/process.c b/app/src/util/process.c index 9c4dcd9f..29d89a54 100644 --- a/app/src/util/process.c +++ b/app/src/util/process.c @@ -1,8 +1,6 @@ #include "process.h" #include -#include -#include "log.h" enum sc_process_result sc_process_execute(const char *const argv[], sc_pid *pid, unsigned flags) { diff --git a/app/src/util/process.h b/app/src/util/process.h index 4d9d1684..eec51bcc 100644 --- a/app/src/util/process.h +++ b/app/src/util/process.h @@ -4,7 +4,9 @@ #include "common.h" #include +#include #include "util/thread.h" +#include "util/tick.h" #ifdef _WIN32 diff --git a/app/src/util/process_intr.c b/app/src/util/process_intr.c index d37bd5a5..641440ab 100644 --- a/app/src/util/process_intr.c +++ b/app/src/util/process_intr.c @@ -5,7 +5,7 @@ sc_pipe_read_intr(struct sc_intr *intr, sc_pid pid, sc_pipe pipe, char *data, size_t len) { if (intr && !sc_intr_set_process(intr, pid)) { // Already interrupted - return false; + return -1; } ssize_t ret = sc_pipe_read(pipe, data, len); @@ -22,7 +22,7 @@ sc_pipe_read_all_intr(struct sc_intr *intr, sc_pid pid, sc_pipe pipe, char *data, size_t len) { if (intr && !sc_intr_set_process(intr, pid)) { // Already interrupted - return false; + return -1; } ssize_t ret = sc_pipe_read_all(pipe, data, len); diff --git a/app/src/util/process_intr.h b/app/src/util/process_intr.h index 530a9046..020eafa1 100644 --- a/app/src/util/process_intr.h +++ b/app/src/util/process_intr.h @@ -3,8 +3,8 @@ #include "common.h" -#include "intr.h" -#include "process.h" +#include "util/intr.h" +#include "util/process.h" ssize_t sc_pipe_read_intr(struct sc_intr *intr, sc_pid pid, sc_pipe pipe, char *data, diff --git a/app/src/util/str.c b/app/src/util/str.c index d78aa9d7..83d19c4d 100644 --- a/app/src/util/str.c +++ b/app/src/util/str.c @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -11,8 +12,8 @@ # include #endif -#include "log.h" -#include "strbuf.h" +#include "util/log.h" +#include "util/strbuf.h" size_t sc_strncpy(char *dest, const char *src, size_t n) { @@ -63,6 +64,26 @@ sc_str_quote(const char *src) { return quoted; } +char * +sc_str_concat(const char *start, const char *end) { + assert(start); + assert(end); + + size_t start_len = strlen(start); + size_t end_len = strlen(end); + + char *result = malloc(start_len + end_len + 1); + if (!result) { + LOG_OOM(); + return NULL; + } + + memcpy(result, start, start_len); + memcpy(result + start_len, end, end_len + 1); + + return result; +} + bool sc_str_parse_integer(const char *s, long *out) { char *endptr; @@ -333,3 +354,22 @@ sc_str_remove_trailing_cr(char *s, size_t len) { } return len; } + +char * +sc_str_to_hex_string(const uint8_t *data, size_t size) { + size_t buffer_size = size * 3 + 1; + char *buffer = malloc(buffer_size); + if (!buffer) { + LOG_OOM(); + return NULL; + } + + for (size_t i = 0; i < size; ++i) { + snprintf(buffer + i * 3, 4, "%02X ", data[i]); + } + + // Remove the final space + buffer[size * 3] = '\0'; + + return buffer; +} diff --git a/app/src/util/str.h b/app/src/util/str.h index 4f7eeeda..b386b48d 100644 --- a/app/src/util/str.h +++ b/app/src/util/str.h @@ -5,6 +5,8 @@ #include #include +#include +#include /* Stringify a numeric value */ #define SC_STR(s) SC_XSTR(s) @@ -38,6 +40,15 @@ sc_str_join(char *dst, const char *const tokens[], char sep, size_t n); char * sc_str_quote(const char *src); +/** + * Concat two strings + * + * Return a new allocated string, contanining the concatenation of the two + * input strings. + */ +char * +sc_str_concat(const char *start, const char *end); + /** * Parse `s` as an integer into `out` * @@ -138,4 +149,10 @@ sc_str_index_of_column(const char *s, unsigned col, const char *seps); size_t sc_str_remove_trailing_cr(char *s, size_t len); +/** + * Convert binary data to hexadecimal string + */ +char * +sc_str_to_hex_string(const uint8_t *data, size_t len); + #endif diff --git a/app/src/util/strbuf.c b/app/src/util/strbuf.c index 1892b46b..6196d746 100644 --- a/app/src/util/strbuf.c +++ b/app/src/util/strbuf.c @@ -1,11 +1,10 @@ #include "strbuf.h" #include -#include #include #include -#include "log.h" +#include "util/log.h" bool sc_strbuf_init(struct sc_strbuf *buf, size_t init_cap) { diff --git a/app/src/util/thread.c b/app/src/util/thread.c index 94921fb7..2a5253f7 100644 --- a/app/src/util/thread.c +++ b/app/src/util/thread.c @@ -1,10 +1,14 @@ #include "thread.h" #include +#include +#include #include #include -#include "log.h" +#include "util/log.h" + +sc_thread_id SC_MAIN_THREAD_ID; bool sc_thread_create(sc_thread *thread, sc_thread_fn fn, const char *name, diff --git a/app/src/util/thread.h b/app/src/util/thread.h index 4183adac..3d544046 100644 --- a/app/src/util/thread.h +++ b/app/src/util/thread.h @@ -39,6 +39,8 @@ typedef struct sc_cond { SDL_cond *cond; } sc_cond; +extern sc_thread_id SC_MAIN_THREAD_ID; + bool sc_thread_create(sc_thread *thread, sc_thread_fn fn, const char *name, void *userdata); diff --git a/app/src/util/tick.c b/app/src/util/tick.c index cc0bab5e..edef1070 100644 --- a/app/src/util/tick.c +++ b/app/src/util/tick.c @@ -1,6 +1,7 @@ #include "tick.h" #include +#include #include #ifdef _WIN32 # include diff --git a/app/src/util/tick.h b/app/src/util/tick.h index 2d941f23..b037734b 100644 --- a/app/src/util/tick.h +++ b/app/src/util/tick.h @@ -10,14 +10,14 @@ typedef int64_t sc_tick; #define SC_TICK_FREQ 1000000 // microsecond // To be adapted if SC_TICK_FREQ changes -#define SC_TICK_TO_NS(tick) ((tick) * 1000) -#define SC_TICK_TO_US(tick) (tick) -#define SC_TICK_TO_MS(tick) ((tick) / 1000) -#define SC_TICK_TO_SEC(tick) ((tick) / 1000000) -#define SC_TICK_FROM_NS(ns) ((ns) / 1000) -#define SC_TICK_FROM_US(us) (us) -#define SC_TICK_FROM_MS(ms) ((ms) * 1000) -#define SC_TICK_FROM_SEC(sec) ((sec) * 1000000) +#define SC_TICK_TO_NS(tick) ((sc_tick) (tick) * 1000) +#define SC_TICK_TO_US(tick) ((sc_tick) tick) +#define SC_TICK_TO_MS(tick) ((sc_tick) (tick) / 1000) +#define SC_TICK_TO_SEC(tick) ((sc_tick) (tick) / 1000000) +#define SC_TICK_FROM_NS(ns) ((sc_tick) (ns) / 1000) +#define SC_TICK_FROM_US(us) ((sc_tick) us) +#define SC_TICK_FROM_MS(ms) ((sc_tick) (ms) * 1000) +#define SC_TICK_FROM_SEC(sec) ((sc_tick) (sec) * 1000000) sc_tick sc_tick_now(void); diff --git a/app/src/util/timeout.c b/app/src/util/timeout.c index a1665373..21bc3a53 100644 --- a/app/src/util/timeout.c +++ b/app/src/util/timeout.c @@ -1,8 +1,9 @@ #include "timeout.h" #include +#include -#include "log.h" +#include "util/log.h" bool sc_timeout_init(struct sc_timeout *timeout) { @@ -62,6 +63,7 @@ void sc_timeout_stop(struct sc_timeout *timeout) { sc_mutex_lock(&timeout->mutex); timeout->stopped = true; + sc_cond_signal(&timeout->cond); sc_mutex_unlock(&timeout->mutex); } diff --git a/app/src/util/timeout.h b/app/src/util/timeout.h index ae171b86..a45ae2ae 100644 --- a/app/src/util/timeout.h +++ b/app/src/util/timeout.h @@ -5,8 +5,8 @@ #include -#include "thread.h" -#include "tick.h" +#include "util/thread.h" +#include "util/tick.h" struct sc_timeout { sc_thread thread; diff --git a/app/src/util/vecdeque.h b/app/src/util/vecdeque.h index ce559ee9..e31724e2 100644 --- a/app/src/util/vecdeque.h +++ b/app/src/util/vecdeque.h @@ -6,6 +6,7 @@ #include #include #include +#include #include #include diff --git a/app/src/util/vector.h b/app/src/util/vector.h index 97d7c389..5b399d56 100644 --- a/app/src/util/vector.h +++ b/app/src/util/vector.h @@ -5,8 +5,8 @@ #include #include +#include #include -#include // Adapted from vlc_vector: // diff --git a/app/src/v4l2_sink.c b/app/src/v4l2_sink.c index 3b3eb8d0..da9e02ef 100644 --- a/app/src/v4l2_sink.c +++ b/app/src/v4l2_sink.c @@ -1,5 +1,9 @@ #include "v4l2_sink.h" +#include +#include +#include +#include #include #include "util/log.h" @@ -240,7 +244,7 @@ sc_v4l2_sink_open(struct sc_v4l2_sink *vs, const AVCodecContext *ctx) { vs->frame = av_frame_alloc(); if (!vs->frame) { LOG_OOM(); - goto error_avcodec_close; + goto error_avcodec_free_context; } vs->packet = av_packet_alloc(); @@ -268,8 +272,6 @@ error_av_packet_free: av_packet_free(&vs->packet); error_av_frame_free: av_frame_free(&vs->frame); -error_avcodec_close: - avcodec_close(vs->encoder_ctx); error_avcodec_free_context: avcodec_free_context(&vs->encoder_ctx); error_avio_close: @@ -297,7 +299,6 @@ sc_v4l2_sink_close(struct sc_v4l2_sink *vs) { av_packet_free(&vs->packet); av_frame_free(&vs->frame); - avcodec_close(vs->encoder_ctx); avcodec_free_context(&vs->encoder_ctx); avio_close(vs->format_ctx->pb); avformat_free_context(vs->format_ctx); diff --git a/app/src/v4l2_sink.h b/app/src/v4l2_sink.h index 365a739d..2b7c5b50 100644 --- a/app/src/v4l2_sink.h +++ b/app/src/v4l2_sink.h @@ -3,13 +3,13 @@ #include "common.h" +#include #include #include -#include "coords.h" -#include "trait/frame_sink.h" #include "frame_buffer.h" -#include "util/tick.h" +#include "trait/frame_sink.h" +#include "util/thread.h" struct sc_v4l2_sink { struct sc_frame_sink frame_sink; // frame sink trait diff --git a/app/src/version.c b/app/src/version.c index 90ea3334..f8610714 100644 --- a/app/src/version.c +++ b/app/src/version.c @@ -1,5 +1,6 @@ #include "version.h" +#include #include #include #include @@ -9,6 +10,7 @@ #ifdef HAVE_USB # include #endif +#include void scrcpy_print_version(void) { diff --git a/app/tests/test_audiobuf.c b/app/tests/test_audiobuf.c new file mode 100644 index 00000000..539ee238 --- /dev/null +++ b/app/tests/test_audiobuf.c @@ -0,0 +1,136 @@ +#include "common.h" + +#include +#include + +#include "util/audiobuf.h" + +static void test_audiobuf_simple(void) { + struct sc_audiobuf buf; + uint32_t data[20]; + + bool ok = sc_audiobuf_init(&buf, 4, 20); + assert(ok); + + uint32_t samples[] = {1, 2, 3, 4, 5}; + uint32_t w = sc_audiobuf_write(&buf, samples, 5); + assert(w == 5); + + uint32_t r = sc_audiobuf_read(&buf, data, 4); + assert(r == 4); + assert(!memcmp(data, samples, 16)); + + uint32_t samples2[] = {6, 7, 8}; + w = sc_audiobuf_write(&buf, samples2, 3); + assert(w == 3); + + uint32_t single = 9; + w = sc_audiobuf_write(&buf, &single, 1); + assert(w == 1); + + r = sc_audiobuf_read(&buf, &data[4], 8); + assert(r == 5); + + uint32_t expected[] = {1, 2, 3, 4, 5, 6, 7, 8, 9}; + assert(!memcmp(data, expected, 36)); + + sc_audiobuf_destroy(&buf); +} + +static void test_audiobuf_boundaries(void) { + struct sc_audiobuf buf; + uint32_t data[20]; + + bool ok = sc_audiobuf_init(&buf, 4, 20); + assert(ok); + + uint32_t samples[] = {1, 2, 3, 4, 5, 6}; + uint32_t w = sc_audiobuf_write(&buf, samples, 6); + assert(w == 6); + + w = sc_audiobuf_write(&buf, samples, 6); + assert(w == 6); + + w = sc_audiobuf_write(&buf, samples, 6); + assert(w == 6); + + uint32_t r = sc_audiobuf_read(&buf, data, 9); + assert(r == 9); + + uint32_t expected[] = {1, 2, 3, 4, 5, 6, 1, 2, 3}; + assert(!memcmp(data, expected, 36)); + + uint32_t samples2[] = {7, 8, 9, 10, 11}; + w = sc_audiobuf_write(&buf, samples2, 5); + assert(w == 5); + + uint32_t single = 12; + w = sc_audiobuf_write(&buf, &single, 1); + assert(w == 1); + + w = sc_audiobuf_read(&buf, NULL, 3); + assert(w == 3); + + assert(sc_audiobuf_can_read(&buf) == 12); + + r = sc_audiobuf_read(&buf, data, 12); + assert(r == 12); + + uint32_t expected2[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}; + assert(!memcmp(data, expected2, 48)); + + sc_audiobuf_destroy(&buf); +} + +static void test_audiobuf_partial_read_write(void) { + struct sc_audiobuf buf; + uint32_t data[15]; + + bool ok = sc_audiobuf_init(&buf, 4, 10); + assert(ok); + + uint32_t samples[] = {1, 2, 3, 4, 5, 6}; + uint32_t w = sc_audiobuf_write(&buf, samples, 6); + assert(w == 6); + + w = sc_audiobuf_write(&buf, samples, 6); + assert(w == 4); + + w = sc_audiobuf_write(&buf, samples, 6); + assert(w == 0); + + uint32_t r = sc_audiobuf_read(&buf, data, 3); + assert(r == 3); + + uint32_t expected[] = {1, 2, 3}; + assert(!memcmp(data, expected, 12)); + + w = sc_audiobuf_write(&buf, samples, 6); + assert(w == 3); + + r = sc_audiobuf_read(&buf, data, 15); + assert(r == 10); + uint32_t expected2[] = {4, 5, 6, 1, 2, 3, 4, 1, 2, 3}; + assert(!memcmp(data, expected2, 12)); + + w = sc_audiobuf_write_silence(&buf, 4); + assert(w == 4); + + r = sc_audiobuf_read(&buf, data, 4); + assert(r == 4); + uint32_t expected3[] = {0, 0, 0, 0}; + assert(!memcmp(data, expected3, 4)); + + sc_audiobuf_destroy(&buf); +} + +int main(int argc, char *argv[]) { + (void) argc; + (void) argv; + + test_audiobuf_simple(); + test_audiobuf_boundaries(); + test_audiobuf_partial_read_write(); + + return 0; +} diff --git a/app/tests/test_binary.c b/app/tests/test_binary.c index 82a9c1e0..bce74ce2 100644 --- a/app/tests/test_binary.c +++ b/app/tests/test_binary.c @@ -42,6 +42,44 @@ static void test_write64be(void) { assert(buf[7] == 0xEF); } +static void test_write16le(void) { + uint16_t val = 0xABCD; + uint8_t buf[2]; + + sc_write16le(buf, val); + + assert(buf[0] == 0xCD); + assert(buf[1] == 0xAB); +} + +static void test_write32le(void) { + uint32_t val = 0xABCD1234; + uint8_t buf[4]; + + sc_write32le(buf, val); + + assert(buf[0] == 0x34); + assert(buf[1] == 0x12); + assert(buf[2] == 0xCD); + assert(buf[3] == 0xAB); +} + +static void test_write64le(void) { + uint64_t val = 0xABCD1234567890EF; + uint8_t buf[8]; + + sc_write64le(buf, val); + + assert(buf[0] == 0xEF); + assert(buf[1] == 0x90); + assert(buf[2] == 0x78); + assert(buf[3] == 0x56); + assert(buf[4] == 0x34); + assert(buf[5] == 0x12); + assert(buf[6] == 0xCD); + assert(buf[7] == 0xAB); +} + static void test_read16be(void) { uint8_t buf[2] = {0xAB, 0xCD}; @@ -108,6 +146,10 @@ int main(int argc, char *argv[]) { test_read32be(); test_read64be(); + test_write16le(); + test_write32le(); + test_write64le(); + test_float_to_u16fp(); test_float_to_i16fp(); return 0; diff --git a/app/tests/test_bytebuf.c b/app/tests/test_bytebuf.c deleted file mode 100644 index 8e9d7c57..00000000 --- a/app/tests/test_bytebuf.c +++ /dev/null @@ -1,126 +0,0 @@ -#include "common.h" - -#include -#include - -#include "util/bytebuf.h" - -static void test_bytebuf_simple(void) { - struct sc_bytebuf buf; - uint8_t data[20]; - - bool ok = sc_bytebuf_init(&buf, 20); - assert(ok); - - sc_bytebuf_write(&buf, (uint8_t *) "hello", sizeof("hello") - 1); - assert(sc_bytebuf_can_read(&buf) == 5); - - sc_bytebuf_read(&buf, data, 4); - assert(!strncmp((char *) data, "hell", 4)); - - sc_bytebuf_write(&buf, (uint8_t *) " world", sizeof(" world") - 1); - assert(sc_bytebuf_can_read(&buf) == 7); - - sc_bytebuf_write(&buf, (uint8_t *) "!", 1); - assert(sc_bytebuf_can_read(&buf) == 8); - - sc_bytebuf_read(&buf, &data[4], 8); - assert(sc_bytebuf_can_read(&buf) == 0); - - data[12] = '\0'; - assert(!strcmp((char *) data, "hello world!")); - assert(sc_bytebuf_can_read(&buf) == 0); - - sc_bytebuf_destroy(&buf); -} - -static void test_bytebuf_boundaries(void) { - struct sc_bytebuf buf; - uint8_t data[20]; - - bool ok = sc_bytebuf_init(&buf, 20); - assert(ok); - - sc_bytebuf_write(&buf, (uint8_t *) "hello ", sizeof("hello ") - 1); - assert(sc_bytebuf_can_read(&buf) == 6); - - sc_bytebuf_write(&buf, (uint8_t *) "hello ", sizeof("hello ") - 1); - assert(sc_bytebuf_can_read(&buf) == 12); - - sc_bytebuf_write(&buf, (uint8_t *) "hello ", sizeof("hello ") - 1); - assert(sc_bytebuf_can_read(&buf) == 18); - - sc_bytebuf_read(&buf, data, 9); - assert(!strncmp((char *) data, "hello hel", 9)); - assert(sc_bytebuf_can_read(&buf) == 9); - - sc_bytebuf_write(&buf, (uint8_t *) "world", sizeof("world") - 1); - assert(sc_bytebuf_can_read(&buf) == 14); - - sc_bytebuf_write(&buf, (uint8_t *) "!", 1); - assert(sc_bytebuf_can_read(&buf) == 15); - - sc_bytebuf_skip(&buf, 3); - assert(sc_bytebuf_can_read(&buf) == 12); - - sc_bytebuf_read(&buf, data, 12); - data[12] = '\0'; - assert(!strcmp((char *) data, "hello world!")); - assert(sc_bytebuf_can_read(&buf) == 0); - - sc_bytebuf_destroy(&buf); -} - -static void test_bytebuf_two_steps_write(void) { - struct sc_bytebuf buf; - uint8_t data[20]; - - bool ok = sc_bytebuf_init(&buf, 20); - assert(ok); - - sc_bytebuf_write(&buf, (uint8_t *) "hello ", sizeof("hello ") - 1); - assert(sc_bytebuf_can_read(&buf) == 6); - - sc_bytebuf_write(&buf, (uint8_t *) "hello ", sizeof("hello ") - 1); - assert(sc_bytebuf_can_read(&buf) == 12); - - sc_bytebuf_prepare_write(&buf, (uint8_t *) "hello ", sizeof("hello ") - 1); - assert(sc_bytebuf_can_read(&buf) == 12); // write not committed yet - - sc_bytebuf_read(&buf, data, 9); - assert(!strncmp((char *) data, "hello hel", 3)); - assert(sc_bytebuf_can_read(&buf) == 3); - - sc_bytebuf_commit_write(&buf, sizeof("hello ") - 1); - assert(sc_bytebuf_can_read(&buf) == 9); - - sc_bytebuf_prepare_write(&buf, (uint8_t *) "world", sizeof("world") - 1); - assert(sc_bytebuf_can_read(&buf) == 9); // write not committed yet - - sc_bytebuf_commit_write(&buf, sizeof("world") - 1); - assert(sc_bytebuf_can_read(&buf) == 14); - - sc_bytebuf_write(&buf, (uint8_t *) "!", 1); - assert(sc_bytebuf_can_read(&buf) == 15); - - sc_bytebuf_skip(&buf, 3); - assert(sc_bytebuf_can_read(&buf) == 12); - - sc_bytebuf_read(&buf, data, 12); - data[12] = '\0'; - assert(!strcmp((char *) data, "hello world!")); - assert(sc_bytebuf_can_read(&buf) == 0); - - sc_bytebuf_destroy(&buf); -} - -int main(int argc, char *argv[]) { - (void) argc; - (void) argv; - - test_bytebuf_simple(); - test_bytebuf_boundaries(); - test_bytebuf_two_steps_write(); - - return 0; -} diff --git a/app/tests/test_cli.c b/app/tests/test_cli.c index f2a17272..de605cb9 100644 --- a/app/tests/test_cli.c +++ b/app/tests/test_cli.c @@ -51,7 +51,6 @@ static void test_options(void) { "--fullscreen", "--max-fps", "30", "--max-size", "1024", - "--lock-video-orientation=2", // optional arguments require '=' // "--no-control" is not compatible with "--turn-screen-off" // "--no-playback" is not compatible with "--fulscreen" "--port", "1234:1236", @@ -78,9 +77,8 @@ static void test_options(void) { assert(opts->video_bit_rate == 5000000); assert(!strcmp(opts->crop, "100:200:300:400")); assert(opts->fullscreen); - assert(opts->max_fps == 30); + assert(!strcmp(opts->max_fps, "30")); assert(opts->max_size == 1024); - assert(opts->lock_video_orientation == 2); assert(opts->port_range.first == 1234); assert(opts->port_range.last == 1236); assert(!strcmp(opts->push_target, "/sdcard/Movies")); @@ -124,32 +122,22 @@ static void test_options2(void) { } static void test_parse_shortcut_mods(void) { - struct sc_shortcut_mods mods; + uint8_t mods; bool ok; ok = sc_parse_shortcut_mods("lctrl", &mods); assert(ok); - assert(mods.count == 1); - assert(mods.data[0] == SC_SHORTCUT_MOD_LCTRL); - - ok = sc_parse_shortcut_mods("lctrl+lalt", &mods); - assert(ok); - assert(mods.count == 1); - assert(mods.data[0] == (SC_SHORTCUT_MOD_LCTRL | SC_SHORTCUT_MOD_LALT)); + assert(mods == SC_SHORTCUT_MOD_LCTRL); ok = sc_parse_shortcut_mods("rctrl,lalt", &mods); assert(ok); - assert(mods.count == 2); - assert(mods.data[0] == SC_SHORTCUT_MOD_RCTRL); - assert(mods.data[1] == SC_SHORTCUT_MOD_LALT); + assert(mods == (SC_SHORTCUT_MOD_RCTRL | SC_SHORTCUT_MOD_LALT)); - ok = sc_parse_shortcut_mods("lsuper,rsuper+lalt,lctrl+rctrl+ralt", &mods); + ok = sc_parse_shortcut_mods("lsuper,rsuper,lctrl", &mods); assert(ok); - assert(mods.count == 3); - assert(mods.data[0] == SC_SHORTCUT_MOD_LSUPER); - assert(mods.data[1] == (SC_SHORTCUT_MOD_RSUPER | SC_SHORTCUT_MOD_LALT)); - assert(mods.data[2] == (SC_SHORTCUT_MOD_LCTRL | SC_SHORTCUT_MOD_RCTRL | - SC_SHORTCUT_MOD_RALT)); + assert(mods == (SC_SHORTCUT_MOD_LSUPER + | SC_SHORTCUT_MOD_RSUPER + | SC_SHORTCUT_MOD_LCTRL)); ok = sc_parse_shortcut_mods("", &mods); assert(!ok); diff --git a/app/tests/test_control_msg_serialize.c b/app/tests/test_control_msg_serialize.c index b2eef49c..0d19919e 100644 --- a/app/tests/test_control_msg_serialize.c +++ b/app/tests/test_control_msg_serialize.c @@ -1,6 +1,7 @@ #include "common.h" #include +#include #include #include "control_msg.h" @@ -16,11 +17,11 @@ static void test_serialize_inject_keycode(void) { }, }; - unsigned char buf[SC_CONTROL_MSG_MAX_SIZE]; + uint8_t buf[SC_CONTROL_MSG_MAX_SIZE]; size_t size = sc_control_msg_serialize(&msg, buf); assert(size == 14); - const unsigned char expected[] = { + const uint8_t expected[] = { SC_CONTROL_MSG_TYPE_INJECT_KEYCODE, 0x01, // AKEY_EVENT_ACTION_UP 0x00, 0x00, 0x00, 0x42, // AKEYCODE_ENTER @@ -38,11 +39,11 @@ static void test_serialize_inject_text(void) { }, }; - unsigned char buf[SC_CONTROL_MSG_MAX_SIZE]; + uint8_t buf[SC_CONTROL_MSG_MAX_SIZE]; size_t size = sc_control_msg_serialize(&msg, buf); assert(size == 18); - const unsigned char expected[] = { + const uint8_t expected[] = { SC_CONTROL_MSG_TYPE_INJECT_TEXT, 0x00, 0x00, 0x00, 0x0d, // text length 'h', 'e', 'l', 'l', 'o', ',', ' ', 'w', 'o', 'r', 'l', 'd', '!', // text @@ -58,11 +59,11 @@ static void test_serialize_inject_text_long(void) { text[SC_CONTROL_MSG_INJECT_TEXT_MAX_LENGTH] = '\0'; msg.inject_text.text = text; - unsigned char buf[SC_CONTROL_MSG_MAX_SIZE]; + uint8_t buf[SC_CONTROL_MSG_MAX_SIZE]; size_t size = sc_control_msg_serialize(&msg, buf); assert(size == 5 + SC_CONTROL_MSG_INJECT_TEXT_MAX_LENGTH); - unsigned char expected[5 + SC_CONTROL_MSG_INJECT_TEXT_MAX_LENGTH]; + uint8_t expected[5 + SC_CONTROL_MSG_INJECT_TEXT_MAX_LENGTH]; expected[0] = SC_CONTROL_MSG_TYPE_INJECT_TEXT; expected[1] = 0x00; expected[2] = 0x00; @@ -95,11 +96,11 @@ static void test_serialize_inject_touch_event(void) { }, }; - unsigned char buf[SC_CONTROL_MSG_MAX_SIZE]; + uint8_t buf[SC_CONTROL_MSG_MAX_SIZE]; size_t size = sc_control_msg_serialize(&msg, buf); assert(size == 32); - const unsigned char expected[] = { + const uint8_t expected[] = { SC_CONTROL_MSG_TYPE_INJECT_TOUCH_EVENT, 0x00, // AKEY_EVENT_ACTION_DOWN 0x12, 0x34, 0x56, 0x78, 0x87, 0x65, 0x43, 0x21, // pointer id @@ -126,22 +127,22 @@ static void test_serialize_inject_scroll_event(void) { .height = 1920, }, }, - .hscroll = 1, - .vscroll = -1, + .hscroll = 16, + .vscroll = -16, .buttons = 1, }, }; - unsigned char buf[SC_CONTROL_MSG_MAX_SIZE]; + uint8_t buf[SC_CONTROL_MSG_MAX_SIZE]; size_t size = sc_control_msg_serialize(&msg, buf); assert(size == 21); - const unsigned char expected[] = { + const uint8_t expected[] = { SC_CONTROL_MSG_TYPE_INJECT_SCROLL_EVENT, 0x00, 0x00, 0x01, 0x04, 0x00, 0x00, 0x04, 0x02, // 260 1026 0x04, 0x38, 0x07, 0x80, // 1080 1920 - 0x7F, 0xFF, // 1 (float encoded as i16) - 0x80, 0x00, // -1 (float encoded as i16) + 0x7F, 0xFF, // 16 (float encoded as i16 in the range [-16, 16]) + 0x80, 0x00, // -16 (float encoded as i16 in the range [-16, 16]) 0x00, 0x00, 0x00, 0x01, // 1 }; assert(!memcmp(buf, expected, sizeof(expected))); @@ -155,11 +156,11 @@ static void test_serialize_back_or_screen_on(void) { }, }; - unsigned char buf[SC_CONTROL_MSG_MAX_SIZE]; + uint8_t buf[SC_CONTROL_MSG_MAX_SIZE]; size_t size = sc_control_msg_serialize(&msg, buf); assert(size == 2); - const unsigned char expected[] = { + const uint8_t expected[] = { SC_CONTROL_MSG_TYPE_BACK_OR_SCREEN_ON, 0x01, // AKEY_EVENT_ACTION_UP }; @@ -171,11 +172,11 @@ static void test_serialize_expand_notification_panel(void) { .type = SC_CONTROL_MSG_TYPE_EXPAND_NOTIFICATION_PANEL, }; - unsigned char buf[SC_CONTROL_MSG_MAX_SIZE]; + uint8_t buf[SC_CONTROL_MSG_MAX_SIZE]; size_t size = sc_control_msg_serialize(&msg, buf); assert(size == 1); - const unsigned char expected[] = { + const uint8_t expected[] = { SC_CONTROL_MSG_TYPE_EXPAND_NOTIFICATION_PANEL, }; assert(!memcmp(buf, expected, sizeof(expected))); @@ -186,11 +187,11 @@ static void test_serialize_expand_settings_panel(void) { .type = SC_CONTROL_MSG_TYPE_EXPAND_SETTINGS_PANEL, }; - unsigned char buf[SC_CONTROL_MSG_MAX_SIZE]; + uint8_t buf[SC_CONTROL_MSG_MAX_SIZE]; size_t size = sc_control_msg_serialize(&msg, buf); assert(size == 1); - const unsigned char expected[] = { + const uint8_t expected[] = { SC_CONTROL_MSG_TYPE_EXPAND_SETTINGS_PANEL, }; assert(!memcmp(buf, expected, sizeof(expected))); @@ -201,11 +202,11 @@ static void test_serialize_collapse_panels(void) { .type = SC_CONTROL_MSG_TYPE_COLLAPSE_PANELS, }; - unsigned char buf[SC_CONTROL_MSG_MAX_SIZE]; + uint8_t buf[SC_CONTROL_MSG_MAX_SIZE]; size_t size = sc_control_msg_serialize(&msg, buf); assert(size == 1); - const unsigned char expected[] = { + const uint8_t expected[] = { SC_CONTROL_MSG_TYPE_COLLAPSE_PANELS, }; assert(!memcmp(buf, expected, sizeof(expected))); @@ -219,11 +220,11 @@ static void test_serialize_get_clipboard(void) { }, }; - unsigned char buf[SC_CONTROL_MSG_MAX_SIZE]; + uint8_t buf[SC_CONTROL_MSG_MAX_SIZE]; size_t size = sc_control_msg_serialize(&msg, buf); assert(size == 2); - const unsigned char expected[] = { + const uint8_t expected[] = { SC_CONTROL_MSG_TYPE_GET_CLIPBOARD, SC_COPY_KEY_COPY, }; @@ -240,11 +241,11 @@ static void test_serialize_set_clipboard(void) { }, }; - unsigned char buf[SC_CONTROL_MSG_MAX_SIZE]; + uint8_t buf[SC_CONTROL_MSG_MAX_SIZE]; size_t size = sc_control_msg_serialize(&msg, buf); assert(size == 27); - const unsigned char expected[] = { + const uint8_t expected[] = { SC_CONTROL_MSG_TYPE_SET_CLIPBOARD, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, // sequence 1, // paste @@ -269,11 +270,11 @@ static void test_serialize_set_clipboard_long(void) { text[SC_CONTROL_MSG_CLIPBOARD_TEXT_MAX_LENGTH] = '\0'; msg.set_clipboard.text = text; - unsigned char buf[SC_CONTROL_MSG_MAX_SIZE]; + uint8_t buf[SC_CONTROL_MSG_MAX_SIZE]; size_t size = sc_control_msg_serialize(&msg, buf); assert(size == SC_CONTROL_MSG_MAX_SIZE); - unsigned char expected[SC_CONTROL_MSG_MAX_SIZE] = { + uint8_t expected[SC_CONTROL_MSG_MAX_SIZE] = { SC_CONTROL_MSG_TYPE_SET_CLIPBOARD, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, // sequence 1, // paste @@ -288,21 +289,21 @@ 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, }, }; - unsigned char buf[SC_CONTROL_MSG_MAX_SIZE]; + uint8_t buf[SC_CONTROL_MSG_MAX_SIZE]; size_t size = sc_control_msg_serialize(&msg, buf); assert(size == 2); - const unsigned char expected[] = { - SC_CONTROL_MSG_TYPE_SET_SCREEN_POWER_MODE, - 0x02, // SC_SCREEN_POWER_MODE_NORMAL + const uint8_t expected[] = { + SC_CONTROL_MSG_TYPE_SET_DISPLAY_POWER, + 0x01, // true }; assert(!memcmp(buf, expected, sizeof(expected))); } @@ -312,16 +313,119 @@ static void test_serialize_rotate_device(void) { .type = SC_CONTROL_MSG_TYPE_ROTATE_DEVICE, }; - unsigned char buf[SC_CONTROL_MSG_MAX_SIZE]; + uint8_t buf[SC_CONTROL_MSG_MAX_SIZE]; size_t size = sc_control_msg_serialize(&msg, buf); assert(size == 1); - const unsigned char expected[] = { + const uint8_t expected[] = { SC_CONTROL_MSG_TYPE_ROTATE_DEVICE, }; assert(!memcmp(buf, expected, sizeof(expected))); } +static void test_serialize_uhid_create(void) { + const uint8_t report_desc[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}; + struct sc_control_msg msg = { + .type = SC_CONTROL_MSG_TYPE_UHID_CREATE, + .uhid_create = { + .id = 42, + .vendor_id = 0x1234, + .product_id = 0x5678, + .name = "ABC", + .report_desc_size = sizeof(report_desc), + .report_desc = report_desc, + }, + }; + + uint8_t buf[SC_CONTROL_MSG_MAX_SIZE]; + size_t size = sc_control_msg_serialize(&msg, buf); + assert(size == 24); + + const uint8_t expected[] = { + SC_CONTROL_MSG_TYPE_UHID_CREATE, + 0, 42, // id + 0x12, 0x34, // vendor id + 0x56, 0x78, // product id + 3, // name size + 65, 66, 67, // "ABC" + 0, 11, // report desc size + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, + }; + assert(!memcmp(buf, expected, sizeof(expected))); +} + +static void test_serialize_uhid_input(void) { + struct sc_control_msg msg = { + .type = SC_CONTROL_MSG_TYPE_UHID_INPUT, + .uhid_input = { + .id = 42, + .size = 5, + .data = {1, 2, 3, 4, 5}, + }, + }; + + uint8_t buf[SC_CONTROL_MSG_MAX_SIZE]; + size_t size = sc_control_msg_serialize(&msg, buf); + assert(size == 10); + + const uint8_t expected[] = { + SC_CONTROL_MSG_TYPE_UHID_INPUT, + 0, 42, // id + 0, 5, // size + 1, 2, 3, 4, 5, + }; + assert(!memcmp(buf, expected, sizeof(expected))); +} + +static void test_serialize_uhid_destroy(void) { + struct sc_control_msg msg = { + .type = SC_CONTROL_MSG_TYPE_UHID_DESTROY, + .uhid_destroy = { + .id = 42, + }, + }; + + uint8_t buf[SC_CONTROL_MSG_MAX_SIZE]; + size_t size = sc_control_msg_serialize(&msg, buf); + assert(size == 3); + + const uint8_t expected[] = { + SC_CONTROL_MSG_TYPE_UHID_DESTROY, + 0, 42, // id + }; + assert(!memcmp(buf, expected, sizeof(expected))); +} + +static void test_serialize_open_hard_keyboard(void) { + struct sc_control_msg msg = { + .type = SC_CONTROL_MSG_TYPE_OPEN_HARD_KEYBOARD_SETTINGS, + }; + + 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_OPEN_HARD_KEYBOARD_SETTINGS, + }; + 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; @@ -338,7 +442,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; } diff --git a/app/tests/test_device_msg_deserialize.c b/app/tests/test_device_msg_deserialize.c index 835096c0..a64a3eb7 100644 --- a/app/tests/test_device_msg_deserialize.c +++ b/app/tests/test_device_msg_deserialize.c @@ -1,32 +1,32 @@ #include "common.h" #include +#include +#include #include #include "device_msg.h" -#include - static void test_deserialize_clipboard(void) { - const unsigned char input[] = { + const uint8_t input[] = { DEVICE_MSG_TYPE_CLIPBOARD, 0x00, 0x00, 0x00, 0x03, // text length 0x41, 0x42, 0x43, // "ABC" }; - struct device_msg msg; - ssize_t r = device_msg_deserialize(input, sizeof(input), &msg); + struct sc_device_msg msg; + ssize_t r = sc_device_msg_deserialize(input, sizeof(input), &msg); assert(r == 8); assert(msg.type == DEVICE_MSG_TYPE_CLIPBOARD); assert(msg.clipboard.text); assert(!strcmp("ABC", msg.clipboard.text)); - device_msg_destroy(&msg); + sc_device_msg_destroy(&msg); } static void test_deserialize_clipboard_big(void) { - unsigned char input[DEVICE_MSG_MAX_SIZE]; + uint8_t input[DEVICE_MSG_MAX_SIZE]; input[0] = DEVICE_MSG_TYPE_CLIPBOARD; input[1] = (DEVICE_MSG_TEXT_MAX_LENGTH & 0xff000000u) >> 24; input[2] = (DEVICE_MSG_TEXT_MAX_LENGTH & 0x00ff0000u) >> 16; @@ -35,8 +35,8 @@ static void test_deserialize_clipboard_big(void) { memset(input + 5, 'a', DEVICE_MSG_TEXT_MAX_LENGTH); - struct device_msg msg; - ssize_t r = device_msg_deserialize(input, sizeof(input), &msg); + struct sc_device_msg msg; + ssize_t r = sc_device_msg_deserialize(input, sizeof(input), &msg); assert(r == DEVICE_MSG_MAX_SIZE); assert(msg.type == DEVICE_MSG_TYPE_CLIPBOARD); @@ -44,23 +44,45 @@ static void test_deserialize_clipboard_big(void) { assert(strlen(msg.clipboard.text) == DEVICE_MSG_TEXT_MAX_LENGTH); assert(msg.clipboard.text[0] == 'a'); - device_msg_destroy(&msg); + sc_device_msg_destroy(&msg); } static void test_deserialize_ack_set_clipboard(void) { - const unsigned char input[] = { + const uint8_t input[] = { DEVICE_MSG_TYPE_ACK_CLIPBOARD, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, // sequence }; - struct device_msg msg; - ssize_t r = device_msg_deserialize(input, sizeof(input), &msg); + struct sc_device_msg msg; + ssize_t r = sc_device_msg_deserialize(input, sizeof(input), &msg); assert(r == 9); assert(msg.type == DEVICE_MSG_TYPE_ACK_CLIPBOARD); assert(msg.ack_clipboard.sequence == UINT64_C(0x0102030405060708)); } +static void test_deserialize_uhid_output(void) { + const uint8_t input[] = { + DEVICE_MSG_TYPE_UHID_OUTPUT, + 0, 42, // id + 0, 5, // size + 0x01, 0x02, 0x03, 0x04, 0x05, // data + }; + + struct sc_device_msg msg; + ssize_t r = sc_device_msg_deserialize(input, sizeof(input), &msg); + assert(r == 10); + + assert(msg.type == DEVICE_MSG_TYPE_UHID_OUTPUT); + assert(msg.uhid_output.id == 42); + assert(msg.uhid_output.size == 5); + + uint8_t expected[] = {1, 2, 3, 4, 5}; + assert(!memcmp(msg.uhid_output.data, expected, sizeof(expected))); + + sc_device_msg_destroy(&msg); +} + int main(int argc, char *argv[]) { (void) argc; (void) argv; @@ -68,5 +90,6 @@ int main(int argc, char *argv[]) { test_deserialize_clipboard(); test_deserialize_clipboard_big(); test_deserialize_ack_set_clipboard(); + test_deserialize_uhid_output(); return 0; } diff --git a/app/tests/test_str.c b/app/tests/test_str.c index 5d365ef5..4a906d92 100644 --- a/app/tests/test_str.c +++ b/app/tests/test_str.c @@ -141,6 +141,16 @@ static void test_quote(void) { free(out); } +static void test_concat(void) { + const char *s = "2024:11"; + char *out = sc_str_concat("my-prefix:", s); + + // contains the concat + assert(!strcmp("my-prefix:2024:11", out)); + + free(out); +} + static void test_utf8_truncate(void) { const char *s = "aÉbÔc"; assert(strlen(s) == 7); // É and Ô are 2 bytes-wide @@ -389,6 +399,7 @@ int main(int argc, char *argv[]) { test_join_truncated_before_sep(); test_join_truncated_after_sep(); test_quote(); + test_concat(); test_utf8_truncate(); test_parse_integer(); test_parse_integers(); diff --git a/build.gradle b/build.gradle index b27befb6..81c91d37 100644 --- a/build.gradle +++ b/build.gradle @@ -7,7 +7,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.1.3' + classpath 'com.android.tools.build:gradle:8.7.1' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files diff --git a/cross_win32.txt b/cross_win32.txt index 05f9a86b..ddbc65f3 100644 --- a/cross_win32.txt +++ b/cross_win32.txt @@ -7,6 +7,8 @@ cpp = 'i686-w64-mingw32-g++' ar = 'i686-w64-mingw32-ar' strip = 'i686-w64-mingw32-strip' pkg-config = 'i686-w64-mingw32-pkg-config' +# backward compatibility +pkgconfig = 'i686-w64-mingw32-pkg-config' windres = 'i686-w64-mingw32-windres' [host_machine] diff --git a/cross_win64.txt b/cross_win64.txt index 86364ad6..a6f16e16 100644 --- a/cross_win64.txt +++ b/cross_win64.txt @@ -7,6 +7,8 @@ cpp = 'x86_64-w64-mingw32-g++' ar = 'x86_64-w64-mingw32-ar' strip = 'x86_64-w64-mingw32-strip' pkg-config = 'x86_64-w64-mingw32-pkg-config' +# backward compatibility +pkgconfig = 'x86_64-w64-mingw32-pkg-config' windres = 'x86_64-w64-mingw32-windres' [host_machine] diff --git a/doc/audio.md b/doc/audio.md index ecae4468..142626f5 100644 --- a/doc/audio.md +++ b/doc/audio.md @@ -28,14 +28,21 @@ To disable only the audio playback, see [no playback](video.md#no-playback). ## Audio only -To play audio only, disable the video: +To play audio only, disable video and control: ```bash -scrcpy --no-video +scrcpy --no-video --no-control +``` + +To play audio without a window: + +```bash +# --no-video and --no-control are implied by --no-window +scrcpy --no-window # interrupt with Ctrl+C ``` -Without video, the audio latency is typically not criticial, so it might be +Without video, the audio latency is typically not critical, so it might be interesting to add [buffering](#buffering) to minimize glitches: ``` @@ -59,6 +66,44 @@ the computer: scrcpy --audio-source=mic --no-video --no-playback --record=file.opus ``` +Many sources are available: + + - `output` (default): forwards the whole audio output, and disables playback on the device (mapped to [`REMOTE_SUBMIX`](https://developer.android.com/reference/android/media/MediaRecorder.AudioSource#REMOTE_SUBMIX)). + - `playback`: captures the audio playback (Android apps can opt-out, so the whole output is not necessarily captured). + - `mic`: captures the microphone (mapped to [`MIC`](https://developer.android.com/reference/android/media/MediaRecorder.AudioSource#MIC)). + - `mic-unprocessed`: captures the microphone unprocessed (raw) sound (mapped to [`UNPROCESSED`](https://developer.android.com/reference/android/media/MediaRecorder.AudioSource#UNPROCESSED)). + - `mic-camcorder`: captures the microphone tuned for video recording, with the same orientation as the camera if available (mapped to [`CAMCORDER`](https://developer.android.com/reference/android/media/MediaRecorder.AudioSource#CAMCORDER)). + - `mic-voice-recognition`: captures the microphone tuned for voice recognition (mapped to [`VOICE_RECOGNITION`](https://developer.android.com/reference/android/media/MediaRecorder.AudioSource#VOICE_RECOGNITION)). + - `mic-voice-communication`: captures the microphone tuned for voice communications (it will for instance take advantage of echo cancellation or automatic gain control if available) (mapped to [`VOICE_COMMUNICATION`](https://developer.android.com/reference/android/media/MediaRecorder.AudioSource#VOICE_COMMUNICATION)). + - `voice-call`: captures voice call (mapped to [`VOICE_CALL`](https://developer.android.com/reference/android/media/MediaRecorder.AudioSource#VOICE_CALL)). + - `voice-call-uplink`: captures voice call uplink only (mapped to [`VOICE_UPLINK`](https://developer.android.com/reference/android/media/MediaRecorder.AudioSource#VOICE_UPLINK)). + - `voice-call-downlink`: captures voice call downlink only (mapped to [`VOICE_DOWNLINK`](https://developer.android.com/reference/android/media/MediaRecorder.AudioSource#VOICE_DOWNLINK)). + - `voice-performance`: captures audio meant to be processed for live performance (karaoke), includes both the microphone and the device playback (mapped to [`VOICE_PERFORMANCE`](https://developer.android.com/reference/android/media/MediaRecorder.AudioSource#VOICE_PERFORMANCE)). + +### Duplication + +An alternative device audio capture method is also available (only for Android +13 and above): + +``` +scrcpy --audio-source=playback +``` + +This audio source supports keeping the audio playing on the device while +mirroring, with `--audio-dup`: + +```bash +scrcpy --audio-source=playback --audio-dup +# or simply: +scrcpy --audio-dup # --audio-source=playback is implied +``` + +However, it requires Android 13, and Android apps can opt-out (so they are not +captured). + + +See [#4380](https://github.com/Genymobile/scrcpy/issues/4380). + ## Codec @@ -139,7 +184,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), diff --git a/doc/build.md b/doc/build.md index 15c567b5..7f76b4fd 100644 --- a/doc/build.md +++ b/doc/build.md @@ -77,7 +77,7 @@ pip3 install meson sudo dnf install https://download1.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm # client build dependencies -sudo dnf install SDL2-devel ffms2-devel libusb1-devel meson gcc make +sudo dnf install SDL2-devel ffms2-devel libusb1-devel libavdevice-free-devel meson gcc make # server build dependencies sudo dnf install java-devel @@ -94,7 +94,7 @@ This is the preferred method (and the way the release is built). From _Debian_, install _mingw_: ```bash -sudo apt install mingw-w64 mingw-w64-tools +sudo apt install mingw-w64 mingw-w64-tools libz-mingw-w64-dev ``` You also need the JDK to build the server: @@ -233,10 +233,10 @@ install` must be run as root)._ #### Option 2: Use prebuilt server - - [`scrcpy-server-v2.2`][direct-scrcpy-server] - SHA-256: `c85c4aa84305efb69115cd497a120ebdd10258993b4cf123a8245b3d99d49874` + - [`scrcpy-server-v3.3.1`][direct-scrcpy-server] + SHA-256: `a0f70b20aa4998fbf658c94118cd6c8dab6abbb0647a3bdab344d70bc1ebcbb8` -[direct-scrcpy-server]: https://github.com/Genymobile/scrcpy/releases/download/v2.2/scrcpy-server-v2.2 +[direct-scrcpy-server]: https://github.com/Genymobile/scrcpy/releases/download/v3.3.1/scrcpy-server-v3.3.1 Download the prebuilt server somewhere, and specify its path during the Meson configuration: diff --git a/doc/connection.md b/doc/connection.md index 90ced010..dcf00147 100644 --- a/doc/connection.md +++ b/doc/connection.md @@ -67,14 +67,6 @@ computer. An option `--tcpip` allows to configure the connection automatically. There are two variants. -If the device (accessible at 192.168.1.1 in this example) already listens on a -port (typically 5555) for incoming _adb_ connections, then run: - -```bash -scrcpy --tcpip=192.168.1.1 # default port is 5555 -scrcpy --tcpip=192.168.1.1:5555 -``` - If _adb_ TCP/IP mode is disabled on the device (or if you don't know the IP address), connect the device over USB, then run: @@ -85,6 +77,20 @@ scrcpy --tcpip # without arguments It will automatically find the device IP address and adb port, enable TCP/IP mode if necessary, then connect to the device before starting. +If the device (accessible at 192.168.1.1 in this example) already listens on a +port (typically 5555) for incoming _adb_ connections, then run: + +```bash +scrcpy --tcpip=192.168.1.1 # default port is 5555 +scrcpy --tcpip=192.168.1.1:5555 +``` + +Prefix the address with a '+' to force a reconnection: + +```bash +scrcpy --tcpip=+192.168.1.1 +``` + ### Manual @@ -107,16 +113,17 @@ with the device IP address you found)_. 7. Run `scrcpy` as usual. 8. Run `adb disconnect` once you're done. -Since Android 11, a [wireless debugging option][adb-wireless] allows to bypass -having to physically connect your device directly to your computer. +Since Android 11, a [wireless debugging option][adb-wireless] allows you to +bypass having to physically connect your device to your computer. [adb-wireless]: https://developer.android.com/studio/command-line/adb#wireless-android11-command-line ## Autostart -A small tool (by the scrcpy author) allows to run arbitrary commands whenever a -new Android device is connected: [AutoAdb]. It can be used to start scrcpy: +A small tool (by the scrcpy author) allows you to run arbitrary commands +whenever a new Android device is connected: [AutoAdb]. It can be used to start +scrcpy: ```bash autoadb scrcpy -s '{}' diff --git a/doc/control.md b/doc/control.md index 0b060775..86c0efe6 100644 --- a/doc/control.md +++ b/doc/control.md @@ -10,36 +10,40 @@ scrcpy --no-control scrcpy -n # short version ``` +## Keyboard and mouse -## Text injection preference +Read [keyboard](keyboard.md) and [mouse](mouse.md). -Two kinds of [events][textevents] are generated when typing text: - - _key events_, signaling that a key is pressed or released; - - _text events_, signaling that a text has been entered. -By default, letters are injected using key events, so that the keyboard behaves -as expected in games (typically for WASD keys). +## Control only -But this may [cause issues][prefertext]. If you encounter such a problem, you -can avoid it by: +To control the device without mirroring: ```bash -scrcpy --prefer-text +scrcpy --no-video --no-audio ``` -(but this will break keyboard behavior in games) +By default, the mouse is disabled when video playback is turned off. -On the contrary, you could force to always inject raw key events: +To control the device using a relative mouse, enable UHID mouse mode: ```bash -scrcpy --raw-key-events +scrcpy --no-video --no-audio --mouse=uhid +scrcpy --no-video --no-audio -M # short version ``` -These options have no effect on HID keyboard (all key events are sent as -scancodes in this mode). +To also use a UHID keyboard, set it explicitly: -[textevents]: https://blog.rom1v.com/2018/03/introducing-scrcpy/#handle-text-input -[prefertext]: https://github.com/Genymobile/scrcpy/issues/650#issuecomment-512945343 +```bash +scrcpy --no-video --no-audio --mouse=uhid --keyboard=uhid +scrcpy --no-video --no-audio -MK # short version +``` + +To use AOA instead (over USB only): + +```bash +scrcpy --no-video --no-audio --keyboard=aoa --mouse=aoa +``` ## Copy-paste @@ -85,7 +89,8 @@ way as MOD+Shift+v). To disable automatic clipboard synchronization, use `--no-clipboard-autosync`. -## Pinch-to-zoom + +## Pinch-to-zoom, rotate and tilt simulation To simulate "pinch-to-zoom": Ctrl+_click-and-move_. @@ -93,33 +98,23 @@ More precisely, hold down Ctrl while pressing the left-click button. Until the left-click button is released, all mouse movements scale and rotate the content (if supported by the app) relative to the center of the screen. +https://github.com/Genymobile/scrcpy/assets/543275/26c4a920-9805-43f1-8d4c-608752d04767 + +To simulate a vertical tilt gesture: Shift+_click-and-move-up-or-down_. + +https://github.com/Genymobile/scrcpy/assets/543275/1e252341-4a90-4b29-9d11-9153b324669f + +Similarly, to simulate a horizontal tilt gesture: +Ctrl+Shift+_click-and-move-left-or-right_. + Technically, _scrcpy_ generates additional touch events from a "virtual finger" -at a location inverted through the center of the screen. +at a location inverted through the center of the screen. When pressing +Ctrl the _x_ and _y_ coordinates are inverted. Using Shift +only inverts _x_, whereas using Ctrl+Shift only inverts +_y_. +This only works for the default mouse mode (`--mouse=sdk`). -## Key repeat - -By default, holding a key down generates repeated key events. This can cause -performance problems in some games, where these events are useless anyway. - -To avoid forwarding repeated key events: - -```bash -scrcpy --no-key-repeat -``` - -This option has no effect on HID keyboard (key repeat is handled by Android -directly in this mode). - - -## Right-click and middle-click - -By default, right-click triggers BACK (or POWER on) and middle-click triggers -HOME. To disable these shortcuts and forward the clicks to the device instead: - -```bash -scrcpy --forward-all-clicks -``` ## File drop @@ -143,7 +138,3 @@ The target directory can be changed on start: ```bash scrcpy --push-target=/sdcard/Movies/ ``` - -## Physical keyboard and mouse simulation - -See the dedicated [HID/OTG](hid-otg.md) page. diff --git a/doc/develop.md b/doc/develop.md index 67d7f9b0..21949ea6 100644 --- a/doc/develop.md +++ b/doc/develop.md @@ -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 @@ -234,7 +234,7 @@ The video and audio streams are decoded by [FFmpeg]. The client parses the command line arguments, then [runs one of two code paths][run]: - scrcpy in "normal" mode ([`scrcpy.c`]) - - scrcpy in [OTG mode](hid-otg.md) ([`scrcpy_otg.c`]) + - scrcpy in [OTG mode](otg.md) ([`scrcpy_otg.c`]) [run]: https://github.com/Genymobile/scrcpy/blob/a3cdf1a6b86ea22786e1f7d09b9c202feabc6949/app/src/main.c#L81-L82 [`scrcpy.c`]: https://github.com/Genymobile/scrcpy/blob/a3cdf1a6b86ea22786e1f7d09b9c202feabc6949/app/src/scrcpy.c#L292-L293 @@ -461,26 +461,30 @@ meson setup x -Dserver_debugger=true meson configure x -Dserver_debugger=true ``` -If your device runs Android 8 or below, set the `server_debugger_method` to -`old` in addition: +Then recompile, and run scrcpy. -```bash -meson setup x -Dserver_debugger=true -Dserver_debugger_method=old -# or, if x is already configured -meson configure x -Dserver_debugger=true -Dserver_debugger_method=old -``` - -Then recompile. - -When you start scrcpy, it will start a debugger on port 5005 on the device. +For Android < 11, it will start a debugger on port 5005 on the device and wait: Redirect that port to the computer: ```bash adb forward tcp:5005 tcp:5005 ``` -In Android Studio, _Run_ > _Debug_ > _Edit configurations..._ On the left, click on -`+`, _Remote_, and fill the form: +For Android >= 11, first find the listening port: + +```bash +adb jdwp +# press Ctrl+C to interrupt +``` + +Then redirect the resulting PID: + +```bash +adb forward tcp:5005 jdwp:XXXX # replace XXXX +``` + +In Android Studio, _Run_ > _Debug_ > _Edit configurations..._ On the left, click +on `+`, _Remote_, and fill the form: - Host: `localhost` - Port: `5005` diff --git a/doc/device.md b/doc/device.md index 988ad417..ab1e6ba4 100644 --- a/doc/device.md +++ b/doc/device.md @@ -18,6 +18,46 @@ 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 +``` + + +## 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. + ## Turn screen off @@ -46,6 +86,15 @@ 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 +``` + ## 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 +``` diff --git a/doc/gamepad.md b/doc/gamepad.md new file mode 100644 index 00000000..d3d27b51 --- /dev/null +++ b/doc/gamepad.md @@ -0,0 +1,58 @@ +# Gamepad + +Several gamepad input modes are available: + + - `--gamepad=disabled` (default) + - `--gamepad=uhid` (or `-G`): simulates physical HID gamepads using the UHID + kernel module on the device + - `--gamepad=aoa`: simulates physical HID gamepads using the AOAv2 protocol + + +## Physical gamepad simulation + +Two modes allow to simulate physical HID gamepads on the device, one for each +physical gamepad plugged into the computer. + + +### UHID + +This mode simulates physical HID gamepads using the [UHID] kernel module on the +device. + +[UHID]: https://kernel.org/doc/Documentation/hid/uhid.txt + +To enable UHID gamepads, use: + +```bash +scrcpy --gamepad=uhid +scrcpy -G # short version +``` + +Note: UHID may not work on old Android versions due to permission errors. + + +### AOA + +This mode simulates physical HID gamepads using the [AOAv2] protocol. + +[AOAv2]: https://source.android.com/devices/accessories/aoa2#hid-support + +To enable AOA gamepads, use: + +```bash +scrcpy --gamepad=aoa +``` + +Contrary to the other mode, it works at the USB level directly (so it only works +over USB). + +It does not use the scrcpy server, and does not require `adb` (USB debugging). +Therefore, it is possible to control the device (but not mirror) even with USB +debugging disabled (see [OTG](otg.md)). + +Note: For some reason, in this mode, Android detects multiple physical gamepads +as a single misbehaving one. Use UHID if you need multiple gamepads. + +Note: On Windows, it may only work in [OTG mode](otg.md), not while mirroring +(it is not possible to open a USB device if it is already open by another +process like the _adb daemon_). diff --git a/doc/hid-otg.md b/doc/hid-otg.md deleted file mode 100644 index 7dfc60fc..00000000 --- a/doc/hid-otg.md +++ /dev/null @@ -1,112 +0,0 @@ -# HID/OTG - -By default, _scrcpy_ injects input events at the Android API level. As an -alternative, when connected over USB, it is possible to send HID events, so that -scrcpy behaves as if it was a physical keyboard and/or mouse connected to the -Android device. - -A special [OTG](#otg) mode allows to control the device without mirroring (and -without USB debugging). - - -## Physical keyboard simulation - -By default, _scrcpy_ uses Android key or text injection. It works everywhere, -but is limited to ASCII. - -Instead, it can simulate a physical USB keyboard on Android to provide a better -input experience (using [USB HID over AOAv2][hid-aoav2]): the virtual keyboard -is disabled and it works for all characters and IME. - -[hid-aoav2]: https://source.android.com/devices/accessories/aoa2#hid-support - -However, it only works if the device is connected via USB. - -Note: On Windows, it may only work in [OTG mode](#otg), not while mirroring (it -is not possible to open a USB device if it is already open by another process -like the _adb daemon_). - -To enable this mode: - -```bash -scrcpy --hid-keyboard -scrcpy -K # short version -``` - -If it fails for some reason (for example because the device is not connected via -USB), it automatically fallbacks to the default mode (with a log in the -console). This allows using the same command line options when connected over -USB and TCP/IP. - -In this mode, raw key events (scancodes) are sent to the device, independently -of the host key mapping. Therefore, if your keyboard layout does not match, it -must be configured on the Android device, in Settings → System → Languages and -input → [Physical keyboard]. - -This settings page can be started directly: - -```bash -adb shell am start -a android.settings.HARD_KEYBOARD_SETTINGS -``` - -However, the option is only available when the HID keyboard is enabled (or when -a physical keyboard is connected). - -[Physical keyboard]: https://github.com/Genymobile/scrcpy/pull/2632#issuecomment-923756915 - - -## Physical mouse simulation - -By default, _scrcpy_ uses Android mouse events injection with absolute -coordinates. By simulating a physical mouse, a mouse pointer appears on the -Android device, and relative mouse motion, clicks and scrolls are injected. - -To enable this mode: - -```bash -scrcpy --hid-mouse -scrcpy -M # short version -``` - -When this mode is enabled, the computer mouse is "captured" (the mouse pointer -disappears from the computer and appears on the Android device instead). - -Special capture keys, either Alt or Super, toggle -(disable or enable) the mouse capture. Use one of them to give the control of -the mouse back to the computer. - - -## OTG - -It is possible to run _scrcpy_ with only physical keyboard and mouse simulation -(HID), as if the computer keyboard and mouse were plugged directly to the device -via an OTG cable. - -In this mode, `adb` (USB debugging) is not necessary, and mirroring is disabled. - -This is similar to `--hid-keyboard --hid-mouse`, but without mirroring. - -To enable OTG mode: - -```bash -scrcpy --otg -# Pass the serial if several USB devices are available -scrcpy --otg -s 0123456789abcdef -``` - -It is possible to enable only HID keyboard or HID mouse: - -```bash -scrcpy --otg --hid-keyboard # keyboard only -scrcpy --otg --hid-mouse # mouse only -scrcpy --otg --hid-keyboard --hid-mouse # keyboard and mouse -# for convenience, enable both by default -scrcpy --otg # keyboard and mouse -``` - -Like `--hid-keyboard` and `--hid-mouse`, it only works if the device is -connected over USB. - -## HID/OTG issues on Windows - -See [FAQ](/FAQ.md#hidotg-issues-on-windows). diff --git a/doc/keyboard.md b/doc/keyboard.md new file mode 100644 index 00000000..80dfe070 --- /dev/null +++ b/doc/keyboard.md @@ -0,0 +1,136 @@ +# Keyboard + +Several keyboard input modes are available: + + - `--keyboard=sdk` (default) + - `--keyboard=uhid` (or `-K`): simulates a physical HID keyboard using the UHID + kernel module on the device + - `--keyboard=aoa`: simulates a physical HID keyboard using the AOAv2 protocol + - `--keyboard=disabled` + +By default, `sdk` is used, but if you use scrcpy regularly, it is recommended to +use [`uhid`](#uhid) and configure the keyboard layout once and for all. + + +## SDK keyboard + +In this mode (`--keyboard=sdk`, or if the parameter is omitted), keyboard input +events are injected at the Android API level. It works everywhere, but it is +limited to ASCII and some other characters. + +Note that on some devices, an additional option must be enabled in developer +options for this keyboard mode to work. See +[prerequisites](/README.md#prerequisites). + +Additional parameters (specific to `--keyboard=sdk`) described below allow to +customize the behavior. + + +### Text injection preference + +Two kinds of [events][textevents] are generated when typing text: + - _key events_, signaling that a key is pressed or released; + - _text events_, signaling that a text has been entered. + +By default, numbers and "special characters" are inserted using text events, but +letters are injected using key events, so that the keyboard behaves as expected +in games (typically for WASD keys). + +But this may [cause issues][prefertext]. If you encounter such a problem, you +can inject letters as text (or just switch to [UHID](#uhid)): + +```bash +scrcpy --prefer-text +``` + +(but this will break keyboard behavior in games) + +On the contrary, you could force to always inject raw key events: + +```bash +scrcpy --raw-key-events +``` + +[textevents]: https://blog.rom1v.com/2018/03/introducing-scrcpy/#handle-text-input +[prefertext]: https://github.com/Genymobile/scrcpy/issues/650#issuecomment-512945343 + + +### Key repeat + +By default, holding a key down generates repeated key events. Ths can cause +performance problems in some games, where these events are useless anyway. + +To avoid forwarding repeated key events: + +```bash +scrcpy --no-key-repeat +``` + + +## Physical keyboard simulation + +Two modes allow to simulate a physical HID keyboard on the device. + +To work properly, it is necessary to configure (once and for all) the keyboard +layout on the device to match that of the computer. + +The configuration page can be opened in one of the following ways: + - from the scrcpy window (when `uhid` or `aoa` is used), by pressing + MOD+k (see [shortcuts](shortcuts.md)) + - from the device, in Settings → System → Languages and input → Physical + devices + - from a terminal on the computer, by executing `adb shell am start -a + android.settings.HARD_KEYBOARD_SETTINGS` + +From this configuration page, it is also possible to enable or disable on-screen +keyboard. + + +### UHID + +This mode simulates a physical HID keyboard using the [UHID] kernel module on the +device. + +[UHID]: https://kernel.org/doc/Documentation/hid/uhid.txt + +To enable UHID keyboard, use: + +```bash +scrcpy --keyboard=uhid +scrcpy -K # short version +``` + +Once the keyboard layout is configured (see above), it is the best mode for +using the keyboard while mirroring: + + - it works for all characters and IME (contrary to `--keyboard=sdk`) + - the on-screen keyboard can be disabled (contrary to `--keyboard=sdk`) + - it works over TCP/IP (wirelessly) (contrary to `--keyboard=aoa`) + - there are no issues on Windows (contrary to `--keyboard=aoa`) + +One drawback is that it may not work on old Android versions due to permission +errors. + + +### AOA + +This mode simulates a physical HID keyboard using the [AOAv2] protocol. + +[AOAv2]: https://source.android.com/devices/accessories/aoa2#hid-support + +To enable AOA keyboard, use: + +```bash +scrcpy --keyboard=aoa +``` + +Contrary to the other modes, it works at the USB level directly (so it only +works over USB). + +It does not use the scrcpy server, and does not require `adb` (USB debugging). +Therefore, it is possible to control the device (but not mirror) even with USB +debugging disabled (see [OTG](otg.md)). + +Note: On Windows, it may only work in [OTG mode](otg.md), not while mirroring +(it is not possible to open a USB device if it is already open by another +process like the _adb daemon_). diff --git a/doc/linux.md b/doc/linux.md index 68b4ee10..be433df4 100644 --- a/doc/linux.md +++ b/doc/linux.md @@ -2,21 +2,38 @@ ## Install +### From the official release + +Download a static build of the [latest release]: + + - [`scrcpy-linux-x86_64-v3.3.1.tar.gz`][direct-linux-x86_64] (x86_64) + SHA-256: `bbfe54c6b178adafeaffbbfbbc1548a74486553170c63e63bdd41863ad123422` + +[latest release]: https://github.com/Genymobile/scrcpy/releases/latest +[direct-linux-x86_64]: https://github.com/Genymobile/scrcpy/releases/download/v3.3.1/scrcpy-linux-x86_64-v3.3.1.tar.gz + +and extract it. + +_Static builds of scrcpy for Linux are still experimental._ + + +### From your package manager + Packaging status Scrcpy is packaged in several distributions and package managers: - - Debian/Ubuntu: `apt install scrcpy` + - Debian/Ubuntu: ~~`apt install scrcpy`~~ _(obsolete version)_ - Arch Linux: `pacman -S scrcpy` - Fedora: `dnf copr enable zeno/scrcpy && dnf install scrcpy` - Gentoo: `emerge scrcpy` - - Snap: `snap install scrcpy` + - Snap: ~~`snap install scrcpy`~~ _(obsolete version)_ - … (see [repology](https://repology.org/project/scrcpy/versions)) -### Latest version -However, the packaged version is not always the latest release. To install the -latest release from `master`, follow this simplified process. +### From an install script + +To install the latest release from `master`, follow this simplified process. First, you need to install the required packages: diff --git a/doc/macos.md b/doc/macos.md index 35d90e9d..f6b01c30 100644 --- a/doc/macos.md +++ b/doc/macos.md @@ -2,6 +2,27 @@ ## Install +### From the official release + +Download a static build of the [latest release]: + + - [`scrcpy-macos-aarch64-v3.3.1.tar.gz`][direct-macos-aarch64] (aarch64) + SHA-256: `907b925900ebd8499c1e47acc9689a95bd3a6f9930eb1d7bdfbca8375ae4f139` + + - [`scrcpy-macos-x86_64-v3.3.1.tar.gz`][direct-macos-x86_64] (x86_64) + SHA-256: `69772491dad718eea82fc65c8e89febff7d41c4ce6faff02f4789a588d10fd7d` + +[latest release]: https://github.com/Genymobile/scrcpy/releases/latest +[direct-macos-aarch64]: https://github.com/Genymobile/scrcpy/releases/download/v3.3.1/scrcpy-macos-aarch64-v3.3.1.tar.gz +[direct-macos-x86_64]: https://github.com/Genymobile/scrcpy/releases/download/v3.3.1/scrcpy-macos-x86_64-v3.3.1.tar.gz + +and extract it. + +_Static builds of scrcpy for macOS are still experimental._ + + +### From a package manager + Scrcpy is available in [Homebrew]: ```bash @@ -13,7 +34,7 @@ brew install scrcpy You need `adb`, accessible from your `PATH`. If you don't have it yet: ```bash -brew install android-platform-tools +brew install --cask android-platform-tools ``` Alternatively, Scrcpy is also available in [MacPorts], which sets up `adb` for you: diff --git a/doc/mouse.md b/doc/mouse.md new file mode 100644 index 00000000..0bea4aea --- /dev/null +++ b/doc/mouse.md @@ -0,0 +1,146 @@ +# Mouse + +Several mouse input modes are available: + + - `--mouse=sdk` (default) + - `--mouse=uhid` (or `-M`): simulates a physical HID mouse using the UHID + kernel module on the device + - `--mouse=aoa`: simulates a physical HID mouse using the AOAv2 protocol + - `--mouse=disabled` + + +## SDK mouse + +In this mode (`--mouse=sdk`, or if the parameter is omitted), mouse input events +are injected at the Android API level with absolute coordinates. + +Note that on some devices, an additional option must be enabled in developer +options for this mouse mode to work. See +[prerequisites](/README.md#prerequisites). + +### Mouse hover + +By default, mouse hover (mouse motion without any clicks) events are forwarded +to the device. This can be disabled with: + +``` +scrcpy --no-mouse-hover +``` + +## Physical mouse simulation + +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. + +The [shortcut mod](shortcuts.md) (either Alt or Super 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 + +This mode simulates a physical HID mouse using the [UHID] kernel module on the +device. + +[UHID]: https://kernel.org/doc/Documentation/hid/uhid.txt + +To enable UHID mouse, use: + +```bash +scrcpy --mouse=uhid +scrcpy -M # short version +``` + +Note: UHID may not work on old Android versions due to permission errors. + + +### AOA + +This mode simulates a physical HID mouse using the [AOAv2] protocol. + +[AOAv2]: https://source.android.com/devices/accessories/aoa2#hid-support + +To enable AOA mouse, use: + +```bash +scrcpy --mouse=aoa +``` + +Contrary to the other modes, it works at the USB level directly (so it only +works over USB). + +It does not use the scrcpy server, and does not require `adb` (USB debugging). +Therefore, it is possible to control the device (but not mirror) even with USB +debugging disabled (see [OTG](otg.md)). + +Note: On Windows, it may only work in [OTG mode](otg.md), not while mirroring +(it is not possible to open a USB device if it is already open by another +process like the _adb daemon_). + + +## Mouse bindings + +By default, with SDK mouse: + - right-click triggers `BACK` (or `POWER` on) + - middle-click triggers `HOME` + - the 4th click triggers `APP_SWITCH` + - the 5th click expands the notification panel + +The secondary clicks may be forwarded to the device instead by pressing the +Shift key (e.g. Shift+right-click injects a right click to +the device). + +In AOA and UHID mouse modes, the default bindings are reversed: all clicks are +forwarded by default, and pressing Shift gives access to the +shortcuts (since the cursor is handled on the device side, it makes more sense +to forward all mouse buttons by default in these modes). + +The shortcuts can be configured using `--mouse-bind=xxxx:xxxx` for any mouse +mode. The argument must be one or two sequences (separated by `:`) of exactly 4 +characters, one for each secondary click: + +``` + .---- Shift + right click + SECONDARY |.--- Shift + middle click + BINDINGS ||.-- Shift + 4th click + |||.- Shift + 5th click + |||| + vvvv +--mouse-bind=xxxx:xxxx + ^^^^ + |||| + PRIMARY ||| `- 5th click + BINDINGS || `-- 4th click + | `--- middle click + `---- right click +``` + +Each character must be one of the following: + + - `+`: forward the click to the device + - `-`: ignore the click + - `b`: trigger shortcut `BACK` (or turn screen on if off) + - `h`: trigger shortcut `HOME` + - `s`: trigger shortcut `APP_SWITCH` + - `n`: trigger shortcut "expand notification panel" + +For example: + +```bash +scrcpy --mouse-bind=bhsn:++++ # the default mode for SDK mouse +scrcpy --mouse-bind=++++:bhsn # the default mode for AOA and UHID +scrcpy --mouse-bind=++bh:++sn # forward right and middle clicks, + # use 4th and 5th for BACK and HOME, + # use Shift+4th and Shift+5th for APP_SWITCH + # and expand notification panel +``` + +The second sequence of bindings may be omitted. In that case, it is the same as +the first one: + +```bash +scrcpy --mouse-bind=bhsn +scrcpy --mouse-bind=bhsn:bhsn # equivalent +``` diff --git a/doc/otg.md b/doc/otg.md new file mode 100644 index 00000000..7d31c0a7 --- /dev/null +++ b/doc/otg.md @@ -0,0 +1,67 @@ +# OTG + +By default, _scrcpy_ injects input events at the Android API level. As an +alternative, it is possible to send HID events, so that scrcpy behaves as if it +was a [physical keyboard] and/or a [physical mouse] connected to the Android +device (see [keyboard](keyboard.md) and [mouse](mouse.md)). + +[physical keyboard]: keyboard.md#physical-keyboard-simulation +[physical mouse]: mouse.md#physical-mouse-simulation + +A special mode (OTG) allows to control the device using AOA +[keyboard](keyboard.md#aoa), [mouse](mouse.md#aoa) and +[gamepad](gamepad.md#aoa), without using _adb_ at all (so USB debugging is not +necessary). In this mode, video and audio are disabled, and `--keyboard=aoa` and +`--mouse=aoa` are implicitly set. However, gamepads are disabled by default, so +`--gamepad=aoa` (or `-G` in OTG mode) must be explicitly set. + +Therefore, it is possible to run _scrcpy_ with only physical keyboard, mouse and +gamepad simulation, as if the computer keyboard, mouse and gamepads were plugged +directly to the device via an OTG cable. + +To enable OTG mode: + +```bash +scrcpy --otg +# Pass the serial if several USB devices are available +scrcpy --otg -s 0123456789abcdef +``` + +It is possible to disable keyboard or mouse: + +```bash +scrcpy --otg --keyboard=disabled +scrcpy --otg --mouse=disabled +``` + +and to enable gamepads: + +```bash +scrcpy --otg --gamepad=aoa +scrcpy --otg -G # short version +``` + +It only works if the device is connected over USB. + +## OTG issues on Windows + +See [FAQ](/FAQ.md#otg-issues-on-windows). + + +## Control only + +Note that the purpose of OTG is to control the device without USB debugging +(adb). + +If you want to solely control the device without mirroring while USB debugging +is enabled, then OTG mode is not necessary. + +Instead, disable video and audio, and select UHID (or AOA): + +```bash +scrcpy --no-video --no-audio --keyboard=uhid --mouse=uhid --gamepad=uhid +scrcpy --no-video --no-audio -KMG # short version +scrcpy --no-video --no-audio --keyboard=aoa --mouse=aoa --gamepad=aoa +``` + +One benefit of UHID is that it also works wirelessly. diff --git a/doc/recording.md b/doc/recording.md index 216542e9..f1a5a6e7 100644 --- a/doc/recording.md +++ b/doc/recording.md @@ -58,12 +58,10 @@ orientation](video.md#orientation). ## No playback -To disable playback while recording: +To disable playback and control while recording: ```bash -scrcpy --no-playback --record=file.mp4 -scrcpy -Nr file.mkv -# interrupt recording with Ctrl+C +scrcpy --no-playback --no-control --record=file.mp4 ``` It is also possible to disable video and audio playback separately: @@ -73,6 +71,13 @@ It is also possible to disable video and audio playback separately: scrcpy --record=file.mkv --no-audio-playback ``` +To also disable the window: + +```bash +scrcpy --no-playback --no-window --record=file.mp4 +# interrupt recording with Ctrl+C +``` + ## Time limit To limit the recording time: diff --git a/doc/shortcuts.md b/doc/shortcuts.md index c0fc2842..d22eb473 100644 --- a/doc/shortcuts.md +++ b/doc/shortcuts.md @@ -13,8 +13,8 @@ It can be changed using `--shortcut-mod`. Possible keys are `lctrl`, `rctrl`, # use RCtrl for shortcuts scrcpy --shortcut-mod=rctrl -# use either LCtrl+LAlt or LSuper for shortcuts -scrcpy --shortcut-mod=lctrl+lalt,lsuper +# use either LCtrl or LSuper for shortcuts +scrcpy --shortcut-mod=lctrl,lsuper ``` _[Super] is typically the Windows or Cmd key._ @@ -28,6 +28,9 @@ _[Super] is typically the Windows or Cmd key._ | Rotate display right | MOD+ _(right)_ | Flip display horizontally | MOD+Shift+ _(left)_ \| MOD+Shift+ _(right)_ | Flip display vertically | MOD+Shift+ _(up)_ \| MOD+Shift+ _(down)_ + | Pause or re-pause display | MOD+z + | Unpause display | MOD+Shift+z + | Reset video capture/encoding | MOD+Shift+r | Resize window to 1:1 (pixel-perfect) | MOD+g | Resize window to remove black borders | MOD+w \| _Double-left-click¹_ | Click on `HOME` | MOD+h \| _Middle-click_ @@ -48,8 +51,11 @@ _[Super] is typically the Windows or Cmd key._ | Cut to clipboard⁵ | MOD+x | Synchronize clipboards and paste⁵ | MOD+v | Inject computer clipboard text | MOD+Shift+v + | Open keyboard settings (HID keyboard only) | MOD+k | Enable/disable FPS counter (on stdout) | MOD+i - | Pinch-to-zoom | Ctrl+_click-and-move_ + | Pinch-to-zoom/rotate | Ctrl+_click-and-move_ + | Tilt vertically (slide with 2 fingers) | Shift+_click-and-move_ + | Tilt horizontally (slide with 2 fingers) | Ctrl+Shift+_click-and-move_ | Drag & drop APK file | Install APK from computer | Drag & drop non-APK file | [Push file to device](control.md#push-file-to-device) diff --git a/doc/v4l2.md b/doc/v4l2.md index 23c99912..54272b2b 100644 --- a/doc/v4l2.md +++ b/doc/v4l2.md @@ -21,6 +21,13 @@ This will create a new video device in `/dev/videoN`, where `N` is an integer (more [options](https://github.com/umlaeute/v4l2loopback#options) are available to create several devices or devices with specific IDs). +If you encounter problems detecting your device with Chrome/WebRTC, you can try +`exclusive_caps` mode: + +``` +sudo modprobe v4l2loopback exclusive_caps=1 +``` + To list the enabled devices: ```bash diff --git a/doc/video.md b/doc/video.md index ed92cb22..4de6814a 100644 --- a/doc/video.md +++ b/doc/video.md @@ -27,6 +27,9 @@ preserved. That way, a device in 1920×1080 will be mirrored at 1024×576. If encoding fails, scrcpy automatically tries again with a lower definition (unless `--no-downsize-on-error` is enabled). +For camera mirroring, the `--max-size` value is used to select the camera source +size instead (among the available resolutions). + ## Bit rate @@ -93,7 +96,7 @@ Sometimes, the default encoder may have issues or even crash, so it is useful to try another one: ```bash -scrcpy --video-codec=h264 --video-encoder='OMX.qcom.video.encoder.avc' +scrcpy --video-codec=h264 --video-encoder=OMX.qcom.video.encoder.avc ``` @@ -103,24 +106,45 @@ The orientation may be applied at 3 different levels: - The [shortcut](shortcuts.md) MOD+r requests the device to switch between portrait and landscape (the current running app may refuse, if it does not support the requested orientation). - - `--lock-video-orientation` changes the mirroring orientation (the orientation + - `--capture-orientation` changes the mirroring orientation (the orientation of the video sent from the device to the computer). This affects the recording. - `--orientation` is applied on the client side, and affects display and recording. For the display, it can be changed dynamically using [shortcuts](shortcuts.md). -To lock the mirroring orientation (on the capture side): +To capture the video with a specific orientation: ```bash -scrcpy --lock-video-orientation # initial (current) orientation -scrcpy --lock-video-orientation=0 # natural orientation -scrcpy --lock-video-orientation=90 # 90° clockwise -scrcpy --lock-video-orientation=180 # 180° -scrcpy --lock-video-orientation=270 # 270° clockwise +scrcpy --capture-orientation=0 +scrcpy --capture-orientation=90 # 90° clockwise +scrcpy --capture-orientation=180 # 180° +scrcpy --capture-orientation=270 # 270° clockwise +scrcpy --capture-orientation=flip0 # hflip +scrcpy --capture-orientation=flip90 # hflip + 90° clockwise +scrcpy --capture-orientation=flip180 # hflip + 180° +scrcpy --capture-orientation=flip270 # hflip + 270° clockwise ``` -To orient the video (on the rendering side): +The capture orientation can be locked by using `@`, so that a physical device +rotation does not change the captured video orientation: + +```bash +scrcpy --capture-orientation=@ # locked to the initial orientation +scrcpy --capture-orientation=@0 # locked to 0° +scrcpy --capture-orientation=@90 # locked to 90° clockwise +scrcpy --capture-orientation=@180 # locked to 180° +scrcpy --capture-orientation=@270 # locked to 270° clockwise +scrcpy --capture-orientation=@flip0 # locked to hflip +scrcpy --capture-orientation=@flip90 # locked to hflip + 90° clockwise +scrcpy --capture-orientation=@flip180 # locked to hflip + 180° +scrcpy --capture-orientation=@flip270 # locked to hflip + 270° clockwise +``` + +The capture orientation transform is applied after `--crop`, but before +`--angle`. + +To orient the video (on the client side): ```bash scrcpy --orientation=0 @@ -141,6 +165,19 @@ to the MP4 or MKV target file. Flipping is not supported, so only the 4 first values are allowed when recording. +## Angle + +To rotate the video content by a custom angle (in degrees, clockwise): + +``` +scrcpy --angle=23 +``` + +The center of rotation is the center of the visible area. + +This transformation is applied after `--crop` and `--capture-orientation`. + + ## Crop The device screen may be cropped to mirror only part of the screen. @@ -154,7 +191,11 @@ scrcpy --crop=1224:1440:0:0 # 1224x1440 at offset (0,0) The values are expressed in the device natural orientation (portrait for a phone, landscape for a tablet). -If `--max-size` is also specified, resizing is applied after cropping. +Cropping is performed before `--capture-orientation` and `--angle`. + +For display mirroring, `--max-size` is applied after cropping. For camera, +`--max-size` is applied first (because it selects the source size rather than +resizing the content). ## Display @@ -175,6 +216,8 @@ scrcpy --list-displays A secondary display may only be controlled if the device runs at least Android 10 (otherwise it is mirrored as read-only). +It is also possible to create a [virtual display](virtual_display.md). + ## Buffering @@ -189,15 +232,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 ``` diff --git a/doc/virtual_display.md b/doc/virtual_display.md new file mode 100644 index 00000000..9f962127 --- /dev/null +++ b/doc/virtual_display.md @@ -0,0 +1,77 @@ +# 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=/240 # use the main display size and 240 dpi +``` + +The new virtual display is destroyed on exit. + +## Start app + +On some devices, a launcher is available in the virtual display. + +When no launcher is available (or if is explicitly disabled by +[`--no-vd-system-decorations`](#system-decorations)), 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 +``` + +The app may itself be a launcher. For example, to run the open source [Fossify +Launcher]: + +```bash +scrcpy --new-display=1920x1080 --no-vd-system-decorations --start-app=org.fossify.home +``` + +[Fossify Launcher]: https://f-droid.org/en/packages/org.fossify.home/ + + +## System decorations + +By default, virtual display system decorations are enabled. To disable them, use +`--no-vd-system-decorations`: + +``` +scrcpy --new-display --no-vd-system-decorations +``` + +This is useful for some devices which might display a broken UI, or to disable +any default launcher UI available in virtual displays. + +Note that if no app is started, no content will be rendered, so no video frame +will be produced at all. + + +## Destroy on close + +By default, when the virtual display is closed, the running apps are destroyed. + +To move them to the main display instead, use: + +``` +scrcpy --new-display --no-vd-destroy-content +``` + + +## Display IME policy + +By default, the virtual display IME appears on the default display. + +To make it appear on the local display, use `--display-ime-policy=local`: + +```bash +scrcpy --display-id=1 --display-ime-policy=local +scrcpy --new-display --display-ime-policy=local +``` diff --git a/doc/window.md b/doc/window.md index b5b73921..b72c716c 100644 --- a/doc/window.md +++ b/doc/window.md @@ -1,5 +1,14 @@ # Window +## Disable window + +To disable window (may be useful for recording or for playing audio only): + +```bash +scrcpy --no-window --record=file.mp4 +# Ctrl+C to interrupt +``` + ## Title By default, the window title is the device model. It can be changed: diff --git a/doc/windows.md b/doc/windows.md index bd4a69f7..8fa1921f 100644 --- a/doc/windows.md +++ b/doc/windows.md @@ -2,41 +2,45 @@ ## Install +### From the official release + Download the [latest release]: - - [`scrcpy-win64-v2.2.zip`][direct-win64] (64-bit) - SHA-256: `9f9da88ac4c8319dcb9bf852f2d9bba942bac663413383419cddf64eaa5685bd` - - [`scrcpy-win32-v2.2.zip`][direct-win32] (32-bit) - SHA-256: `cb84269fc847b8b880e320879492a1ae6c017b42175f03e199530f7a53be9d74` + - [`scrcpy-win64-v3.3.1.zip`][direct-win64] (64-bit) + SHA-256: `4fcad494772a3ae5de9a133149f8856d2fc429b41795f7cf7c754e0c1bb6fbc0` + - [`scrcpy-win32-v3.3.1.zip`][direct-win32] (32-bit) + SHA-256: `ccdf1b4f5d19dfe760446a107e55b0a010a00e097d46533a161499c9333a20a6` [latest release]: https://github.com/Genymobile/scrcpy/releases/latest -[direct-win64]: https://github.com/Genymobile/scrcpy/releases/download/v2.2/scrcpy-win64-v2.2.zip -[direct-win32]: https://github.com/Genymobile/scrcpy/releases/download/v2.2/scrcpy-win32-v2.2.zip +[direct-win64]: https://github.com/Genymobile/scrcpy/releases/download/v3.3.1/scrcpy-win64-v3.3.1.zip +[direct-win32]: https://github.com/Genymobile/scrcpy/releases/download/v3.3.1/scrcpy-win32-v3.3.1.zip and extract it. -Alternatively, you could install it from packages manager, like [Winget]: + +### From a package manager + +From [WinGet] (ADB and other dependencies will be installed alongside scrcpy): ```bash -winget install scrcpy +winget install --exact Genymobile.scrcpy ``` -or [Chocolatey]: +From [Chocolatey]: ```bash choco install scrcpy choco install adb # if you don't have it yet ``` -or [Scoop]: - +From [Scoop]: ```bash scoop install scrcpy scoop install adb # if you don't have it yet ``` -[Winget]: https://github.com/microsoft/winget-cli +[WinGet]: https://github.com/microsoft/winget-cli [Chocolatey]: https://chocolatey.org/ [Scoop]: https://scoop.sh diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e411586a..b34b7096 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +# https://gradle.org/release-checksums/ +distributionSha256Sum=d725d707bfabd4dfdc958c624003b3c80accc03f7037b5122c4b1d0ef15cecab zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/install_release.sh b/install_release.sh index adad85f7..d960932b 100755 --- a/install_release.sh +++ b/install_release.sh @@ -2,8 +2,8 @@ set -e BUILDDIR=build-auto -PREBUILT_SERVER_URL=https://github.com/Genymobile/scrcpy/releases/download/v2.2/scrcpy-server-v2.2 -PREBUILT_SERVER_SHA256=c85c4aa84305efb69115cd497a120ebdd10258993b4cf123a8245b3d99d49874 +PREBUILT_SERVER_URL=https://github.com/Genymobile/scrcpy/releases/download/v3.3.1/scrcpy-server-v3.3.1 +PREBUILT_SERVER_SHA256=a0f70b20aa4998fbf658c94118cd6c8dab6abbb0647a3bdab344d70bc1ebcbb8 echo "[scrcpy] Downloading prebuilt server..." wget "$PREBUILT_SERVER_URL" -O scrcpy-server diff --git a/meson.build b/meson.build index 43898157..d991d672 100644 --- a/meson.build +++ b/meson.build @@ -1,6 +1,6 @@ project('scrcpy', 'c', - version: '2.3', - meson_version: '>= 0.48', + version: '3.3.1', + meson_version: '>= 0.49', default_options: [ 'c_std=c11', 'warning_level=2', @@ -16,5 +16,3 @@ endif if get_option('compile_server') subdir('server') endif - -run_target('run', command: ['scripts/run-scrcpy.sh']) diff --git a/meson_options.txt b/meson_options.txt index d1030694..fd347734 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -2,7 +2,7 @@ option('compile_app', type: 'boolean', value: true, description: 'Build the clie option('compile_server', type: 'boolean', value: true, description: 'Build the server') 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('static', type: 'boolean', value: false, description: 'Use static dependencies') 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') diff --git a/release.mk b/release.mk deleted file mode 100644 index fd969e5a..00000000 --- a/release.mk +++ /dev/null @@ -1,135 +0,0 @@ -# This makefile provides recipes to build a "portable" version of scrcpy for -# Windows. -# -# Here, "portable" means that the client and server binaries are expected to be -# anywhere, but in the same directory, instead of well-defined separate -# locations (e.g. /usr/bin/scrcpy and /usr/share/scrcpy/scrcpy-server). -# -# In particular, this implies to change the location from where the client push -# the server to the device. - -.PHONY: default clean \ - test \ - build-server \ - prepare-deps \ - build-win32 build-win64 \ - dist-win32 dist-win64 \ - zip-win32 zip-win64 \ - release - -GRADLE ?= ./gradlew - -TEST_BUILD_DIR := build-test -SERVER_BUILD_DIR := build-server -WIN32_BUILD_DIR := build-win32 -WIN64_BUILD_DIR := build-win64 - -VERSION := $(shell git describe --tags --always) - -DIST := dist -WIN32_TARGET_DIR := scrcpy-win32-$(VERSION) -WIN64_TARGET_DIR := scrcpy-win64-$(VERSION) -WIN32_TARGET := $(WIN32_TARGET_DIR).zip -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)/" - -clean: - $(GRADLE) clean - rm -rf "$(DIST)" "$(TEST_BUILD_DIR)" "$(SERVER_BUILD_DIR)" \ - "$(WIN32_BUILD_DIR)" "$(WIN64_BUILD_DIR)" - -test: - [ -d "$(TEST_BUILD_DIR)" ] || ( mkdir "$(TEST_BUILD_DIR)" && \ - meson setup "$(TEST_BUILD_DIR)" -Db_sanitize=address ) - ninja -C "$(TEST_BUILD_DIR)" - $(GRADLE) -p server check - -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)" - -prepare-deps: - @app/prebuilt-deps/prepare-adb.sh - @app/prebuilt-deps/prepare-sdl.sh - @app/prebuilt-deps/prepare-ffmpeg.sh - @app/prebuilt-deps/prepare-libusb.sh - -build-win32: prepare-deps - rm -rf "$(WIN32_BUILD_DIR)" - mkdir -p "$(WIN32_BUILD_DIR)/local" - cp -r app/prebuilt-deps/data/ffmpeg-6.1-scrcpy-3/win32/. "$(WIN32_BUILD_DIR)/local/" - cp -r app/prebuilt-deps/data/SDL2-2.28.5/i686-w64-mingw32/. "$(WIN32_BUILD_DIR)/local/" - cp -r app/prebuilt-deps/data/libusb-1.0.26/libusb-MinGW-Win32/. "$(WIN32_BUILD_DIR)/local/" - meson setup "$(WIN32_BUILD_DIR)" \ - --pkg-config-path="$(WIN32_BUILD_DIR)/local/lib/pkgconfig" \ - -Dc_args="-I$(PWD)/$(WIN32_BUILD_DIR)/local/include" \ - -Dc_link_args="-L$(PWD)/$(WIN32_BUILD_DIR)/local/lib" \ - --cross-file=cross_win32.txt \ - --buildtype=release --strip -Db_lto=true \ - -Dcompile_server=false \ - -Dportable=true - ninja -C "$(WIN32_BUILD_DIR)" - -build-win64: prepare-deps - rm -rf "$(WIN64_BUILD_DIR)" - mkdir -p "$(WIN64_BUILD_DIR)/local" - cp -r app/prebuilt-deps/data/ffmpeg-6.1-scrcpy-3/win64/. "$(WIN64_BUILD_DIR)/local/" - cp -r app/prebuilt-deps/data/SDL2-2.28.5/x86_64-w64-mingw32/. "$(WIN64_BUILD_DIR)/local/" - cp -r app/prebuilt-deps/data/libusb-1.0.26/libusb-MinGW-x64/. "$(WIN64_BUILD_DIR)/local/" - meson setup "$(WIN64_BUILD_DIR)" \ - --pkg-config-path="$(WIN64_BUILD_DIR)/local/lib/pkgconfig" \ - -Dc_args="-I$(PWD)/$(WIN64_BUILD_DIR)/local/include" \ - -Dc_link_args="-L$(PWD)/$(WIN64_BUILD_DIR)/local/lib" \ - --cross-file=cross_win64.txt \ - --buildtype=release --strip -Db_lto=true \ - -Dcompile_server=false \ - -Dportable=true - ninja -C "$(WIN64_BUILD_DIR)" - -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/prebuilt-deps/data/platform-tools-34.0.5/adb.exe "$(DIST)/$(WIN32_TARGET_DIR)/" - cp app/prebuilt-deps/data/platform-tools-34.0.5/AdbWinApi.dll "$(DIST)/$(WIN32_TARGET_DIR)/" - cp app/prebuilt-deps/data/platform-tools-34.0.5/AdbWinUsbApi.dll "$(DIST)/$(WIN32_TARGET_DIR)/" - cp "$(WIN32_BUILD_DIR)"/local/bin/*.dll "$(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/prebuilt-deps/data/platform-tools-34.0.5/adb.exe "$(DIST)/$(WIN64_TARGET_DIR)/" - cp app/prebuilt-deps/data/platform-tools-34.0.5/AdbWinApi.dll "$(DIST)/$(WIN64_TARGET_DIR)/" - cp app/prebuilt-deps/data/platform-tools-34.0.5/AdbWinUsbApi.dll "$(DIST)/$(WIN64_TARGET_DIR)/" - cp "$(WIN64_BUILD_DIR)"/local/bin/*.dll "$(DIST)/$(WIN64_TARGET_DIR)/" - -zip-win32: dist-win32 - cd "$(DIST)"; \ - zip -r "$(WIN32_TARGET)" "$(WIN32_TARGET_DIR)" - -zip-win64: dist-win64 - cd "$(DIST)"; \ - zip -r "$(WIN64_TARGET)" "$(WIN64_TARGET_DIR)" diff --git a/release.sh b/release.sh deleted file mode 100755 index 51ce2e38..00000000 --- a/release.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/bash -make -f release.mk diff --git a/release/.gitignore b/release/.gitignore new file mode 100644 index 00000000..ed363cdf --- /dev/null +++ b/release/.gitignore @@ -0,0 +1,2 @@ +/work +/output diff --git a/release/build_common b/release/build_common new file mode 100644 index 00000000..199a80b6 --- /dev/null +++ b/release/build_common @@ -0,0 +1,5 @@ +# This file must be sourced from the release scripts directory +WORK_DIR="$PWD/work" +OUTPUT_DIR="$PWD/output" + +VERSION="${VERSION:-$(git describe --tags --always)}" diff --git a/release/build_linux.sh b/release/build_linux.sh new file mode 100755 index 00000000..6bca6979 --- /dev/null +++ b/release/build_linux.sh @@ -0,0 +1,43 @@ +#!/bin/bash +set -ex +cd "$(dirname ${BASH_SOURCE[0]})" +. build_common +cd .. # root project dir + +if [[ $# != 1 ]] +then + echo "Syntax: $0 " >&2 + exit 1 +fi + +ARCH="$1" +LINUX_BUILD_DIR="$WORK_DIR/build-linux-$ARCH" + +app/deps/adb_linux.sh +app/deps/sdl.sh linux native static +app/deps/dav1d.sh linux native static +app/deps/ffmpeg.sh linux native static +app/deps/libusb.sh linux native static + +DEPS_INSTALL_DIR="$PWD/app/deps/work/install/linux-native-static" +ADB_INSTALL_DIR="$PWD/app/deps/work/install/adb-linux" + +rm -rf "$LINUX_BUILD_DIR" +meson setup "$LINUX_BUILD_DIR" \ + --pkg-config-path="$DEPS_INSTALL_DIR/lib/pkgconfig" \ + -Dc_args="-I$DEPS_INSTALL_DIR/include" \ + -Dc_link_args="-L$DEPS_INSTALL_DIR/lib" \ + --buildtype=release \ + --strip \ + -Db_lto=true \ + -Dcompile_server=false \ + -Dportable=true \ + -Dstatic=true +ninja -C "$LINUX_BUILD_DIR" + +# Group intermediate outputs into a 'dist' directory +mkdir -p "$LINUX_BUILD_DIR/dist" +cp "$LINUX_BUILD_DIR"/app/scrcpy "$LINUX_BUILD_DIR/dist/" +cp app/data/icon.png "$LINUX_BUILD_DIR/dist/" +cp app/scrcpy.1 "$LINUX_BUILD_DIR/dist/" +cp -r "$ADB_INSTALL_DIR"/. "$LINUX_BUILD_DIR/dist/" diff --git a/release/build_macos.sh b/release/build_macos.sh new file mode 100755 index 00000000..8f4beb9b --- /dev/null +++ b/release/build_macos.sh @@ -0,0 +1,43 @@ +#!/bin/bash +set -ex +cd "$(dirname ${BASH_SOURCE[0]})" +. build_common +cd .. # root project dir + +if [[ $# != 1 ]] +then + echo "Syntax: $0 " >&2 + exit 1 +fi + +ARCH="$1" +MACOS_BUILD_DIR="$WORK_DIR/build-macos-$ARCH" + +app/deps/adb_macos.sh +app/deps/sdl.sh macos native static +app/deps/dav1d.sh macos native static +app/deps/ffmpeg.sh macos native static +app/deps/libusb.sh macos native static + +DEPS_INSTALL_DIR="$PWD/app/deps/work/install/macos-native-static" +ADB_INSTALL_DIR="$PWD/app/deps/work/install/adb-macos" + +rm -rf "$MACOS_BUILD_DIR" +meson setup "$MACOS_BUILD_DIR" \ + --pkg-config-path="$DEPS_INSTALL_DIR/lib/pkgconfig" \ + -Dc_args="-I$DEPS_INSTALL_DIR/include" \ + -Dc_link_args="-L$DEPS_INSTALL_DIR/lib" \ + --buildtype=release \ + --strip \ + -Db_lto=true \ + -Dcompile_server=false \ + -Dportable=true \ + -Dstatic=true +ninja -C "$MACOS_BUILD_DIR" + +# Group intermediate outputs into a 'dist' directory +mkdir -p "$MACOS_BUILD_DIR/dist" +cp "$MACOS_BUILD_DIR"/app/scrcpy "$MACOS_BUILD_DIR/dist/" +cp app/data/icon.png "$MACOS_BUILD_DIR/dist/" +cp app/scrcpy.1 "$MACOS_BUILD_DIR/dist/" +cp -r "$ADB_INSTALL_DIR"/. "$MACOS_BUILD_DIR/dist/" diff --git a/release/build_server.sh b/release/build_server.sh new file mode 100755 index 00000000..f52672de --- /dev/null +++ b/release/build_server.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -ex +cd "$(dirname ${BASH_SOURCE[0]})" +. build_common +cd .. # root project dir + +GRADLE="${GRADLE:-./gradlew}" +SERVER_BUILD_DIR="$WORK_DIR/build-server" + +rm -rf "$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" diff --git a/release/build_windows.sh b/release/build_windows.sh new file mode 100755 index 00000000..c83d2e31 --- /dev/null +++ b/release/build_windows.sh @@ -0,0 +1,53 @@ +#!/bin/bash +set -ex + +case "$1" in + 32) + WINXX=win32 + ;; + 64) + WINXX=win64 + ;; + *) + echo "ERROR: $0 must be called with one argument: 32 or 64" >&2 + exit 1 + ;; +esac + +cd "$(dirname ${BASH_SOURCE[0]})" +. build_common +cd .. # root project dir + +WINXX_BUILD_DIR="$WORK_DIR/build-$WINXX" + +app/deps/adb_windows.sh +app/deps/sdl.sh $WINXX cross shared +app/deps/dav1d.sh $WINXX cross shared +app/deps/ffmpeg.sh $WINXX cross shared +app/deps/libusb.sh $WINXX cross shared + +DEPS_INSTALL_DIR="$PWD/app/deps/work/install/$WINXX-cross-shared" +ADB_INSTALL_DIR="$PWD/app/deps/work/install/adb-windows" + +rm -rf "$WINXX_BUILD_DIR" +meson setup "$WINXX_BUILD_DIR" \ + --pkg-config-path="$DEPS_INSTALL_DIR/lib/pkgconfig" \ + -Dc_args="-I$DEPS_INSTALL_DIR/include" \ + -Dc_link_args="-L$DEPS_INSTALL_DIR/lib" \ + --cross-file=cross_$WINXX.txt \ + --buildtype=release \ + --strip \ + -Db_lto=true \ + -Dcompile_server=false \ + -Dportable=true +ninja -C "$WINXX_BUILD_DIR" + +# Group intermediate outputs into a 'dist' directory +mkdir -p "$WINXX_BUILD_DIR/dist" +cp "$WINXX_BUILD_DIR"/app/scrcpy.exe "$WINXX_BUILD_DIR/dist/" +cp app/data/scrcpy-console.bat "$WINXX_BUILD_DIR/dist/" +cp app/data/scrcpy-noconsole.vbs "$WINXX_BUILD_DIR/dist/" +cp app/data/icon.png "$WINXX_BUILD_DIR/dist/" +cp app/data/open_a_terminal_here.bat "$WINXX_BUILD_DIR/dist/" +cp "$DEPS_INSTALL_DIR"/bin/*.dll "$WINXX_BUILD_DIR/dist/" +cp -r "$ADB_INSTALL_DIR"/. "$WINXX_BUILD_DIR/dist/" diff --git a/release/generate_checksums.sh b/release/generate_checksums.sh new file mode 100755 index 00000000..2785c6c3 --- /dev/null +++ b/release/generate_checksums.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -ex +cd "$(dirname ${BASH_SOURCE[0]})" +. build_common + +cd "$OUTPUT_DIR" +sha256sum "scrcpy-server-$VERSION" \ + "scrcpy-linux-x86_64-$VERSION.tar.gz" \ + "scrcpy-win32-$VERSION.zip" \ + "scrcpy-win64-$VERSION.zip" \ + "scrcpy-macos-aarch64-$VERSION.tar.gz" \ + "scrcpy-macos-x86_64-$VERSION.tar.gz" \ + | tee SHA256SUMS.txt +echo "Release checksums generated in $PWD/SHA256SUMS.txt" diff --git a/release/package_client.sh b/release/package_client.sh new file mode 100755 index 00000000..51997e75 --- /dev/null +++ b/release/package_client.sh @@ -0,0 +1,52 @@ +#!/bin/bash +set -ex +cd "$(dirname ${BASH_SOURCE[0]})" +. build_common +cd .. # root project dir + +if [[ $# != 2 ]] +then + # : for example win64 + # : zip or tar.gz + echo "Syntax: $0 " >&2 + exit 1 +fi + +FORMAT=$2 + +if [[ "$FORMAT" != zip && "$FORMAT" != tar.gz ]] +then + echo "Invalid format (expected zip or tar.gz): $FORMAT" >&2 + exit 1 +fi + +BUILD_DIR="$WORK_DIR/build-$1" +ARCHIVE_DIR="$BUILD_DIR/release-archive" +TARGET_DIRNAME="scrcpy-$1-$VERSION" + +rm -rf "$ARCHIVE_DIR/$TARGET_DIRNAME" +mkdir -p "$ARCHIVE_DIR/$TARGET_DIRNAME" + +cp -r "$BUILD_DIR/dist/." "$ARCHIVE_DIR/$TARGET_DIRNAME/" +cp "$WORK_DIR/build-server/server/scrcpy-server" "$ARCHIVE_DIR/$TARGET_DIRNAME/" + +mkdir -p "$OUTPUT_DIR" + +cd "$ARCHIVE_DIR" +rm -f "$OUTPUT_DIR/$TARGET_DIRNAME.$FORMAT" + +case "$FORMAT" in + zip) + zip -r "$OUTPUT_DIR/$TARGET_DIRNAME.zip" "$TARGET_DIRNAME" + ;; + tar.gz) + tar cvzf "$OUTPUT_DIR/$TARGET_DIRNAME.tar.gz" "$TARGET_DIRNAME" + ;; + *) + echo "Invalid format (expected zip or tar.gz): $FORMAT" >&2 + exit 1 +esac + +rm -rf "$TARGET_DIRNAME" +cd - +echo "Generated '$OUTPUT_DIR/$TARGET_DIRNAME.$FORMAT'" diff --git a/release/package_server.sh b/release/package_server.sh new file mode 100755 index 00000000..a856cebb --- /dev/null +++ b/release/package_server.sh @@ -0,0 +1,10 @@ +#!/bin/bash +set -ex +cd "$(dirname ${BASH_SOURCE[0]})" +OUTPUT_DIR="$PWD/output" +. build_common +cd .. # root project dir + +mkdir -p "$OUTPUT_DIR" +cp "$WORK_DIR/build-server/server/scrcpy-server" "$OUTPUT_DIR/scrcpy-server-$VERSION" +echo "Generated '$OUTPUT_DIR/scrcpy-server-$VERSION'" diff --git a/release/release.sh b/release/release.sh new file mode 100755 index 00000000..ddba585b --- /dev/null +++ b/release/release.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# To customize the version name: +# VERSION=myversion ./release.sh +set -e + +cd "$(dirname ${BASH_SOURCE[0]})" +rm -rf output + +./test_server.sh +./test_client.sh + +./build_server.sh +./build_windows.sh 32 +./build_windows.sh 64 +./build_linux.sh x86_64 + +./package_server.sh +./package_client.sh win32 zip +./package_client.sh win64 zip +./package_client.sh linux-x86_64 tar.gz + +./generate_checksums.sh + +echo "Release generated in $PWD/output" diff --git a/release/test_client.sh b/release/test_client.sh new file mode 100755 index 00000000..6059541d --- /dev/null +++ b/release/test_client.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -ex +cd "$(dirname ${BASH_SOURCE[0]})" +. build_common +cd .. # root project dir + +TEST_BUILD_DIR="$WORK_DIR/build-test" + +rm -rf "$TEST_BUILD_DIR" +meson setup "$TEST_BUILD_DIR" -Dcompile_server=false \ + -Db_sanitize=address,undefined +ninja -C "$TEST_BUILD_DIR" test diff --git a/release/test_server.sh b/release/test_server.sh new file mode 100755 index 00000000..940e8c1a --- /dev/null +++ b/release/test_server.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -ex +cd "$(dirname ${BASH_SOURCE[0]})" +. build_common +cd .. # root project dir + +GRADLE="${GRADLE:-./gradlew}" + +"$GRADLE" -p server check diff --git a/scripts/run-scrcpy.sh b/scripts/run-scrcpy.sh deleted file mode 100755 index e93b639f..00000000 --- a/scripts/run-scrcpy.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/usr/bin/env bash -SCRCPY_SERVER_PATH="$MESON_BUILD_ROOT/server/scrcpy-server" "$MESON_BUILD_ROOT/app/scrcpy" diff --git a/server/build.gradle b/server/build.gradle index 45bf0cc8..31092b12 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -2,13 +2,13 @@ apply plugin: 'com.android.application' android { namespace 'com.genymobile.scrcpy' - compileSdk 34 + compileSdk 35 defaultConfig { applicationId "com.genymobile.scrcpy" minSdkVersion 21 - targetSdkVersion 34 - versionCode 20300 - versionName "2.3" + targetSdkVersion 35 + versionCode 30301 + versionName "3.3.1" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" } buildTypes { diff --git a/server/build_without_gradle.sh b/server/build_without_gradle.sh index 9f153e2a..193a9902 100755 --- a/server/build_without_gradle.sh +++ b/server/build_without_gradle.sh @@ -12,10 +12,11 @@ set -e SCRCPY_DEBUG=false -SCRCPY_VERSION_NAME=2.3 +SCRCPY_VERSION_NAME=3.3.1 -PLATFORM=${ANDROID_PLATFORM:-34} -BUILD_TOOLS=${ANDROID_BUILD_TOOLS:-34.0.0} +PLATFORM=${ANDROID_PLATFORM:-35} +BUILD_TOOLS=${ANDROID_BUILD_TOOLS:-35.0.0} +PLATFORM_TOOLS="$ANDROID_HOME/platforms/android-$PLATFORM" BUILD_TOOLS_DIR="$ANDROID_HOME/build-tools/$BUILD_TOOLS" BUILD_DIR="$(realpath ${BUILD_DIR:-build_manual})" @@ -23,7 +24,8 @@ CLASSES_DIR="$BUILD_DIR/classes" GEN_DIR="$BUILD_DIR/gen" SERVER_DIR=$(dirname "$0") SERVER_BINARY=scrcpy-server -ANDROID_JAR="$ANDROID_HOME/platforms/android-$PLATFORM/android.jar" +ANDROID_JAR="$PLATFORM_TOOLS/android.jar" +ANDROID_AIDL="$PLATFORM_TOOLS/framework.aidl" LAMBDA_JAR="$BUILD_TOOLS_DIR/core-lambda-stubs.jar" echo "Platform: android-$PLATFORM" @@ -45,19 +47,41 @@ EOF echo "Generating java from aidl..." cd "$SERVER_DIR/src/main/aidl" -"$BUILD_TOOLS_DIR/aidl" -o"$GEN_DIR" android/view/IRotationWatcher.aidl -"$BUILD_TOOLS_DIR/aidl" -o"$GEN_DIR" \ +"$BUILD_TOOLS_DIR/aidl" -o"$GEN_DIR" -I. \ android/content/IOnPrimaryClipChangedListener.aidl -"$BUILD_TOOLS_DIR/aidl" -o"$GEN_DIR" android/view/IDisplayFoldListener.aidl +"$BUILD_TOOLS_DIR/aidl" -o"$GEN_DIR" -I. -p "$ANDROID_AIDL" \ + android/view/IDisplayWindowListener.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 \ + com/genymobile/scrcpy/control/*.java \ + com/genymobile/scrcpy/device/*.java \ + com/genymobile/scrcpy/opengl/*.java \ + com/genymobile/scrcpy/util/*.java \ + com/genymobile/scrcpy/video/*.java \ + com/genymobile/scrcpy/wrappers/*.java \ +) + +CLASSES=() +for src in "${SRC[@]}" +do + CLASSES+=("${src%.java}.class") +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 \ - com/genymobile/scrcpy/*.java \ - com/genymobile/scrcpy/wrappers/*.java + ${FAKE_SRC[@]} \ + ${SRC[@]} echo "Dexing..." cd "$CLASSES_DIR" @@ -68,8 +92,7 @@ then "$BUILD_TOOLS_DIR/dx" --dex --output "$BUILD_DIR/classes.dex" \ android/view/*.class \ android/content/*.class \ - com/genymobile/scrcpy/*.class \ - com/genymobile/scrcpy/wrappers/*.class + ${CLASSES[@]} echo "Archiving..." cd "$BUILD_DIR" @@ -81,8 +104,7 @@ else --output "$BUILD_DIR/classes.zip" \ android/view/*.class \ android/content/*.class \ - com/genymobile/scrcpy/*.class \ - com/genymobile/scrcpy/wrappers/*.class + ${CLASSES[@]} cd "$BUILD_DIR" mv classes.zip "$SERVER_BINARY" diff --git a/server/meson.build b/server/meson.build index 42b97981..55828e2d 100644 --- a/server/meson.build +++ b/server/meson.build @@ -23,3 +23,9 @@ else install: true, install_dir: 'share/scrcpy') endif + +if meson.version().version_compare('>= 0.58.0') + devenv = environment() + devenv.set('SCRCPY_SERVER_PATH', meson.current_build_dir() / 'scrcpy-server') + meson.add_devenv(devenv) +endif diff --git a/server/src/main/aidl/android/view/IDisplayFoldListener.aidl b/server/src/main/aidl/android/view/IDisplayFoldListener.aidl deleted file mode 100644 index 2c91149d..00000000 --- a/server/src/main/aidl/android/view/IDisplayFoldListener.aidl +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package android.view; - -/** - * {@hide} - */ -oneway interface IDisplayFoldListener -{ - /** Called when the foldedness of a display changes */ - void onDisplayFoldChanged(int displayId, boolean folded); -} diff --git a/server/src/main/aidl/android/view/IDisplayWindowListener.aidl b/server/src/main/aidl/android/view/IDisplayWindowListener.aidl new file mode 100644 index 00000000..2b331175 --- /dev/null +++ b/server/src/main/aidl/android/view/IDisplayWindowListener.aidl @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.view; + +import android.graphics.Rect; +import android.content.res.Configuration; + +import java.util.List; + +/** + * Interface to listen for changes to display window-containers. + * + * This differs from DisplayManager's DisplayListener in a couple ways: + * - onDisplayAdded is always called after the display is actually added to the WM hierarchy. + * This corresponds to the DisplayContent and not the raw Dislay from DisplayManager. + * - onDisplayConfigurationChanged is called for all configuration changes, not just changes + * to displayinfo (eg. windowing-mode). + * + */ +oneway interface IDisplayWindowListener { + + /** + * Called when a new display is added to the WM hierarchy. The existing display ids are returned + * when this listener is registered with WM via {@link #registerDisplayWindowListener}. + */ + void onDisplayAdded(int displayId); + + /** + * Called when a display's window-container configuration has changed. + */ + void onDisplayConfigurationChanged(int displayId, in Configuration newConfig); + + /** + * Called when a display is removed from the hierarchy. + */ + void onDisplayRemoved(int displayId); + + /** + * Called when fixed rotation is started on a display. + */ + void onFixedRotationStarted(int displayId, int newRotation); + + /** + * Called when the previous fixed rotation on a display is finished. + */ + void onFixedRotationFinished(int displayId); + + /** + * Called when the keep clear ares on a display have changed. + */ + void onKeepClearAreasChanged(int displayId, in List restricted, in List unrestricted); +} diff --git a/server/src/main/aidl/android/view/IRotationWatcher.aidl b/server/src/main/aidl/android/view/IRotationWatcher.aidl deleted file mode 100644 index 2cc5e44a..00000000 --- a/server/src/main/aidl/android/view/IRotationWatcher.aidl +++ /dev/null @@ -1,25 +0,0 @@ -/* //device/java/android/android/hardware/ISensorListener.aidl -** -** Copyright 2008, The Android Open Source Project -** -** Licensed under the Apache License, Version 2.0 (the "License"); -** you may not use this file except in compliance with the License. -** You may obtain a copy of the License at -** -** http://www.apache.org/licenses/LICENSE-2.0 -** -** Unless required by applicable law or agreed to in writing, software -** distributed under the License is distributed on an "AS IS" BASIS, -** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -** See the License for the specific language governing permissions and -** limitations under the License. -*/ - -package android.view; - -/** - * {@hide} - */ -interface IRotationWatcher { - oneway void onRotationChanged(int rotation); -} diff --git a/server/src/main/java/android/content/IContentProvider.java b/server/src/main/java/android/content/IContentProvider.java new file mode 100644 index 00000000..bb907dd3 --- /dev/null +++ b/server/src/main/java/android/content/IContentProvider.java @@ -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 +} diff --git a/server/src/main/java/com/genymobile/scrcpy/AndroidVersions.java b/server/src/main/java/com/genymobile/scrcpy/AndroidVersions.java new file mode 100644 index 00000000..5303924a --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/AndroidVersions.java @@ -0,0 +1,32 @@ +package com.genymobile.scrcpy; + +import android.os.Build; + +/** + * Android version code constants, done right. + *

+ * API levels + */ +public final class AndroidVersions { + + private AndroidVersions() { + // not instantiable + } + + public static final int API_21_ANDROID_5_0 = Build.VERSION_CODES.LOLLIPOP; + public static final int API_22_ANDROID_5_1 = Build.VERSION_CODES.LOLLIPOP_MR1; + public static final int API_23_ANDROID_6_0 = Build.VERSION_CODES.M; + public static final int API_24_ANDROID_7_0 = Build.VERSION_CODES.N; + public static final int API_25_ANDROID_7_1 = Build.VERSION_CODES.N_MR1; + public static final int API_26_ANDROID_8_0 = Build.VERSION_CODES.O; + public static final int API_27_ANDROID_8_1 = Build.VERSION_CODES.O_MR1; + public static final int API_28_ANDROID_9 = Build.VERSION_CODES.P; + public static final int API_29_ANDROID_10 = Build.VERSION_CODES.Q; + public static final int API_30_ANDROID_11 = Build.VERSION_CODES.R; + public static final int API_31_ANDROID_12 = Build.VERSION_CODES.S; + public static final int API_32_ANDROID_12L = Build.VERSION_CODES.S_V2; + public static final int API_33_ANDROID_13 = Build.VERSION_CODES.TIRAMISU; + public static final int API_34_ANDROID_14 = Build.VERSION_CODES.UPSIDE_DOWN_CAKE; + public static final int API_35_ANDROID_15 = Build.VERSION_CODES.VANILLA_ICE_CREAM; + +} diff --git a/server/src/main/java/com/genymobile/scrcpy/AudioCaptureForegroundException.java b/server/src/main/java/com/genymobile/scrcpy/AudioCaptureForegroundException.java deleted file mode 100644 index baa7d846..00000000 --- a/server/src/main/java/com/genymobile/scrcpy/AudioCaptureForegroundException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.genymobile.scrcpy; - -/** - * Exception thrown if audio capture failed on Android 11 specifically because the running App (shell) was not in foreground. - */ -public class AudioCaptureForegroundException extends Exception { -} diff --git a/server/src/main/java/com/genymobile/scrcpy/AudioSource.java b/server/src/main/java/com/genymobile/scrcpy/AudioSource.java deleted file mode 100644 index 466ea297..00000000 --- a/server/src/main/java/com/genymobile/scrcpy/AudioSource.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.genymobile.scrcpy; - -import android.media.MediaRecorder; - -public enum AudioSource { - OUTPUT("output", MediaRecorder.AudioSource.REMOTE_SUBMIX), - MIC("mic", MediaRecorder.AudioSource.MIC); - - private final String name; - private final int value; - - AudioSource(String name, int value) { - this.name = name; - this.value = value; - } - - int value() { - return value; - } - - static AudioSource findByName(String name) { - for (AudioSource audioSource : AudioSource.values()) { - if (name.equals(audioSource.name)) { - return audioSource; - } - } - - return null; - } -} diff --git a/server/src/main/java/com/genymobile/scrcpy/CleanUp.java b/server/src/main/java/com/genymobile/scrcpy/CleanUp.java index b3a1aac1..77018afa 100644 --- a/server/src/main/java/com/genymobile/scrcpy/CleanUp.java +++ b/server/src/main/java/com/genymobile/scrcpy/CleanUp.java @@ -1,11 +1,19 @@ package com.genymobile.scrcpy; -import android.os.Parcel; -import android.os.Parcelable; -import android.util.Base64; +import com.genymobile.scrcpy.device.Device; +import com.genymobile.scrcpy.util.Ln; +import com.genymobile.scrcpy.util.Settings; +import com.genymobile.scrcpy.util.SettingsException; +import com.genymobile.scrcpy.wrappers.ServiceManager; + +import android.os.BatteryManager; +import android.os.Looper; +import android.system.ErrnoException; +import android.system.Os; import java.io.File; import java.io.IOException; +import java.io.OutputStream; /** * Handle the cleanup of scrcpy, even if the main process is killed. @@ -14,127 +22,154 @@ import java.io.IOException; */ public final class CleanUp { - // A simple struct to be passed from the main process to the cleanup process - public static class Config implements Parcelable { + // Dynamic options + private static final int PENDING_CHANGE_DISPLAY_POWER = 1 << 0; + private int pendingChanges; + private boolean pendingRestoreDisplayPower; - public static final Creator CREATOR = new Creator() { - @Override - public Config createFromParcel(Parcel in) { - return new Config(in); - } + private Thread thread; + private boolean interrupted; - @Override - public Config[] newArray(int size) { - return new Config[size]; + private CleanUp(Options options) { + thread = new Thread(() -> runCleanUp(options), "cleanup"); + thread.start(); + } + + public static CleanUp start(Options options) { + return new CleanUp(options); + } + + public synchronized void interrupt() { + // Do not use thread.interrupt() because only the wait() call must be interrupted, not Command.exec() + interrupted = true; + notify(); + } + + 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); + } + } + + int displayId = options.getDisplayId(); + + int restoreDisplayImePolicy = -1; + if (displayId > 0) { + int displayImePolicy = options.getDisplayImePolicy(); + if (displayImePolicy != -1) { + int currentDisplayImePolicy = ServiceManager.getWindowManager().getDisplayImePolicy(displayId); + if (currentDisplayImePolicy != displayImePolicy) { + ServiceManager.getWindowManager().setDisplayImePolicy(displayId, displayImePolicy); + restoreDisplayImePolicy = currentDisplayImePolicy; + } + } + } + + boolean powerOffScreen = options.getPowerOffScreenOnClose(); + + try { + run(displayId, restoreStayOn, disableShowTouches, powerOffScreen, restoreScreenOffTimeout, restoreDisplayImePolicy); + } catch (IOException e) { + Ln.e("Clean up I/O exception", e); + } + } + + private void run(int displayId, int restoreStayOn, boolean disableShowTouches, boolean powerOffScreen, int restoreScreenOffTimeout, + int restoreDisplayImePolicy) throws IOException { + String[] cmd = { + "app_process", + "/", + CleanUp.class.getName(), + String.valueOf(displayId), + String.valueOf(restoreStayOn), + String.valueOf(disableShowTouches), + String.valueOf(powerOffScreen), + String.valueOf(restoreScreenOffTimeout), + String.valueOf(restoreDisplayImePolicy), }; - private static final int FLAG_DISABLE_SHOW_TOUCHES = 1; - private static final int FLAG_RESTORE_NORMAL_POWER_MODE = 2; - private static final int FLAG_POWER_OFF_SCREEN = 4; - - private int displayId; - - // Restore the value (between 0 and 7), -1 to not restore - // - private int restoreStayOn = -1; - - private boolean disableShowTouches; - private boolean restoreNormalPowerMode; - private boolean powerOffScreen; - - public Config() { - // Default constructor, the fields are initialized by CleanUp.configure() - } - - protected Config(Parcel in) { - displayId = in.readInt(); - restoreStayOn = in.readInt(); - byte options = in.readByte(); - disableShowTouches = (options & FLAG_DISABLE_SHOW_TOUCHES) != 0; - restoreNormalPowerMode = (options & FLAG_RESTORE_NORMAL_POWER_MODE) != 0; - powerOffScreen = (options & FLAG_POWER_OFF_SCREEN) != 0; - } - - @Override - public void writeToParcel(Parcel dest, int flags) { - dest.writeInt(displayId); - dest.writeInt(restoreStayOn); - byte options = 0; - if (disableShowTouches) { - options |= FLAG_DISABLE_SHOW_TOUCHES; - } - if (restoreNormalPowerMode) { - options |= FLAG_RESTORE_NORMAL_POWER_MODE; - } - if (powerOffScreen) { - options |= FLAG_POWER_OFF_SCREEN; - } - dest.writeByte(options); - } - - private boolean hasWork() { - return disableShowTouches || restoreStayOn != -1 || restoreNormalPowerMode || powerOffScreen; - } - - @Override - public int describeContents() { - return 0; - } - - byte[] serialize() { - Parcel parcel = Parcel.obtain(); - writeToParcel(parcel, 0); - byte[] bytes = parcel.marshall(); - parcel.recycle(); - return bytes; - } - - static Config deserialize(byte[] bytes) { - Parcel parcel = Parcel.obtain(); - parcel.unmarshall(bytes, 0, bytes.length); - parcel.setDataPosition(0); - return CREATOR.createFromParcel(parcel); - } - - static Config fromBase64(String base64) { - byte[] bytes = Base64.decode(base64, Base64.NO_WRAP); - return deserialize(bytes); - } - - String toBase64() { - byte[] bytes = serialize(); - return Base64.encodeToString(bytes, Base64.NO_WRAP); - } - } - - private CleanUp() { - // not instantiable - } - - public static void configure(int displayId, int restoreStayOn, boolean disableShowTouches, boolean restoreNormalPowerMode, boolean powerOffScreen) - throws IOException { - Config config = new Config(); - config.displayId = displayId; - config.disableShowTouches = disableShowTouches; - config.restoreStayOn = restoreStayOn; - config.restoreNormalPowerMode = restoreNormalPowerMode; - config.powerOffScreen = powerOffScreen; - - if (config.hasWork()) { - startProcess(config); - } else { - // There is no additional clean up to do when scrcpy dies - unlinkSelf(); - } - } - - private static void startProcess(Config config) throws IOException { - String[] cmd = {"app_process", "/", CleanUp.class.getName(), config.toBase64()}; - ProcessBuilder builder = new ProcessBuilder(cmd); builder.environment().put("CLASSPATH", Server.SERVER_PATH); - builder.start(); + Process process = builder.start(); + OutputStream out = process.getOutputStream(); + + while (true) { + int localPendingChanges; + boolean localPendingRestoreDisplayPower; + synchronized (this) { + while (!interrupted && pendingChanges == 0) { + try { + wait(); + } catch (InterruptedException e) { + throw new AssertionError("Clean up thread MUST NOT be interrupted"); + } + } + if (interrupted) { + break; + } + localPendingChanges = pendingChanges; + localPendingRestoreDisplayPower = pendingRestoreDisplayPower; + pendingChanges = 0; + } + if ((localPendingChanges & PENDING_CHANGE_DISPLAY_POWER) != 0) { + out.write(localPendingRestoreDisplayPower ? 1 : 0); + out.flush(); + } + } + } + + public synchronized void setRestoreDisplayPower(boolean restoreDisplayPower) { + pendingRestoreDisplayPower = restoreDisplayPower; + pendingChanges |= PENDING_CHANGE_DISPLAY_POWER; + notify(); } public static void unlinkSelf() { @@ -145,47 +180,91 @@ public final class CleanUp { } } + @SuppressWarnings("deprecation") + private static void prepareMainLooper() { + Looper.prepareMainLooper(); + } + public static void main(String... args) { + try { + // Start a new session to avoid being terminated along with the server process on some devices + Os.setsid(); + } catch (ErrnoException e) { + Ln.e("setsid() failed", e); + } unlinkSelf(); + // Needed for workarounds + prepareMainLooper(); + + 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 restoreDisplayImePolicy = Integer.parseInt(args[5]); + + // Dynamic option + boolean restoreDisplayPower = false; + try { // Wait for the server to die - System.in.read(); + int msg; + while ((msg = System.in.read()) != -1) { + // Only restore display power + assert msg == 0 || msg == 1; + restoreDisplayPower = msg != 0; + } } catch (IOException e) { // Expected when the server is dead } Ln.i("Cleaning up"); - Config config = Config.fromBase64(args[0]); - - if (config.disableShowTouches || config.restoreStayOn != -1) { - if (config.disableShowTouches) { - Ln.i("Disabling \"show touches\""); - try { - Settings.putValue(Settings.TABLE_SYSTEM, "show_touches", "0"); - } catch (SettingsException e) { - Ln.e("Could not restore \"show_touches\"", e); - } - } - if (config.restoreStayOn != -1) { - Ln.i("Restoring \"stay awake\""); - try { - Settings.putValue(Settings.TABLE_GLOBAL, "stay_on_while_plugged_in", String.valueOf(config.restoreStayOn)); - } catch (SettingsException e) { - Ln.e("Could not restore \"stay_on_while_plugged_in\"", e); - } + if (disableShowTouches) { + Ln.i("Disabling \"show touches\""); + try { + Settings.putValue(Settings.TABLE_SYSTEM, "show_touches", "0"); + } catch (SettingsException e) { + Ln.e("Could not restore \"show_touches\"", e); } } - if (Device.isScreenOn()) { - if (config.powerOffScreen) { + if (restoreStayOn != -1) { + Ln.i("Restoring \"stay awake\""); + try { + Settings.putValue(Settings.TABLE_GLOBAL, "stay_on_while_plugged_in", String.valueOf(restoreStayOn)); + } catch (SettingsException e) { + Ln.e("Could not restore \"stay_on_while_plugged_in\"", e); + } + } + + 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 (restoreDisplayImePolicy != -1) { + Ln.i("Restoring \"display IME policy\""); + ServiceManager.getWindowManager().setDisplayImePolicy(displayId, restoreDisplayImePolicy); + } + + // Change the power of the main display when mirroring a virtual display + int targetDisplayId = displayId != Device.DISPLAY_ID_NONE ? displayId : 0; + if (Device.isScreenOn(targetDisplayId)) { + if (powerOffScreen) { Ln.i("Power off screen"); - Device.powerOffScreen(config.displayId); - } else if (config.restoreNormalPowerMode) { - Ln.i("Restoring normal power mode"); - Device.setScreenPowerMode(Device.POWER_MODE_NORMAL); + Device.powerOffScreen(targetDisplayId); + } else if (restoreDisplayPower) { + Ln.i("Restoring display power"); + Device.setDisplayPower(targetDisplayId, true); } } + + System.exit(0); } } diff --git a/server/src/main/java/com/genymobile/scrcpy/Codec.java b/server/src/main/java/com/genymobile/scrcpy/Codec.java deleted file mode 100644 index 7e905af3..00000000 --- a/server/src/main/java/com/genymobile/scrcpy/Codec.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.genymobile.scrcpy; - -public interface Codec { - - enum Type { - VIDEO, - AUDIO, - } - - Type getType(); - - int getId(); - - String getName(); - - String getMimeType(); -} diff --git a/server/src/main/java/com/genymobile/scrcpy/CodecUtils.java b/server/src/main/java/com/genymobile/scrcpy/CodecUtils.java deleted file mode 100644 index afb6f904..00000000 --- a/server/src/main/java/com/genymobile/scrcpy/CodecUtils.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.genymobile.scrcpy; - -import android.media.MediaCodecInfo; -import android.media.MediaCodecList; -import android.media.MediaFormat; - -import java.util.ArrayList; -import java.util.Arrays; -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 - } - - public static void setCodecOption(MediaFormat format, String key, Object value) { - if (value instanceof Integer) { - format.setInteger(key, (Integer) value); - } else if (value instanceof Long) { - format.setLong(key, (Long) value); - } else if (value instanceof Float) { - format.setFloat(key, (Float) value); - } else if (value instanceof String) { - format.setString(key, (String) value); - } - } - - private static MediaCodecInfo[] getEncoders(MediaCodecList codecs, String mimeType) { - List result = new ArrayList<>(); - for (MediaCodecInfo codecInfo : codecs.getCodecInfos()) { - if (codecInfo.isEncoder() && Arrays.asList(codecInfo.getSupportedTypes()).contains(mimeType)) { - result.add(codecInfo); - } - } - return result.toArray(new MediaCodecInfo[result.size()]); - } - - public static List listVideoEncoders() { - List 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 listAudioEncoders() { - List 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; - } -} diff --git a/server/src/main/java/com/genymobile/scrcpy/ControlMessageReader.java b/server/src/main/java/com/genymobile/scrcpy/ControlMessageReader.java deleted file mode 100644 index d95c36d8..00000000 --- a/server/src/main/java/com/genymobile/scrcpy/ControlMessageReader.java +++ /dev/null @@ -1,203 +0,0 @@ -package com.genymobile.scrcpy; - -import java.io.EOFException; -import java.io.IOException; -import java.io.InputStream; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; - -public class ControlMessageReader { - - static final int INJECT_KEYCODE_PAYLOAD_LENGTH = 13; - static final int INJECT_TOUCH_EVENT_PAYLOAD_LENGTH = 31; - static final int INJECT_SCROLL_EVENT_PAYLOAD_LENGTH = 20; - static final int BACK_OR_SCREEN_ON_LENGTH = 1; - static final int SET_SCREEN_POWER_MODE_PAYLOAD_LENGTH = 1; - static final int GET_CLIPBOARD_LENGTH = 1; - static final int SET_CLIPBOARD_FIXED_PAYLOAD_LENGTH = 9; - - private static final int MESSAGE_MAX_SIZE = 1 << 18; // 256k - - public static final int CLIPBOARD_TEXT_MAX_LENGTH = MESSAGE_MAX_SIZE - 14; // type: 1 byte; sequence: 8 bytes; paste flag: 1 byte; length: 4 bytes - public static final int INJECT_TEXT_MAX_LENGTH = 300; - - private final byte[] rawBuffer = new byte[MESSAGE_MAX_SIZE]; - private final ByteBuffer buffer = ByteBuffer.wrap(rawBuffer); - - public ControlMessageReader() { - // invariant: the buffer is always in "get" mode - buffer.limit(0); - } - - public boolean isFull() { - return buffer.remaining() == rawBuffer.length; - } - - public void readFrom(InputStream input) throws IOException { - if (isFull()) { - throw new IllegalStateException("Buffer full, call next() to consume"); - } - buffer.compact(); - int head = buffer.position(); - int r = input.read(rawBuffer, head, rawBuffer.length - head); - if (r == -1) { - throw new EOFException("Controller socket closed"); - } - buffer.position(head + r); - buffer.flip(); - } - - public ControlMessage next() { - if (!buffer.hasRemaining()) { - return null; - } - int savedPosition = buffer.position(); - - int type = buffer.get(); - ControlMessage msg; - switch (type) { - case ControlMessage.TYPE_INJECT_KEYCODE: - msg = parseInjectKeycode(); - break; - case ControlMessage.TYPE_INJECT_TEXT: - msg = parseInjectText(); - break; - case ControlMessage.TYPE_INJECT_TOUCH_EVENT: - msg = parseInjectTouchEvent(); - break; - case ControlMessage.TYPE_INJECT_SCROLL_EVENT: - msg = parseInjectScrollEvent(); - break; - case ControlMessage.TYPE_BACK_OR_SCREEN_ON: - msg = parseBackOrScreenOnEvent(); - break; - case ControlMessage.TYPE_GET_CLIPBOARD: - msg = parseGetClipboard(); - break; - case ControlMessage.TYPE_SET_CLIPBOARD: - msg = parseSetClipboard(); - break; - case ControlMessage.TYPE_SET_SCREEN_POWER_MODE: - msg = parseSetScreenPowerMode(); - break; - case ControlMessage.TYPE_EXPAND_NOTIFICATION_PANEL: - case ControlMessage.TYPE_EXPAND_SETTINGS_PANEL: - case ControlMessage.TYPE_COLLAPSE_PANELS: - case ControlMessage.TYPE_ROTATE_DEVICE: - msg = ControlMessage.createEmpty(type); - break; - default: - Ln.w("Unknown event type: " + type); - msg = null; - break; - } - - if (msg == null) { - // failure, reset savedPosition - buffer.position(savedPosition); - } - return msg; - } - - private ControlMessage parseInjectKeycode() { - if (buffer.remaining() < INJECT_KEYCODE_PAYLOAD_LENGTH) { - return null; - } - int action = Binary.toUnsigned(buffer.get()); - int keycode = buffer.getInt(); - int repeat = buffer.getInt(); - int metaState = buffer.getInt(); - return ControlMessage.createInjectKeycode(action, keycode, repeat, metaState); - } - - private String parseString() { - if (buffer.remaining() < 4) { - return null; - } - int len = buffer.getInt(); - if (buffer.remaining() < len) { - return null; - } - int position = buffer.position(); - // Move the buffer position to consume the text - buffer.position(position + len); - return new String(rawBuffer, position, len, StandardCharsets.UTF_8); - } - - private ControlMessage parseInjectText() { - String text = parseString(); - if (text == null) { - return null; - } - return ControlMessage.createInjectText(text); - } - - private ControlMessage parseInjectTouchEvent() { - if (buffer.remaining() < INJECT_TOUCH_EVENT_PAYLOAD_LENGTH) { - return null; - } - int action = Binary.toUnsigned(buffer.get()); - long pointerId = buffer.getLong(); - Position position = readPosition(buffer); - float pressure = Binary.u16FixedPointToFloat(buffer.getShort()); - int actionButton = buffer.getInt(); - int buttons = buffer.getInt(); - return ControlMessage.createInjectTouchEvent(action, pointerId, position, pressure, actionButton, buttons); - } - - private ControlMessage parseInjectScrollEvent() { - if (buffer.remaining() < INJECT_SCROLL_EVENT_PAYLOAD_LENGTH) { - return null; - } - Position position = readPosition(buffer); - float hScroll = Binary.i16FixedPointToFloat(buffer.getShort()); - float vScroll = Binary.i16FixedPointToFloat(buffer.getShort()); - int buttons = buffer.getInt(); - return ControlMessage.createInjectScrollEvent(position, hScroll, vScroll, buttons); - } - - private ControlMessage parseBackOrScreenOnEvent() { - if (buffer.remaining() < BACK_OR_SCREEN_ON_LENGTH) { - return null; - } - int action = Binary.toUnsigned(buffer.get()); - return ControlMessage.createBackOrScreenOn(action); - } - - private ControlMessage parseGetClipboard() { - if (buffer.remaining() < GET_CLIPBOARD_LENGTH) { - return null; - } - int copyKey = Binary.toUnsigned(buffer.get()); - return ControlMessage.createGetClipboard(copyKey); - } - - private ControlMessage parseSetClipboard() { - if (buffer.remaining() < SET_CLIPBOARD_FIXED_PAYLOAD_LENGTH) { - return null; - } - long sequence = buffer.getLong(); - boolean paste = buffer.get() != 0; - String text = parseString(); - if (text == null) { - return null; - } - return ControlMessage.createSetClipboard(sequence, text, paste); - } - - private ControlMessage parseSetScreenPowerMode() { - if (buffer.remaining() < SET_SCREEN_POWER_MODE_PAYLOAD_LENGTH) { - return null; - } - int mode = buffer.get(); - return ControlMessage.createSetScreenPowerMode(mode); - } - - private static Position readPosition(ByteBuffer buffer) { - int x = buffer.getInt(); - int y = buffer.getInt(); - int screenWidth = Binary.toUnsigned(buffer.getShort()); - int screenHeight = Binary.toUnsigned(buffer.getShort()); - return new Position(x, y, screenWidth, screenHeight); - } -} diff --git a/server/src/main/java/com/genymobile/scrcpy/Controller.java b/server/src/main/java/com/genymobile/scrcpy/Controller.java deleted file mode 100644 index 3b0e9031..00000000 --- a/server/src/main/java/com/genymobile/scrcpy/Controller.java +++ /dev/null @@ -1,413 +0,0 @@ -package com.genymobile.scrcpy; - -import com.genymobile.scrcpy.wrappers.InputManager; - -import android.os.Build; -import android.os.SystemClock; -import android.view.InputDevice; -import android.view.KeyCharacterMap; -import android.view.KeyEvent; -import android.view.MotionEvent; - -import java.io.IOException; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; - -public class Controller implements AsyncProcessor { - - private static final int DEFAULT_DEVICE_ID = 0; - - // control_msg.h values of the pointerId field in inject_touch_event message - private static final int POINTER_ID_MOUSE = -1; - private static final int POINTER_ID_VIRTUAL_MOUSE = -3; - - private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(); - - private Thread thread; - - private final Device device; - private final DesktopConnection connection; - private final DeviceMessageSender sender; - private final boolean clipboardAutosync; - private final boolean powerOn; - - private final KeyCharacterMap charMap = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD); - - 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; - - public Controller(Device device, DesktopConnection connection, boolean clipboardAutosync, boolean powerOn) { - this.device = device; - this.connection = connection; - this.clipboardAutosync = clipboardAutosync; - this.powerOn = powerOn; - initPointers(); - sender = new DeviceMessageSender(connection); - } - - private void initPointers() { - for (int i = 0; i < PointersState.MAX_POINTERS; ++i) { - MotionEvent.PointerProperties props = new MotionEvent.PointerProperties(); - props.toolType = MotionEvent.TOOL_TYPE_FINGER; - - MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords(); - coords.orientation = 0; - coords.size = 0; - - pointerProperties[i] = props; - pointerCoords[i] = coords; - } - } - - private void control() throws IOException { - // on start, power on the device - if (powerOn && !Device.isScreenOn()) { - device.pressReleaseKeycode(KeyEvent.KEYCODE_POWER, Device.INJECT_MODE_ASYNC); - - // dirty hack - // After POWER is injected, the device is powered on asynchronously. - // To turn the device screen off while mirroring, the client will send a message that - // would be handled before the device is actually powered on, so its effect would - // be "canceled" once the device is turned back on. - // Adding this delay prevents to handle the message before the device is actually - // powered on. - SystemClock.sleep(500); - } - - while (!Thread.currentThread().isInterrupted()) { - handleEvent(); - } - } - - @Override - public void start(TerminationListener listener) { - thread = new Thread(() -> { - try { - control(); - } catch (IOException e) { - // this is expected on close - } finally { - Ln.d("Controller stopped"); - listener.onTerminated(true); - } - }, "control-recv"); - thread.start(); - sender.start(); - } - - @Override - public void stop() { - if (thread != null) { - thread.interrupt(); - } - sender.stop(); - } - - @Override - public void join() throws InterruptedException { - if (thread != null) { - thread.join(); - } - sender.join(); - } - - public DeviceMessageSender getSender() { - return sender; - } - - private void handleEvent() throws IOException { - ControlMessage msg = connection.receiveControlMessage(); - switch (msg.getType()) { - case ControlMessage.TYPE_INJECT_KEYCODE: - if (device.supportsInputEvents()) { - injectKeycode(msg.getAction(), msg.getKeycode(), msg.getRepeat(), msg.getMetaState()); - } - break; - case ControlMessage.TYPE_INJECT_TEXT: - if (device.supportsInputEvents()) { - injectText(msg.getText()); - } - break; - case ControlMessage.TYPE_INJECT_TOUCH_EVENT: - if (device.supportsInputEvents()) { - injectTouch(msg.getAction(), msg.getPointerId(), msg.getPosition(), msg.getPressure(), msg.getActionButton(), msg.getButtons()); - } - break; - case ControlMessage.TYPE_INJECT_SCROLL_EVENT: - if (device.supportsInputEvents()) { - injectScroll(msg.getPosition(), msg.getHScroll(), msg.getVScroll(), msg.getButtons()); - } - break; - case ControlMessage.TYPE_BACK_OR_SCREEN_ON: - if (device.supportsInputEvents()) { - pressBackOrTurnScreenOn(msg.getAction()); - } - break; - case ControlMessage.TYPE_EXPAND_NOTIFICATION_PANEL: - Device.expandNotificationPanel(); - break; - case ControlMessage.TYPE_EXPAND_SETTINGS_PANEL: - Device.expandSettingsPanel(); - break; - case ControlMessage.TYPE_COLLAPSE_PANELS: - Device.collapsePanels(); - break; - case ControlMessage.TYPE_GET_CLIPBOARD: - getClipboard(msg.getCopyKey()); - break; - 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")); - } - } - break; - case ControlMessage.TYPE_ROTATE_DEVICE: - Device.rotateDevice(); - break; - default: - // do nothing - } - } - - 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(); - } - return device.injectKeyEvent(action, keycode, repeat, metaState, Device.INJECT_MODE_ASYNC); - } - - private boolean injectChar(char c) { - String decomposed = KeyComposition.decompose(c); - char[] chars = decomposed != null ? decomposed.toCharArray() : new char[]{c}; - KeyEvent[] events = charMap.getEvents(chars); - if (events == null) { - return false; - } - for (KeyEvent event : events) { - if (!device.injectEvent(event, Device.INJECT_MODE_ASYNC)) { - return false; - } - } - return true; - } - - private int injectText(String text) { - int successCount = 0; - for (char c : text.toCharArray()) { - if (!injectChar(c)) { - Ln.w("Could not inject char u+" + String.format("%04x", (int) c)); - continue; - } - successCount++; - } - return successCount; - } - - private boolean injectTouch(int action, long pointerId, Position position, float pressure, int actionButton, int buttons) { - long now = SystemClock.uptimeMillis(); - - Point point = device.getPhysicalPoint(position); - if (point == null) { - Ln.w("Ignore touch event, it was generated for a different device size"); - return false; - } - - int pointerIndex = pointersState.getPointerIndex(pointerId); - if (pointerIndex == -1) { - Ln.w("Too many pointers for touch event"); - return false; - } - Pointer pointer = pointersState.get(pointerIndex); - pointer.setPoint(point); - pointer.setPressure(pressure); - - int source; - if (pointerId == POINTER_ID_MOUSE || pointerId == POINTER_ID_VIRTUAL_MOUSE) { - // real mouse event (forced by the client when --forward-on-click) - pointerProperties[pointerIndex].toolType = MotionEvent.TOOL_TYPE_MOUSE; - source = InputDevice.SOURCE_MOUSE; - pointer.setUp(buttons == 0); - } else { - // POINTER_ID_GENERIC_FINGER, POINTER_ID_VIRTUAL_FINGER or real touch from device - pointerProperties[pointerIndex].toolType = MotionEvent.TOOL_TYPE_FINGER; - source = InputDevice.SOURCE_TOUCHSCREEN; - // Buttons must not be set for touch events - buttons = 0; - pointer.setUp(action == MotionEvent.ACTION_UP); - } - - int pointerCount = pointersState.update(pointerProperties, pointerCoords); - if (pointerCount == 1) { - if (action == MotionEvent.ACTION_DOWN) { - lastTouchDown = now; - } - } else { - // secondary pointers must use ACTION_POINTER_* ORed with the pointerIndex - if (action == MotionEvent.ACTION_UP) { - action = MotionEvent.ACTION_POINTER_UP | (pointerIndex << MotionEvent.ACTION_POINTER_INDEX_SHIFT); - } else if (action == MotionEvent.ACTION_DOWN) { - action = MotionEvent.ACTION_POINTER_DOWN | (pointerIndex << MotionEvent.ACTION_POINTER_INDEX_SHIFT); - } - } - - /* If the input device is a mouse (on API >= 23): - * - the first button pressed must first generate ACTION_DOWN; - * - all button pressed (including the first one) must generate ACTION_BUTTON_PRESS; - * - all button released (including the last one) must generate ACTION_BUTTON_RELEASE; - * - the last button released must in addition generate ACTION_UP. - * - * Otherwise, Chrome does not work properly: - */ - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && source == InputDevice.SOURCE_MOUSE) { - if (action == MotionEvent.ACTION_DOWN) { - if (actionButton == buttons) { - // 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)) { - return false; - } - } - - // Any button pressed: ACTION_BUTTON_PRESS - MotionEvent pressEvent = MotionEvent.obtain(lastTouchDown, now, MotionEvent.ACTION_BUTTON_PRESS, pointerCount, pointerProperties, - pointerCoords, 0, buttons, 1f, 1f, DEFAULT_DEVICE_ID, 0, source, 0); - if (!InputManager.setActionButton(pressEvent, actionButton)) { - return false; - } - if (!device.injectEvent(pressEvent, Device.INJECT_MODE_ASYNC)) { - return false; - } - - return true; - } - - if (action == MotionEvent.ACTION_UP) { - // Any button released: ACTION_BUTTON_RELEASE - MotionEvent releaseEvent = MotionEvent.obtain(lastTouchDown, now, MotionEvent.ACTION_BUTTON_RELEASE, pointerCount, pointerProperties, - pointerCoords, 0, buttons, 1f, 1f, DEFAULT_DEVICE_ID, 0, source, 0); - if (!InputManager.setActionButton(releaseEvent, actionButton)) { - return false; - } - if (!device.injectEvent(releaseEvent, Device.INJECT_MODE_ASYNC)) { - return false; - } - - if (buttons == 0) { - // 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)) { - return false; - } - } - - return true; - } - } - - 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); - } - - private boolean injectScroll(Position position, float hScroll, float vScroll, int buttons) { - long now = SystemClock.uptimeMillis(); - Point point = device.getPhysicalPoint(position); - if (point == null) { - // ignore event - return false; - } - - MotionEvent.PointerProperties props = pointerProperties[0]; - props.id = 0; - - MotionEvent.PointerCoords coords = pointerCoords[0]; - coords.x = point.getX(); - coords.y = point.getY(); - coords.setAxisValue(MotionEvent.AXIS_HSCROLL, hScroll); - coords.setAxisValue(MotionEvent.AXIS_VSCROLL, vScroll); - - 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); - } - - /** - * Schedule a call to set power mode to off after a small delay. - */ - private static void schedulePowerModeOff() { - EXECUTOR.schedule(() -> { - Ln.i("Forcing screen off"); - Device.setScreenPowerMode(Device.POWER_MODE_OFF); - }, 200, TimeUnit.MILLISECONDS); - } - - private boolean pressBackOrTurnScreenOn(int action) { - if (Device.isScreenOn()) { - return device.injectKeyEvent(action, KeyEvent.KEYCODE_BACK, 0, 0, Device.INJECT_MODE_ASYNC); - } - - // Screen is off - // Only press POWER on ACTION_DOWN - if (action != KeyEvent.ACTION_DOWN) { - // do nothing, - return true; - } - - if (keepPowerModeOff) { - schedulePowerModeOff(); - } - return device.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 >= Build.VERSION_CODES.N && device.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); - } - - // If clipboard autosync is enabled, then the device clipboard is synchronized to the computer clipboard whenever it changes, in - // particular when COPY or CUT are injected, so it should not be synchronized twice. On Android < 7, do not synchronize at all rather than - // copying an old clipboard content. - if (!clipboardAutosync) { - String clipboardText = Device.getClipboardText(); - if (clipboardText != null) { - sender.pushClipboardText(clipboardText); - } - } - } - - private boolean setClipboard(String text, boolean paste, long sequence) { - boolean ok = device.setClipboardText(text); - if (ok) { - Ln.i("Device clipboard set"); - } - - // On Android >= 7, also press the PASTE key if requested - if (paste && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && device.supportsInputEvents()) { - device.pressReleaseKeycode(KeyEvent.KEYCODE_PASTE, Device.INJECT_MODE_ASYNC); - } - - if (sequence != ControlMessage.SEQUENCE_INVALID) { - // Acknowledgement requested - sender.pushAckClipboard(sequence); - } - - return ok; - } -} diff --git a/server/src/main/java/com/genymobile/scrcpy/Device.java b/server/src/main/java/com/genymobile/scrcpy/Device.java deleted file mode 100644 index b51ad8d3..00000000 --- a/server/src/main/java/com/genymobile/scrcpy/Device.java +++ /dev/null @@ -1,375 +0,0 @@ -package com.genymobile.scrcpy; - -import com.genymobile.scrcpy.wrappers.ClipboardManager; -import com.genymobile.scrcpy.wrappers.DisplayControl; -import com.genymobile.scrcpy.wrappers.InputManager; -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.os.Build; -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; - -public final class Device { - - public static final int POWER_MODE_OFF = SurfaceControl.POWER_MODE_OFF; - public static final int POWER_MODE_NORMAL = SurfaceControl.POWER_MODE_NORMAL; - - public static final int INJECT_MODE_ASYNC = InputManager.INJECT_INPUT_EVENT_MODE_ASYNC; - public static final int INJECT_MODE_WAIT_FOR_RESULT = InputManager.INJECT_INPUT_EVENT_MODE_WAIT_FOR_RESULT; - public static final int INJECT_MODE_WAIT_FOR_FINISH = InputManager.INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH; - - public static final int LOCK_VIDEO_ORIENTATION_UNLOCKED = -1; - public static final int LOCK_VIDEO_ORIENTATION_INITIAL = -2; - - 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 Size deviceSize; - private final Rect crop; - private int maxSize; - private final int lockVideoOrientation; - - 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 >= Build.VERSION_CODES.Q) { - 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; - } - - screenInfo = ScreenInfo.computeScreenInfo(displayInfo.getRotation(), displayInfo.getSize(), options.getCrop(), - options.getMaxSize(), options.getLockVideoOrientation()); - // 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 >= Q - supportsInputEvents = displayId == 0 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q; - if (!supportsInputEvents) { - Ln.w("Input events are not supported for secondary displays before Android 10"); - } - } - - 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); - } - - public static String getDeviceName() { - return Build.MODEL; - } - - public static boolean supportsInputEvents(int displayId) { - return displayId == 0 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q; - } - - 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()"); - } - - if (displayId != 0 && !InputManager.setDisplayId(inputEvent, displayId)) { - return false; - } - - 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, - InputDevice.SOURCE_KEYBOARD); - 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 void expandNotificationPanel() { - ServiceManager.getStatusBarManager().expandNotificationsPanel(); - } - - public static void expandSettingsPanel() { - ServiceManager.getStatusBarManager().expandSettingsPanel(); - } - - public static void collapsePanels() { - ServiceManager.getStatusBarManager().collapsePanels(); - } - - public static String getClipboardText() { - ClipboardManager clipboardManager = ServiceManager.getClipboardManager(); - if (clipboardManager == null) { - return null; - } - CharSequence s = clipboardManager.getText(); - if (s == null) { - return null; - } - return s.toString(); - } - - public boolean setClipboardText(String text) { - ClipboardManager clipboardManager = ServiceManager.getClipboardManager(); - if (clipboardManager == null) { - return false; - } - - String currentClipboard = getClipboardText(); - if (currentClipboard != null && currentClipboard.equals(text)) { - // The clipboard already contains the requested text. - // Since pasting text from the computer involves setting the device clipboard, it could be set twice on a copy-paste. This would cause - // the clipboard listeners to be notified twice, and that would flood the Android keyboard clipboard history. To workaround this - // problem, do not explicitly set the clipboard text if it already contains the expected content. - return false; - } - - isSettingClipboard.set(true); - boolean ok = clipboardManager.setText(text); - isSettingClipboard.set(false); - return ok; - } - - /** - * @param mode one of the {@code POWER_MODE_*} constants - */ - public static boolean setScreenPowerMode(int mode) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - // On Android 14, these internal methods have been moved to DisplayControl - boolean useDisplayControl = - Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && !SurfaceControl.hasPhysicalDisplayIdsMethod(); - - // Change the power mode for all physical displays - long[] physicalDisplayIds = useDisplayControl ? DisplayControl.getPhysicalDisplayIds() : SurfaceControl.getPhysicalDisplayIds(); - if (physicalDisplayIds == null) { - Ln.e("Could not get physical display ids"); - return false; - } - - boolean allOk = true; - for (long physicalDisplayId : physicalDisplayIds) { - IBinder binder = useDisplayControl ? DisplayControl.getPhysicalDisplayToken( - physicalDisplayId) : SurfaceControl.getPhysicalDisplayToken(physicalDisplayId); - allOk &= SurfaceControl.setDisplayPowerMode(binder, mode); - } - return allOk; - } - - // Older Android versions, only 1 display - IBinder d = SurfaceControl.getBuiltInDisplay(); - if (d == null) { - Ln.e("Could not get built-in display"); - return false; - } - return SurfaceControl.setDisplayPowerMode(d, mode); - } - - public static boolean powerOffScreen(int displayId) { - if (!isScreenOn()) { - return true; - } - return pressReleaseKeycode(KeyEvent.KEYCODE_POWER, displayId, Device.INJECT_MODE_ASYNC); - } - - /** - * Disable auto-rotation (if enabled), set the screen rotation and re-enable auto-rotation (if it was enabled). - */ - public static void rotateDevice() { - WindowManager wm = ServiceManager.getWindowManager(); - - boolean accelerometerRotation = !wm.isRotationFrozen(); - - int currentRotation = wm.getRotation(); - int newRotation = (currentRotation & 1) ^ 1; // 0->1, 1->0, 2->1, 3->0 - String newRotationString = newRotation == 0 ? "portrait" : "landscape"; - - Ln.i("Device rotation requested: " + newRotationString); - wm.freezeRotation(newRotation); - - // restore auto-rotate if necessary - if (accelerometerRotation) { - wm.thawRotation(); - } - } -} diff --git a/server/src/main/java/com/genymobile/scrcpy/DeviceMessageSender.java b/server/src/main/java/com/genymobile/scrcpy/DeviceMessageSender.java deleted file mode 100644 index 94e842ee..00000000 --- a/server/src/main/java/com/genymobile/scrcpy/DeviceMessageSender.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.genymobile.scrcpy; - -import java.io.IOException; - -public final class DeviceMessageSender { - - private final DesktopConnection connection; - - private Thread thread; - - private String clipboardText; - - private long ack; - - public DeviceMessageSender(DesktopConnection connection) { - this.connection = connection; - } - - public synchronized void pushClipboardText(String text) { - clipboardText = text; - notify(); - } - - public synchronized void pushAckClipboard(long sequence) { - ack = sequence; - notify(); - } - - private void loop() throws IOException, InterruptedException { - while (!Thread.currentThread().isInterrupted()) { - String text; - long sequence; - synchronized (this) { - while (ack == DeviceMessage.SEQUENCE_INVALID && clipboardText == null) { - wait(); - } - text = clipboardText; - clipboardText = null; - - sequence = ack; - ack = DeviceMessage.SEQUENCE_INVALID; - } - - if (sequence != DeviceMessage.SEQUENCE_INVALID) { - DeviceMessage event = DeviceMessage.createAckClipboard(sequence); - connection.sendDeviceMessage(event); - } - if (text != null) { - DeviceMessage event = DeviceMessage.createClipboard(text); - connection.sendDeviceMessage(event); - } - } - } - - public void start() { - thread = new Thread(() -> { - try { - loop(); - } catch (IOException | InterruptedException e) { - // this is expected on close - } finally { - Ln.d("Device message sender stopped"); - } - }, "control-send"); - thread.start(); - } - - public void stop() { - if (thread != null) { - thread.interrupt(); - } - } - - public void join() throws InterruptedException { - if (thread != null) { - thread.join(); - } - } -} diff --git a/server/src/main/java/com/genymobile/scrcpy/DeviceMessageWriter.java b/server/src/main/java/com/genymobile/scrcpy/DeviceMessageWriter.java deleted file mode 100644 index bcd8d206..00000000 --- a/server/src/main/java/com/genymobile/scrcpy/DeviceMessageWriter.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.genymobile.scrcpy; - -import java.io.IOException; -import java.io.OutputStream; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; - -public class DeviceMessageWriter { - - private static final int MESSAGE_MAX_SIZE = 1 << 18; // 256k - public static final int CLIPBOARD_TEXT_MAX_LENGTH = MESSAGE_MAX_SIZE - 5; // type: 1 byte; length: 4 bytes - - private final byte[] rawBuffer = new byte[MESSAGE_MAX_SIZE]; - private final ByteBuffer buffer = ByteBuffer.wrap(rawBuffer); - - public void writeTo(DeviceMessage msg, OutputStream output) throws IOException { - buffer.clear(); - buffer.put((byte) msg.getType()); - switch (msg.getType()) { - case DeviceMessage.TYPE_CLIPBOARD: - String text = msg.getText(); - byte[] raw = text.getBytes(StandardCharsets.UTF_8); - int len = StringUtils.getUtf8TruncationIndex(raw, CLIPBOARD_TEXT_MAX_LENGTH); - buffer.putInt(len); - buffer.put(raw, 0, len); - output.write(rawBuffer, 0, buffer.position()); - break; - case DeviceMessage.TYPE_ACK_CLIPBOARD: - buffer.putLong(msg.getSequence()); - output.write(rawBuffer, 0, buffer.position()); - break; - default: - Ln.w("Unknown device message: " + msg.getType()); - break; - } - } -} diff --git a/server/src/main/java/com/genymobile/scrcpy/FakeContext.java b/server/src/main/java/com/genymobile/scrcpy/FakeContext.java index 2ea7bf4a..b43e9e1b 100644 --- a/server/src/main/java/com/genymobile/scrcpy/FakeContext.java +++ b/server/src/main/java/com/genymobile/scrcpy/FakeContext.java @@ -1,12 +1,20 @@ package com.genymobile.scrcpy; +import com.genymobile.scrcpy.wrappers.ServiceManager; + +import android.annotation.SuppressLint; import android.annotation.TargetApi; import android.content.AttributionSource; +import android.content.ClipboardManager; +import android.content.ContentResolver; import android.content.Context; import android.content.ContextWrapper; -import android.os.Build; +import android.content.IContentProvider; +import android.os.Binder; import android.os.Process; +import java.lang.reflect.Field; + public final class FakeContext extends ContextWrapper { public static final String PACKAGE_NAME = "com.android.shell"; @@ -18,6 +26,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()); } @@ -32,7 +72,7 @@ public final class FakeContext extends ContextWrapper { return PACKAGE_NAME; } - @TargetApi(Build.VERSION_CODES.S) + @TargetApi(AndroidVersions.API_31_ANDROID_12) @Override public AttributionSource getAttributionSource() { AttributionSource.Builder builder = new AttributionSource.Builder(Process.SHELL_UID); @@ -50,4 +90,30 @@ public final class FakeContext extends ContextWrapper { public Context getApplicationContext() { return this; } + + @Override + public ContentResolver getContentResolver() { + return contentResolver; + } + + @SuppressLint("SoonBlockedPrivateApi") + @Override + public Object getSystemService(String name) { + Object service = super.getSystemService(name); + if (service == null) { + return null; + } + + if (Context.CLIPBOARD_SERVICE.equals(name)) { + try { + Field field = ClipboardManager.class.getDeclaredField("mContext"); + field.setAccessible(true); + field.set(service, this); + } catch (ReflectiveOperationException e) { + throw new RuntimeException(e); + } + } + + return service; + } } diff --git a/server/src/main/java/com/genymobile/scrcpy/LogUtils.java b/server/src/main/java/com/genymobile/scrcpy/LogUtils.java deleted file mode 100644 index efa0672b..00000000 --- a/server/src/main/java/com/genymobile/scrcpy/LogUtils.java +++ /dev/null @@ -1,151 +0,0 @@ -package com.genymobile.scrcpy; - -import com.genymobile.scrcpy.wrappers.DisplayManager; -import com.genymobile.scrcpy.wrappers.ServiceManager; - -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.util.Range; - -import java.util.List; -import java.util.SortedSet; -import java.util.TreeSet; - -public final class LogUtils { - - private LogUtils() { - // not instantiable - } - - public static String buildVideoEncoderListMessage() { - StringBuilder builder = new StringBuilder("List of video encoders:"); - List 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()).append("'"); - } - } - return builder.toString(); - } - - public static String buildAudioEncoderListMessage() { - StringBuilder builder = new StringBuilder("List of audio encoders:"); - List 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()).append("'"); - } - } - return builder.toString(); - } - - public static String buildDisplayListMessage() { - StringBuilder builder = new StringBuilder("List of displays:"); - DisplayManager displayManager = ServiceManager.getDisplayManager(); - int[] displayIds = displayManager.getDisplayIds(); - if (displayIds == null || displayIds.length == 0) { - builder.append("\n (none)"); - } else { - for (int id : displayIds) { - builder.append("\n --display-id=").append(id).append(" ("); - DisplayInfo displayInfo = displayManager.getDisplayInfo(id); - if (displayInfo != null) { - Size size = displayInfo.getSize(); - builder.append(size.getWidth()).append("x").append(size.getHeight()); - } else { - builder.append("size unknown"); - } - builder.append(")"); - } - } - return builder.toString(); - } - - private static String getCameraFacingName(int facing) { - switch (facing) { - case CameraCharacteristics.LENS_FACING_FRONT: - return "front"; - case CameraCharacteristics.LENS_FACING_BACK: - return "back"; - case CameraCharacteristics.LENS_FACING_EXTERNAL: - return "external"; - default: - return "unknown"; - } - } - - public static String buildCameraListMessage(boolean includeSizes) { - StringBuilder builder = new StringBuilder("List of cameras:"); - CameraManager cameraManager = ServiceManager.getCameraManager(); - try { - String[] cameraIds = cameraManager.getCameraIdList(); - if (cameraIds == null || cameraIds.length == 0) { - builder.append("\n (none)"); - } else { - for (String id : cameraIds) { - builder.append("\n --camera-id=").append(id); - CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(id); - - int facing = characteristics.get(CameraCharacteristics.LENS_FACING); - builder.append(" (").append(getCameraFacingName(facing)).append(", "); - - Rect activeSize = characteristics.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE); - builder.append(activeSize.width()).append("x").append(activeSize.height()); - - try { - // Capture frame rates for low-FPS mode are the same for every resolution - Range[] lowFpsRanges = characteristics.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES); - SortedSet uniqueLowFps = getUniqueSet(lowFpsRanges); - builder.append(", fps=").append(uniqueLowFps); - } catch (Exception e) { - // Some devices may provide invalid ranges, causing an IllegalArgumentException "lower must be less than or equal to upper" - Ln.w("Could not get available frame rates for camera " + id, e); - } - - builder.append(')'); - - if (includeSizes) { - StreamConfigurationMap configs = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); - - android.util.Size[] sizes = configs.getOutputSizes(MediaCodec.class); - for (android.util.Size size : sizes) { - builder.append("\n - ").append(size.getWidth()).append('x').append(size.getHeight()); - } - - android.util.Size[] highSpeedSizes = configs.getHighSpeedVideoSizes(); - if (highSpeedSizes.length > 0) { - builder.append("\n High speed capture (--camera-high-speed):"); - for (android.util.Size size : highSpeedSizes) { - Range[] highFpsRanges = configs.getHighSpeedVideoFpsRanges(); - SortedSet uniqueHighFps = getUniqueSet(highFpsRanges); - builder.append("\n - ").append(size.getWidth()).append("x").append(size.getHeight()); - builder.append(" (fps=").append(uniqueHighFps).append(')'); - } - } - } - } - } - } catch (CameraAccessException e) { - builder.append("\n (access denied)"); - } - return builder.toString(); - } - - private static SortedSet getUniqueSet(Range[] ranges) { - SortedSet set = new TreeSet<>(); - for (Range range : ranges) { - set.add(range.getUpper()); - } - return set; - } -} diff --git a/server/src/main/java/com/genymobile/scrcpy/Options.java b/server/src/main/java/com/genymobile/scrcpy/Options.java index 9b1d8d8d..66bb68e8 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Options.java +++ b/server/src/main/java/com/genymobile/scrcpy/Options.java @@ -1,6 +1,21 @@ 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.Orientation; +import com.genymobile.scrcpy.device.Size; +import com.genymobile.scrcpy.util.CodecOption; +import com.genymobile.scrcpy.util.Ln; +import com.genymobile.scrcpy.video.CameraAspectRatio; +import com.genymobile.scrcpy.video.CameraFacing; +import com.genymobile.scrcpy.video.VideoCodec; +import com.genymobile.scrcpy.video.VideoSource; +import com.genymobile.scrcpy.wrappers.WindowManager; + import android.graphics.Rect; +import android.util.Pair; import java.util.List; import java.util.Locale; @@ -16,10 +31,11 @@ public class Options { private AudioCodec audioCodec = AudioCodec.OPUS; private VideoSource videoSource = VideoSource.DISPLAY; private AudioSource audioSource = AudioSource.OUTPUT; + private boolean audioDup; private int videoBitRate = 8000000; private int audioBitRate = 128000; - private int maxFps; - private int lockVideoOrientation = -1; + private float maxFps; + private float angle; private boolean tunnelForward; private Rect crop; private boolean control = true; @@ -32,6 +48,8 @@ public class Options { private boolean cameraHighSpeed; private boolean showTouches; private boolean stayAwake; + private int screenOffTimeout = -1; + private int displayImePolicy = -1; private List videoCodecOptions; private List audioCodecOptions; @@ -43,10 +61,18 @@ public class Options { private boolean cleanup = true; private boolean powerOn = true; + private NewDisplay newDisplay; + private boolean vdDestroyContent = true; + private boolean vdSystemDecorations = true; + + private Orientation.Lock captureOrientationLock = Orientation.Lock.Unlocked; + private Orientation captureOrientation = Orientation.Orient0; + 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 @@ -90,6 +116,10 @@ public class Options { return audioSource; } + public boolean getAudioDup() { + return audioDup; + } + public int getVideoBitRate() { return videoBitRate; } @@ -98,12 +128,12 @@ public class Options { return audioBitRate; } - public int getMaxFps() { + public float getMaxFps() { return maxFps; } - public int getLockVideoOrientation() { - return lockVideoOrientation; + public float getAngle() { + return angle; } public boolean isTunnelForward() { @@ -154,6 +184,14 @@ public class Options { return stayAwake; } + public int getScreenOffTimeout() { + return screenOffTimeout; + } + + public int getDisplayImePolicy() { + return displayImePolicy; + } + public List getVideoCodecOptions() { return videoCodecOptions; } @@ -190,8 +228,28 @@ public class Options { return powerOn; } + public NewDisplay getNewDisplay() { + return newDisplay; + } + + public Orientation getCaptureOrientation() { + return captureOrientation; + } + + public Orientation.Lock getCaptureOrientationLock() { + return captureOrientationLock; + } + + public boolean getVDDestroyContent() { + return vdDestroyContent; + } + + public boolean getVDSystemDecorations() { + return vdSystemDecorations; + } + public boolean getList() { - return listEncoders || listDisplays || listCameras || listCameraSizes; + return listEncoders || listDisplays || listCameras || listCameraSizes || listApps; } public boolean getListEncoders() { @@ -210,6 +268,10 @@ public class Options { return listCameraSizes; } + public boolean getListApps() { + return listApps; + } + public boolean getSendDeviceMeta() { return sendDeviceMeta; } @@ -293,6 +355,9 @@ public class Options { } options.audioSource = audioSource; break; + case "audio_dup": + options.audioDup = Boolean.parseBoolean(value); + break; case "max_size": options.maxSize = Integer.parseInt(value) & ~7; // multiple of 8 break; @@ -303,10 +368,10 @@ public class Options { options.audioBitRate = Integer.parseInt(value); break; case "max_fps": - options.maxFps = Integer.parseInt(value); + options.maxFps = parseFloat("max_fps", value); break; - case "lock_video_orientation": - options.lockVideoOrientation = Integer.parseInt(value); + case "angle": + options.angle = parseFloat("angle", value); break; case "tunnel_forward": options.tunnelForward = Boolean.parseBoolean(value); @@ -328,6 +393,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; @@ -370,6 +441,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; @@ -400,6 +474,23 @@ public class Options { case "camera_high_speed": options.cameraHighSpeed = Boolean.parseBoolean(value); break; + case "new_display": + options.newDisplay = parseNewDisplay(value); + break; + case "vd_destroy_content": + options.vdDestroyContent = Boolean.parseBoolean(value); + break; + case "vd_system_decorations": + options.vdSystemDecorations = Boolean.parseBoolean(value); + break; + case "capture_orientation": + Pair pair = parseCaptureOrientation(value); + options.captureOrientationLock = pair.first; + options.captureOrientation = pair.second; + break; + case "display_ime_policy": + options.displayImePolicy = parseDisplayImePolicy(value); + break; case "send_device_meta": options.sendDeviceMeta = Boolean.parseBoolean(value); break; @@ -427,6 +518,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; } @@ -438,8 +534,14 @@ public class Options { } int width = Integer.parseInt(tokens[0]); int height = Integer.parseInt(tokens[1]); + if (width <= 0 || height <= 0) { + throw new IllegalArgumentException("Invalid crop size: " + width + "x" + height); + } int x = Integer.parseInt(tokens[2]); int y = Integer.parseInt(tokens[3]); + if (x < 0 || y < 0) { + throw new IllegalArgumentException("Invalid crop offset: " + x + ":" + y); + } return new Rect(x, y, x + width, y + height); } @@ -451,6 +553,9 @@ public class Options { } int width = Integer.parseInt(tokens[0]); int height = Integer.parseInt(tokens[1]); + if (width <= 0 || height <= 0) { + throw new IllegalArgumentException("Invalid non-positive size dimension: \"" + size + "\""); + } return new Size(width, height); } @@ -469,4 +574,78 @@ public class Options { float floatAr = Float.parseFloat(tokens[0]); return CameraAspectRatio.fromFloat(floatAr); } + + private static float parseFloat(String key, String value) { + try { + return Float.parseFloat(value); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid float value for " + key + ": \"" + value + "\""); + } + } + + private static NewDisplay parseNewDisplay(String newDisplay) { + // Possible inputs: + // - "" (empty string) + // - "x/" + // - "x" + // - "/" + 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); + } + + private static Pair parseCaptureOrientation(String value) { + if (value.isEmpty()) { + throw new IllegalArgumentException("Empty capture orientation string"); + } + + Orientation.Lock lock; + if (value.charAt(0) == '@') { + // Consume '@' + value = value.substring(1); + if (value.isEmpty()) { + // Only '@': lock to the initial orientation (orientation is unused) + return Pair.create(Orientation.Lock.LockedInitial, Orientation.Orient0); + } + lock = Orientation.Lock.LockedValue; + } else { + lock = Orientation.Lock.Unlocked; + } + + return Pair.create(lock, Orientation.getByName(value)); + } + + private static int parseDisplayImePolicy(String value) { + switch (value) { + case "local": + return WindowManager.DISPLAY_IME_POLICY_LOCAL; + case "fallback": + return WindowManager.DISPLAY_IME_POLICY_FALLBACK_DISPLAY; + case "hide": + return WindowManager.DISPLAY_IME_POLICY_HIDE; + default: + throw new IllegalArgumentException("Invalid display IME policy: " + value); + } + } } diff --git a/server/src/main/java/com/genymobile/scrcpy/ScreenCapture.java b/server/src/main/java/com/genymobile/scrcpy/ScreenCapture.java deleted file mode 100644 index e048354a..00000000 --- a/server/src/main/java/com/genymobile/scrcpy/ScreenCapture.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.genymobile.scrcpy; - -import com.genymobile.scrcpy.wrappers.SurfaceControl; - -import android.graphics.Rect; -import android.os.Build; -import android.os.IBinder; -import android.view.Surface; - -public class ScreenCapture extends SurfaceCapture implements Device.RotationListener, Device.FoldListener { - - private final Device device; - private IBinder display; - - public ScreenCapture(Device device) { - this.device = device; - } - - @Override - public void init() { - device.setRotationListener(this); - device.setFoldListener(this); - } - - @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 = createDisplay(); - setDisplaySurface(display, surface, videoRotation, contentRect, unlockedVideoRect, layerStack); - } - - @Override - public void release() { - device.setRotationListener(null); - device.setFoldListener(null); - if (display != null) { - SurfaceControl.destroyDisplay(display); - } - } - - @Override - public Size getSize() { - return device.getScreenInfo().getVideoSize(); - } - - @Override - public boolean setMaxSize(int maxSize) { - device.setMaxSize(maxSize); - return true; - } - - @Override - public void onFoldChanged(int displayId, boolean folded) { - requestReset(); - } - - @Override - public void onRotationChanged(int rotation) { - requestReset(); - } - - private static IBinder createDisplay() { - // 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". - boolean secure = Build.VERSION.SDK_INT < Build.VERSION_CODES.R || (Build.VERSION.SDK_INT == Build.VERSION_CODES.R && !"S".equals( - Build.VERSION.CODENAME)); - return SurfaceControl.createDisplay("scrcpy", secure); - } - - private static void setDisplaySurface(IBinder display, Surface surface, int orientation, Rect deviceRect, Rect displayRect, int layerStack) { - SurfaceControl.openTransaction(); - try { - SurfaceControl.setDisplaySurface(display, surface); - SurfaceControl.setDisplayProjection(display, orientation, deviceRect, displayRect); - SurfaceControl.setDisplayLayerStack(display, layerStack); - } finally { - SurfaceControl.closeTransaction(); - } - } -} diff --git a/server/src/main/java/com/genymobile/scrcpy/ScreenInfo.java b/server/src/main/java/com/genymobile/scrcpy/ScreenInfo.java deleted file mode 100644 index 8e5b401f..00000000 --- a/server/src/main/java/com/genymobile/scrcpy/ScreenInfo.java +++ /dev/null @@ -1,166 +0,0 @@ -package com.genymobile.scrcpy; - -import android.graphics.Rect; - -public final class ScreenInfo { - /** - * Device (physical) size, possibly cropped - */ - private final Rect contentRect; // device size, possibly cropped - - /** - * Video size, possibly smaller than the device size, already taking the device rotation and crop into account. - *

- * However, it does not include the locked video orientation. - */ - private final Size unlockedVideoSize; - - /** - * Device rotation, related to the natural device orientation (0, 1, 2 or 3) - */ - private final int deviceRotation; - - /** - * The locked video orientation (-1: disabled, 0: normal, 1: 90° CCW, 2: 180°, 3: 90° CW) - */ - private final int lockedVideoOrientation; - - public ScreenInfo(Rect contentRect, Size unlockedVideoSize, int deviceRotation, int lockedVideoOrientation) { - this.contentRect = contentRect; - this.unlockedVideoSize = unlockedVideoSize; - this.deviceRotation = deviceRotation; - this.lockedVideoOrientation = lockedVideoOrientation; - } - - public Rect getContentRect() { - return contentRect; - } - - /** - * Return the video size as if locked video orientation was not set. - * - * @return the unlocked video size - */ - public Size getUnlockedVideoSize() { - return unlockedVideoSize; - } - - /** - * Return the actual video size if locked video orientation is set. - * - * @return the actual video size - */ - public Size getVideoSize() { - if (getVideoRotation() % 2 == 0) { - return unlockedVideoSize; - } - - 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) { - // The user requested to lock the video orientation to the current orientation - lockedVideoOrientation = rotation; - } - - Rect contentRect = new Rect(0, 0, deviceSize.getWidth(), deviceSize.getHeight()); - if (crop != null) { - if (rotation % 2 != 0) { // 180s preserve dimensions - // the crop (provided by the user) is expressed in the natural orientation - crop = flipRect(crop); - } - if (!contentRect.intersect(crop)) { - // intersect() changes contentRect so that it is intersected with crop - Ln.w("Crop rectangle (" + formatCrop(crop) + ") does not intersect device screen (" + formatCrop(deviceSize.toRect()) + ")"); - contentRect = new Rect(); // empty - } - } - - Size videoSize = computeVideoSize(contentRect.width(), contentRect.height(), maxSize); - return new ScreenInfo(contentRect, videoSize, rotation, lockedVideoOrientation); - } - - private static String formatCrop(Rect rect) { - return rect.width() + ":" + rect.height() + ":" + rect.left + ":" + rect.top; - } - - private 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); - // - scale down the other side so that the aspect ratio is preserved; - // - round this value to the nearest multiple of 8 (H.264 only accepts multiples of 8) - w &= ~7; // in case it's not a multiple of 8 - h &= ~7; - if (maxSize > 0) { - if (BuildConfig.DEBUG && maxSize % 8 != 0) { - throw new AssertionError("Max size must be a multiple of 8"); - } - boolean portrait = h > w; - int major = portrait ? h : w; - int minor = portrait ? w : h; - if (major > maxSize) { - int minorExact = minor * maxSize / major; - // +4 to round the value to the nearest multiple of 8 - minor = (minorExact + 4) & ~7; - major = maxSize; - } - w = portrait ? minor : major; - h = portrait ? major : minor; - } - return new Size(w, h); - } - - private static Rect flipRect(Rect crop) { - return new Rect(crop.top, crop.left, crop.bottom, crop.right); - } - - /** - * Return the rotation to apply to the device rotation to get the requested locked video orientation - * - * @return the rotation offset - */ - public int getVideoRotation() { - if (lockedVideoOrientation == -1) { - // no offset - return 0; - } - return (deviceRotation + 4 - lockedVideoOrientation) % 4; - } - - /** - * Return the rotation to apply to the requested locked video orientation to get the device rotation - * - * @return the (reverse) rotation offset - */ - public int getReverseVideoRotation() { - if (lockedVideoOrientation == -1) { - // no offset - return 0; - } - return (lockedVideoOrientation + 4 - deviceRotation) % 4; - } -} diff --git a/server/src/main/java/com/genymobile/scrcpy/Server.java b/server/src/main/java/com/genymobile/scrcpy/Server.java index e4a95140..a08c948c 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Server.java +++ b/server/src/main/java/com/genymobile/scrcpy/Server.java @@ -1,10 +1,36 @@ package com.genymobile.scrcpy; -import android.os.BatteryManager; +import com.genymobile.scrcpy.audio.AudioCapture; +import com.genymobile.scrcpy.audio.AudioCodec; +import com.genymobile.scrcpy.audio.AudioDirectCapture; +import com.genymobile.scrcpy.audio.AudioEncoder; +import com.genymobile.scrcpy.audio.AudioPlaybackCapture; +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.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.opengl.OpenGLRunner; +import com.genymobile.scrcpy.util.Ln; +import com.genymobile.scrcpy.util.LogUtils; +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.annotation.SuppressLint; import android.os.Build; +import android.os.Looper; import java.io.File; import java.io.IOException; +import java.lang.reflect.Field; import java.util.ArrayList; import java.util.List; @@ -32,17 +58,7 @@ public final class Server { this.fatalError = true; } if (running == 0 || this.fatalError) { - notify(); - } - } - - synchronized void await() { - try { - while (running > 0 && !fatalError) { - wait(); - } - } catch (InterruptedException e) { - // ignore + Looper.getMainLooper().quitSafely(); } } } @@ -51,57 +67,28 @@ public final class Server { // not instantiable } - private static void initAndCleanUp(Options options) { - boolean mustDisableShowTouchesOnCleanUp = false; - int restoreStayOn = -1; - boolean restoreNormalPowerMode = options.getControl(); // only restore power mode if control is enabled - if (options.getShowTouches() || options.getStayAwake()) { - 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 - mustDisableShowTouchesOnCleanUp = !"1".equals(oldValue); - } 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 { - restoreStayOn = Integer.parseInt(oldValue); - if (restoreStayOn == stayOn) { - // No need to restore - restoreStayOn = -1; - } - } catch (NumberFormatException e) { - restoreStayOn = 0; - } - } catch (SettingsException e) { - Ln.e("Could not change \"stay_on_while_plugged_in\"", e); - } - } - } - - if (options.getCleanup()) { - try { - CleanUp.configure(options.getDisplayId(), restoreStayOn, mustDisableShowTouchesOnCleanUp, restoreNormalPowerMode, - options.getPowerOffScreenOnClose()); - } catch (IOException e) { - Ln.e("Could not configure cleanup", e); - } - } - } - private static void scrcpy(Options options) throws IOException, ConfigurationException { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S && options.getVideoSource() == VideoSource.CAMERA) { + 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"); } - Thread initThread = startInitThread(options); + if (Build.VERSION.SDK_INT < AndroidVersions.API_29_ANDROID_10) { + if (options.getNewDisplay() != null) { + Ln.e("New virtual display is not supported before Android 10"); + throw new ConfigurationException("New virtual display is not supported"); + } + if (options.getDisplayImePolicy() != -1) { + Ln.e("Display IME policy is not supported before Android 10"); + throw new ConfigurationException("Display IME policy is not supported"); + } + } + + CleanUp cleanUp = null; + + if (options.getCleanup()) { + cleanUp = CleanUp.start(options); + } int scid = options.getScid(); boolean tunnelForward = options.isTunnelForward(); @@ -109,11 +96,8 @@ 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(audio, camera); + Workarounds.apply(); List asyncProcessors = new ArrayList<>(); @@ -123,22 +107,30 @@ public final class Server { connection.sendDeviceMeta(Device.getDeviceName()); } + Controller controller = null; + if (control) { - Controller controller = new Controller(device, connection, options.getClipboardAutosync(), options.getPowerOn()); - device.setClipboardListener(text -> controller.getSender().pushClipboardText(text)); + ControlChannel controlChannel = connection.getControlChannel(); + controller = new Controller(controlChannel, cleanUp, options); asyncProcessors.add(controller); } if (audio) { AudioCodec audioCodec = options.getAudioCodec(); - AudioCapture audioCapture = new AudioCapture(options.getAudioSource()); + AudioSource audioSource = options.getAudioSource(); + AudioCapture audioCapture; + if (audioSource.isDirect()) { + audioCapture = new AudioDirectCapture(audioSource); + } else { + audioCapture = new AudioPlaybackCapture(options.getAudioDup()); + } + Streamer audioStreamer = new Streamer(connection.getAudioFd(), audioCodec, options.getSendCodecMeta(), options.getSendFrameMeta()); AsyncProcessor audioRecorder; 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); } @@ -148,14 +140,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()); @@ -165,20 +165,27 @@ public final class Server { }); } - completion.await(); + Looper.loop(); // interrupted by the Completion implementation } finally { - initThread.interrupt(); + if (cleanUp != null) { + cleanUp.interrupt(); + } for (AsyncProcessor asyncProcessor : asyncProcessors) { asyncProcessor.stop(); } + OpenGLRunner.quit(); // quit the OpenGL thread, if any + connection.shutdown(); try { - initThread.join(); + if (cleanUp != null) { + cleanUp.join(); + } for (AsyncProcessor asyncProcessor : asyncProcessors) { asyncProcessor.join(); } + OpenGLRunner.join(); } catch (InterruptedException e) { // ignore } @@ -187,10 +194,19 @@ public final class Server { } } - private static Thread startInitThread(final Options options) { - Thread thread = new Thread(() -> initAndCleanUp(options), "init-cleanup"); - thread.start(); - return thread; + private static void prepareMainLooper() { + // Like Looper.prepareMainLooper(), but with quitAllowed set to true + Looper.prepare(); + synchronized (Looper.class) { + try { + @SuppressLint("DiscouragedPrivateApi") + Field field = Looper.class.getDeclaredField("sMainLooper"); + field.setAccessible(true); + field.set(null, Looper.myLooper()); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } } public static void main(String... args) { @@ -213,6 +229,8 @@ public final class Server { Ln.e("Exception on thread " + t, e); }); + prepareMainLooper(); + Options options = Options.parse(args); Ln.disableSystemStreams(); @@ -233,9 +251,14 @@ public final class Server { Ln.i(LogUtils.buildDisplayListMessage()); } if (options.getListCameras() || options.getListCameraSizes()) { - Workarounds.apply(false, true); + 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; } diff --git a/server/src/main/java/com/genymobile/scrcpy/Size.java b/server/src/main/java/com/genymobile/scrcpy/Size.java deleted file mode 100644 index fd4b6971..00000000 --- a/server/src/main/java/com/genymobile/scrcpy/Size.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.genymobile.scrcpy; - -import android.graphics.Rect; - -import java.util.Objects; - -public final class Size { - private final int width; - private final int height; - - public Size(int width, int height) { - this.width = width; - this.height = height; - } - - public int getWidth() { - return width; - } - - public int getHeight() { - return height; - } - - public Size rotate() { - return new Size(height, width); - } - - public Rect toRect() { - return new Rect(0, 0, width, height); - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - Size size = (Size) o; - return width == size.width && height == size.height; - } - - @Override - public int hashCode() { - return Objects.hash(width, height); - } - - @Override - public String toString() { - return "Size{" + "width=" + width + ", height=" + height + '}'; - } -} diff --git a/server/src/main/java/com/genymobile/scrcpy/SurfaceCapture.java b/server/src/main/java/com/genymobile/scrcpy/SurfaceCapture.java deleted file mode 100644 index e300e4d6..00000000 --- a/server/src/main/java/com/genymobile/scrcpy/SurfaceCapture.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.genymobile.scrcpy; - -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(); - - /** - * 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). - */ - protected void requestReset() { - resetCapture.set(true); - } - - /** - * Consume the reset request (intended to be called by the encoder). - * - * @return {@code true} if a reset request was pending, {@code false} otherwise. - */ - public boolean consumeReset() { - return resetCapture.getAndSet(false); - } - - /** - * Called once before the capture starts. - */ - public abstract void init() throws IOException; - - /** - * Called after the capture ends (if and only if {@link #init()} has been called). - */ - public abstract void release(); - - /** - * Start the capture to the target surface. - * - * @param surface the surface which will be encoded - */ - public abstract void start(Surface surface) throws IOException; - - /** - * Return the video size - * - * @return the video size - */ - public abstract Size getSize(); - - /** - * Set the maximum capture size (set by the encoder if it does not support the current size). - * - * @param maxSize Maximum size - */ - public abstract boolean setMaxSize(int maxSize); - - /** - * Indicate if the capture has been closed internally. - * - * @return {@code true} is the capture is closed, {@code false} otherwise. - */ - public boolean isClosed() { - return false; - } -} diff --git a/server/src/main/java/com/genymobile/scrcpy/Workarounds.java b/server/src/main/java/com/genymobile/scrcpy/Workarounds.java index db9c9629..b89f19ae 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Workarounds.java +++ b/server/src/main/java/com/genymobile/scrcpy/Workarounds.java @@ -1,5 +1,8 @@ package com.genymobile.scrcpy; +import com.genymobile.scrcpy.audio.AudioCaptureException; +import com.genymobile.scrcpy.util.Ln; + import android.annotation.SuppressLint; import android.annotation.TargetApi; import android.app.Application; @@ -26,8 +29,6 @@ public final class Workarounds { private static final Object ACTIVITY_THREAD; static { - prepareMainLooper(); - try { // ActivityThread activityThread = new ActivityThread(); ACTIVITY_THREAD_CLASS = Class.forName("android.app.ActivityThread"); @@ -39,6 +40,11 @@ public final class Workarounds { Field sCurrentActivityThreadField = ACTIVITY_THREAD_CLASS.getDeclaredField("sCurrentActivityThread"); sCurrentActivityThreadField.setAccessible(true); sCurrentActivityThreadField.set(null, ACTIVITY_THREAD); + + // activityThread.mSystemThread = true; + Field mSystemThreadField = ACTIVITY_THREAD_CLASS.getDeclaredField("mSystemThread"); + mSystemThreadField.setAccessible(true); + mSystemThreadField.setBoolean(ACTIVITY_THREAD, true); } catch (Exception e) { throw new AssertionError(e); } @@ -48,62 +54,25 @@ public final class Workarounds { // not instantiable } - public static void apply(boolean audio, boolean camera) { - boolean mustFillAppInfo = false; - boolean mustFillAppContext = false; - - if (Build.BRAND.equalsIgnoreCase("meizu")) { - // Workarounds must be applied for Meizu phones: - // - - // - - // - - // - // But only apply when strictly necessary, since workarounds can cause other issues: - // - - // - - mustFillAppInfo = true; - } else if (Build.BRAND.equalsIgnoreCase("honor")) { - // More workarounds must be applied for Honor devices: - // - - // - // The system context must not be set for all devices, because it would cause other problems: - // - - // - - mustFillAppInfo = true; - mustFillAppContext = true; + public static void apply() { + if (Build.VERSION.SDK_INT >= AndroidVersions.API_31_ANDROID_12) { + // On some Samsung devices, DisplayManagerGlobal.getDisplayInfoLocked() calls ActivityThread.currentActivityThread().getConfiguration(), + // which requires a non-null ConfigurationController. + // ConfigurationController was introduced in Android 12, so do not attempt to set it on lower versions. + // + // Must be called before fillAppContext() because it is necessary to get a valid system context. + fillConfigurationController(); } - if (audio && Build.VERSION.SDK_INT == Build.VERSION_CODES.R) { - // Before Android 11, audio is not supported. - // Since Android 12, we can properly set a context on the AudioRecord. - // Only on Android 11 we must fill the application context for the AudioRecord to work. - mustFillAppContext = true; - } - - if (camera) { - mustFillAppInfo = true; - mustFillAppContext = true; - } + // On ONYX devices, fillAppInfo() breaks video mirroring: + // + boolean mustFillAppInfo = !Build.BRAND.equalsIgnoreCase("ONYX"); if (mustFillAppInfo) { - Workarounds.fillAppInfo(); + fillAppInfo(); } - if (mustFillAppContext) { - Workarounds.fillAppContext(); - } - } - @SuppressWarnings("deprecation") - private static void prepareMainLooper() { - // Some devices internally create a Handler when creating an input Surface, causing an exception: - // "Can't create handler inside thread that has not called Looper.prepare()" - // - // - // Use Looper.prepareMainLooper() instead of Looper.prepare() to avoid a NullPointerException: - // "Attempt to read from field 'android.os.MessageQueue android.os.Looper.mQueue' - // on a null object reference" - // - Looper.prepareMainLooper(); + fillAppContext(); } private static void fillAppInfo() { @@ -149,6 +118,25 @@ public final class Workarounds { } } + private static void fillConfigurationController() { + try { + Class configurationControllerClass = Class.forName("android.app.ConfigurationController"); + Class activityThreadInternalClass = Class.forName("android.app.ActivityThreadInternal"); + + // configurationController = new ConfigurationController(ACTIVITY_THREAD); + Constructor configurationControllerConstructor = configurationControllerClass.getDeclaredConstructor(activityThreadInternalClass); + configurationControllerConstructor.setAccessible(true); + Object configurationController = configurationControllerConstructor.newInstance(ACTIVITY_THREAD); + + // ACTIVITY_THREAD.mConfigurationController = configurationController; + Field configurationControllerField = ACTIVITY_THREAD_CLASS.getDeclaredField("mConfigurationController"); + configurationControllerField.setAccessible(true); + configurationControllerField.set(ACTIVITY_THREAD, configurationController); + } catch (Throwable throwable) { + Ln.d("Could not fill configuration: " + throwable.getMessage()); + } + } + static Context getSystemContext() { try { Method getSystemContextMethod = ACTIVITY_THREAD_CLASS.getDeclaredMethod("getSystemContext"); @@ -160,9 +148,10 @@ public final class Workarounds { } } - @TargetApi(Build.VERSION_CODES.R) + @TargetApi(AndroidVersions.API_30_ANDROID_11) @SuppressLint("WrongConstant,MissingPermission") - public static AudioRecord createAudioRecord(int source, int sampleRate, int channelConfig, int channels, int channelMask, int encoding) { + public static AudioRecord createAudioRecord(int source, int sampleRate, int channelConfig, int channels, int channelMask, int encoding) throws + AudioCaptureException { // Vivo (and maybe some other third-party ROMs) modified `AudioRecord`'s constructor, requiring `Context`s from real App environment. // // This method invokes the `AudioRecord(long nativeRecordInJavaObj)` constructor to create an empty `AudioRecord` instance, then uses @@ -230,7 +219,7 @@ public final class Workarounds { int[] session = new int[]{AudioManager.AUDIO_SESSION_ID_GENERATE}; int initResult; - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + if (Build.VERSION.SDK_INT < AndroidVersions.API_31_ANDROID_12) { // private native final int native_setup(Object audiorecord_this, // Object /*AudioAttributes*/ attributes, // int[] sampleRate, int channelMask, int channelIndexMask, int audioFormat, @@ -256,16 +245,28 @@ public final class Workarounds { Method getParcelMethod = attributionSourceState.getClass().getDeclaredMethod("getParcel"); Parcel attributionSourceParcel = (Parcel) getParcelMethod.invoke(attributionSourceState); - // private native int native_setup(Object audiorecordThis, - // Object /*AudioAttributes*/ attributes, - // int[] sampleRate, int channelMask, int channelIndexMask, int audioFormat, - // int buffSizeInBytes, int[] sessionId, @NonNull Parcel attributionSource, - // long nativeRecordInJavaObj, int maxSharedAudioHistoryMs); - Method nativeSetupMethod = AudioRecord.class.getDeclaredMethod("native_setup", Object.class, Object.class, int[].class, int.class, - int.class, int.class, int.class, int[].class, Parcel.class, long.class, int.class); - nativeSetupMethod.setAccessible(true); - initResult = (int) nativeSetupMethod.invoke(audioRecord, new WeakReference(audioRecord), attributes, sampleRateArray, - channelMask, channelIndexMask, audioRecord.getAudioFormat(), bufferSizeInBytes, session, attributionSourceParcel, 0L, 0); + if (Build.VERSION.SDK_INT < AndroidVersions.API_34_ANDROID_14) { + // private native int native_setup(Object audiorecordThis, + // Object /*AudioAttributes*/ attributes, + // int[] sampleRate, int channelMask, int channelIndexMask, int audioFormat, + // int buffSizeInBytes, int[] sessionId, @NonNull Parcel attributionSource, + // long nativeRecordInJavaObj, int maxSharedAudioHistoryMs); + Method nativeSetupMethod = AudioRecord.class.getDeclaredMethod("native_setup", Object.class, Object.class, int[].class, + int.class, int.class, int.class, int.class, int[].class, Parcel.class, long.class, int.class); + nativeSetupMethod.setAccessible(true); + initResult = (int) nativeSetupMethod.invoke(audioRecord, new WeakReference(audioRecord), attributes, + sampleRateArray, channelMask, channelIndexMask, audioRecord.getAudioFormat(), bufferSizeInBytes, session, + attributionSourceParcel, 0L, 0); + } else { + // Android 14 added a new int parameter "halInputFlags" + // + Method nativeSetupMethod = AudioRecord.class.getDeclaredMethod("native_setup", Object.class, Object.class, int[].class, + int.class, int.class, int.class, int.class, int[].class, Parcel.class, long.class, int.class, int.class); + nativeSetupMethod.setAccessible(true); + initResult = (int) nativeSetupMethod.invoke(audioRecord, new WeakReference(audioRecord), attributes, + sampleRateArray, channelMask, channelIndexMask, audioRecord.getAudioFormat(), bufferSizeInBytes, session, + attributionSourceParcel, 0L, 0, 0); + } } } @@ -291,8 +292,8 @@ public final class Workarounds { return audioRecord; } catch (Exception e) { - Ln.e("Failed to invoke AudioRecord..", e); - throw new RuntimeException("Cannot create AudioRecord"); + Ln.e("Cannot create AudioRecord", e); + throw new AudioCaptureException(); } } } diff --git a/server/src/main/java/com/genymobile/scrcpy/audio/AudioCapture.java b/server/src/main/java/com/genymobile/scrcpy/audio/AudioCapture.java new file mode 100644 index 00000000..62903f83 --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/audio/AudioCapture.java @@ -0,0 +1,20 @@ +package com.genymobile.scrcpy.audio; + +import android.media.MediaCodec; + +import java.nio.ByteBuffer; + +public interface AudioCapture { + void checkCompatibility() throws AudioCaptureException; + void start() throws AudioCaptureException; + void stop(); + + /** + * Read a chunk of {@link AudioConfig#MAX_READ_SIZE} samples. + * + * @param outDirectBuffer The target buffer + * @param outBufferInfo The info to provide to MediaCodec + * @return the number of bytes actually read. + */ + int read(ByteBuffer outDirectBuffer, MediaCodec.BufferInfo outBufferInfo); +} diff --git a/server/src/main/java/com/genymobile/scrcpy/audio/AudioCaptureException.java b/server/src/main/java/com/genymobile/scrcpy/audio/AudioCaptureException.java new file mode 100644 index 00000000..4b0b7e83 --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/audio/AudioCaptureException.java @@ -0,0 +1,12 @@ +package com.genymobile.scrcpy.audio; + +/** + * Exception for any audio capture issue. + *

+ * This includes the case where audio capture failed on Android 11 specifically because the running App (Shell) was not in foreground. + *

+ * Its purpose is to disable audio without errors (that's why the exception is empty, any error message must be printed by the caller before + * throwing the exception). + */ +public class AudioCaptureException extends Exception { +} diff --git a/server/src/main/java/com/genymobile/scrcpy/AudioCodec.java b/server/src/main/java/com/genymobile/scrcpy/audio/AudioCodec.java similarity index 93% rename from server/src/main/java/com/genymobile/scrcpy/AudioCodec.java rename to server/src/main/java/com/genymobile/scrcpy/audio/AudioCodec.java index b4ea3680..8f9e59b3 100644 --- a/server/src/main/java/com/genymobile/scrcpy/AudioCodec.java +++ b/server/src/main/java/com/genymobile/scrcpy/audio/AudioCodec.java @@ -1,4 +1,6 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.audio; + +import com.genymobile.scrcpy.util.Codec; import android.media.MediaFormat; diff --git a/server/src/main/java/com/genymobile/scrcpy/audio/AudioConfig.java b/server/src/main/java/com/genymobile/scrcpy/audio/AudioConfig.java new file mode 100644 index 00000000..c77165a7 --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/audio/AudioConfig.java @@ -0,0 +1,29 @@ +package com.genymobile.scrcpy.audio; + +import android.media.AudioFormat; + +public final class AudioConfig { + public static final int SAMPLE_RATE = 48000; + public static final int CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_STEREO; + public static final int CHANNELS = 2; + public static final int CHANNEL_MASK = AudioFormat.CHANNEL_IN_LEFT | AudioFormat.CHANNEL_IN_RIGHT; + public static final int ENCODING = AudioFormat.ENCODING_PCM_16BIT; + public static final int BYTES_PER_SAMPLE = 2; + + // Never read more than 1024 samples, even if the buffer is bigger (that would increase latency). + // A lower value is useless, since the system captures audio samples by blocks of 1024 (so for example if we read by blocks of 256 samples, we + // receive 4 successive blocks without waiting, then we wait for the 4 next ones). + public static final int MAX_READ_SIZE = 1024 * CHANNELS * BYTES_PER_SAMPLE; + + private AudioConfig() { + // Not instantiable + } + + public static AudioFormat createAudioFormat() { + AudioFormat.Builder builder = new AudioFormat.Builder(); + builder.setEncoding(ENCODING); + builder.setSampleRate(SAMPLE_RATE); + builder.setChannelMask(CHANNEL_CONFIG); + return builder.build(); + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/AudioCapture.java b/server/src/main/java/com/genymobile/scrcpy/audio/AudioDirectCapture.java similarity index 50% rename from server/src/main/java/com/genymobile/scrcpy/AudioCapture.java rename to server/src/main/java/com/genymobile/scrcpy/audio/AudioDirectCapture.java index e3de50e6..bf870bee 100644 --- a/server/src/main/java/com/genymobile/scrcpy/AudioCapture.java +++ b/server/src/main/java/com/genymobile/scrcpy/audio/AudioDirectCapture.java @@ -1,70 +1,55 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.audio; +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.FakeContext; +import com.genymobile.scrcpy.Workarounds; +import com.genymobile.scrcpy.util.Ln; import com.genymobile.scrcpy.wrappers.ServiceManager; import android.annotation.SuppressLint; import android.annotation.TargetApi; import android.content.ComponentName; import android.content.Intent; -import android.media.AudioFormat; import android.media.AudioRecord; -import android.media.AudioTimestamp; import android.media.MediaCodec; import android.os.Build; import android.os.SystemClock; import java.nio.ByteBuffer; -public final class AudioCapture { +public class AudioDirectCapture implements AudioCapture { - public static final int SAMPLE_RATE = 48000; - public static final int CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_STEREO; - public static final int CHANNELS = 2; - public static final int CHANNEL_MASK = AudioFormat.CHANNEL_IN_LEFT | AudioFormat.CHANNEL_IN_RIGHT; - public static final int ENCODING = AudioFormat.ENCODING_PCM_16BIT; - public static final int BYTES_PER_SAMPLE = 2; - - // Never read more than 1024 samples, even if the buffer is bigger (that would increase latency). - // A lower value is useless, since the system captures audio samples by blocks of 1024 (so for example if we read by blocks of 256 samples, we - // receive 4 successive blocks without waiting, then we wait for the 4 next ones). - public static final int MAX_READ_SIZE = 1024 * CHANNELS * BYTES_PER_SAMPLE; - - private static final long ONE_SAMPLE_US = (1000000 + SAMPLE_RATE - 1) / SAMPLE_RATE; // 1 sample in microseconds (used for fixing PTS) + private static final int SAMPLE_RATE = AudioConfig.SAMPLE_RATE; + private static final int CHANNEL_CONFIG = AudioConfig.CHANNEL_CONFIG; + private static final int CHANNELS = AudioConfig.CHANNELS; + private static final int CHANNEL_MASK = AudioConfig.CHANNEL_MASK; + private static final int ENCODING = AudioConfig.ENCODING; private final int audioSource; private AudioRecord recorder; + private AudioRecordReader reader; - private final AudioTimestamp timestamp = new AudioTimestamp(); - private long previousRecorderTimestamp = -1; - private long previousPts = 0; - private long nextPts = 0; - - public AudioCapture(AudioSource audioSource) { - this.audioSource = audioSource.value(); + public AudioDirectCapture(AudioSource audioSource) { + this.audioSource = audioSource.getDirectAudioSource(); } - private static AudioFormat createAudioFormat() { - AudioFormat.Builder builder = new AudioFormat.Builder(); - builder.setEncoding(ENCODING); - builder.setSampleRate(SAMPLE_RATE); - builder.setChannelMask(CHANNEL_CONFIG); - return builder.build(); - } - - @TargetApi(Build.VERSION_CODES.M) + @TargetApi(AndroidVersions.API_23_ANDROID_6_0) @SuppressLint({"WrongConstant", "MissingPermission"}) private static AudioRecord createAudioRecord(int audioSource) { AudioRecord.Builder builder = new AudioRecord.Builder(); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (Build.VERSION.SDK_INT >= AndroidVersions.API_31_ANDROID_12) { // On older APIs, Workarounds.fillAppInfo() must be called beforehand builder.setContext(FakeContext.get()); } builder.setAudioSource(audioSource); - builder.setAudioFormat(createAudioFormat()); + builder.setAudioFormat(AudioConfig.createAudioFormat()); int minBufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, ENCODING); - // This buffer size does not impact latency - builder.setBufferSizeInBytes(8 * minBufferSize); + if (minBufferSize > 0) { + // This buffer size does not impact latency + builder.setBufferSizeInBytes(8 * minBufferSize); + } + return builder.build(); } @@ -79,14 +64,14 @@ public final class AudioCapture { intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); intent.addCategory(Intent.CATEGORY_LAUNCHER); intent.setComponent(new ComponentName(FakeContext.PACKAGE_NAME, "com.android.shell.HeapDumpActivity")); - ServiceManager.getActivityManager().startActivityAsUserWithFeature(intent); + ServiceManager.getActivityManager().startActivity(intent); } private static void stopWorkaroundAndroid11() { ServiceManager.getActivityManager().forceStopPackage(FakeContext.PACKAGE_NAME); } - private void tryStartRecording(int attempts, int delayMs) throws AudioCaptureForegroundException { + private void tryStartRecording(int attempts, int delayMs) throws AudioCaptureException { while (attempts-- > 0) { // Wait for activity to start SystemClock.sleep(delayMs); @@ -98,7 +83,7 @@ public final class AudioCapture { Ln.e("Failed to start audio capture"); Ln.e("On Android 11, audio capture must be started in the foreground, make sure that the device is unlocked when starting " + "scrcpy."); - throw new AudioCaptureForegroundException(); + throw new AudioCaptureException(); } else { Ln.d("Failed to start audio capture, retrying..."); } @@ -106,7 +91,7 @@ public final class AudioCapture { } } - private void startRecording() { + private void startRecording() throws AudioCaptureException { try { recorder = createAudioRecord(audioSource); } catch (NullPointerException e) { @@ -116,10 +101,20 @@ public final class AudioCapture { recorder = Workarounds.createAudioRecord(audioSource, SAMPLE_RATE, CHANNEL_CONFIG, CHANNELS, CHANNEL_MASK, ENCODING); } recorder.startRecording(); + reader = new AudioRecordReader(recorder); } - public void start() throws AudioCaptureForegroundException { - if (Build.VERSION.SDK_INT == Build.VERSION_CODES.R) { + @Override + public void checkCompatibility() throws AudioCaptureException { + if (Build.VERSION.SDK_INT < AndroidVersions.API_30_ANDROID_11) { + Ln.w("Audio disabled: it is not supported before Android 11"); + throw new AudioCaptureException(); + } + } + + @Override + public void start() throws AudioCaptureException { + if (Build.VERSION.SDK_INT == AndroidVersions.API_30_ANDROID_11) { startWorkaroundAndroid11(); try { tryStartRecording(5, 100); @@ -131,6 +126,7 @@ public final class AudioCapture { } } + @Override public void stop() { if (recorder != null) { // Will call .stop() if necessary, without throwing an IllegalStateException @@ -138,41 +134,9 @@ public final class AudioCapture { } } - @TargetApi(Build.VERSION_CODES.N) - public int read(ByteBuffer directBuffer, MediaCodec.BufferInfo outBufferInfo) { - int r = recorder.read(directBuffer, MAX_READ_SIZE); - if (r <= 0) { - return r; - } - - long pts; - - int ret = recorder.getTimestamp(timestamp, AudioTimestamp.TIMEBASE_MONOTONIC); - if (ret == AudioRecord.SUCCESS && timestamp.nanoTime != previousRecorderTimestamp) { - pts = timestamp.nanoTime / 1000; - previousRecorderTimestamp = timestamp.nanoTime; - } else { - if (nextPts == 0) { - Ln.w("Could not get any audio timestamp"); - } - // compute from previous timestamp and packet size - pts = nextPts; - } - - long durationUs = r * 1000000 / (CHANNELS * BYTES_PER_SAMPLE * SAMPLE_RATE); - nextPts = pts + durationUs; - - if (previousPts != 0 && pts < previousPts + ONE_SAMPLE_US) { - // Audio PTS may come from two sources: - // - recorder.getTimestamp() if the call works; - // - an estimation from the previous PTS and the packet size as a fallback. - // - // Therefore, the property that PTS are monotonically increasing is no guaranteed in corner cases, so enforce it. - pts = previousPts + ONE_SAMPLE_US; - } - previousPts = pts; - - outBufferInfo.set(0, r, pts, 0); - return r; + @Override + @TargetApi(AndroidVersions.API_24_ANDROID_7_0) + public int read(ByteBuffer outDirectBuffer, MediaCodec.BufferInfo outBufferInfo) { + return reader.read(outDirectBuffer, outBufferInfo); } } diff --git a/server/src/main/java/com/genymobile/scrcpy/AudioEncoder.java b/server/src/main/java/com/genymobile/scrcpy/audio/AudioEncoder.java similarity index 77% rename from server/src/main/java/com/genymobile/scrcpy/AudioEncoder.java rename to server/src/main/java/com/genymobile/scrcpy/audio/AudioEncoder.java index 0b59369b..33177228 100644 --- a/server/src/main/java/com/genymobile/scrcpy/AudioEncoder.java +++ b/server/src/main/java/com/genymobile/scrcpy/audio/AudioEncoder.java @@ -1,4 +1,16 @@ -package com.genymobile.scrcpy; +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; +import com.genymobile.scrcpy.util.CodecOption; +import com.genymobile.scrcpy.util.CodecUtils; +import com.genymobile.scrcpy.util.IO; +import com.genymobile.scrcpy.util.Ln; +import com.genymobile.scrcpy.util.LogUtils; import android.annotation.TargetApi; import android.media.MediaCodec; @@ -34,8 +46,8 @@ public final class AudioEncoder implements AsyncProcessor { } } - private static final int SAMPLE_RATE = AudioCapture.SAMPLE_RATE; - private static final int CHANNELS = AudioCapture.CHANNELS; + private static final int SAMPLE_RATE = AudioConfig.SAMPLE_RATE; + private static final int CHANNELS = AudioConfig.CHANNELS; private final AudioCapture capture; private final Streamer streamer; @@ -43,6 +55,9 @@ public final class AudioEncoder implements AsyncProcessor { private final List codecOptions; private final String encoderName; + private boolean recreatePts; + private long previousPts; + // Capacity of 64 is in practice "infinite" (it is limited by the number of available MediaCodec buffers, typically 4). // So many pending tasks would lead to an unacceptable delay anyway. private final BlockingQueue inputTasks = new ArrayBlockingQueue<>(64); @@ -56,12 +71,12 @@ public final class AudioEncoder implements AsyncProcessor { private boolean ended; - public AudioEncoder(AudioCapture capture, Streamer streamer, int bitRate, List 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 codecOptions) { @@ -83,7 +98,7 @@ public final class AudioEncoder implements AsyncProcessor { return format; } - @TargetApi(Build.VERSION_CODES.N) + @TargetApi(AndroidVersions.API_24_ANDROID_7_0) private void inputThread(MediaCodec mediaCodec, AudioCapture capture) throws IOException, InterruptedException { final MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); @@ -106,6 +121,9 @@ public final class AudioEncoder implements AsyncProcessor { OutputTask task = outputTasks.take(); ByteBuffer buffer = mediaCodec.getOutputBuffer(task.index); try { + if (recreatePts) { + fixTimestamp(task.bufferInfo); + } streamer.writePacket(buffer, task.bufferInfo); } finally { mediaCodec.releaseOutputBuffer(task.index, false); @@ -113,6 +131,25 @@ public final class AudioEncoder implements AsyncProcessor { } } + private void fixTimestamp(MediaCodec.BufferInfo bufferInfo) { + assert recreatePts; + + if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) { + // Config packet, nothing to fix + return; + } + + long pts = bufferInfo.presentationTimeUs; + if (previousPts != 0) { + long now = System.nanoTime() / 1000; + // This specific encoder produces PTS matching the exact number of samples + long duration = pts - previousPts; + bufferInfo.presentationTimeUs = now - duration; + } + + previousPts = pts; + } + @Override public void start(TerminationListener listener) { thread = new Thread(() -> { @@ -122,7 +159,7 @@ public final class AudioEncoder implements AsyncProcessor { } catch (ConfigurationException e) { // Do not print stack trace, a user-friendly error-message has already been logged fatalError = true; - } catch (AudioCaptureForegroundException e) { + } catch (AudioCaptureException e) { // Do not print stack trace, a user-friendly error-message has already been logged } catch (IOException e) { Ln.e("Audio encoding error", e); @@ -165,9 +202,9 @@ public final class AudioEncoder implements AsyncProcessor { } } - @TargetApi(Build.VERSION_CODES.M) - public void encode() throws IOException, ConfigurationException, AudioCaptureForegroundException { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + @TargetApi(AndroidVersions.API_23_ANDROID_6_0) + private void encode() throws IOException, ConfigurationException, AudioCaptureException { + if (Build.VERSION.SDK_INT < AndroidVersions.API_30_ANDROID_11) { Ln.w("Audio disabled: it is not supported before Android 11"); streamer.writeDisableStream(false); return; @@ -177,9 +214,17 @@ public final class AudioEncoder implements AsyncProcessor { boolean mediaCodecStarted = false; try { + capture.checkCompatibility(); // throws an AudioCaptureException on error + Codec codec = streamer.getCodec(); mediaCodec = createMediaCodec(codec, encoderName); + // The default OPUS and FLAC encoders overwrite the input PTS with a value that matches the number of samples. This is not the behavior + // we want: it ignores any audio clock drift and hard silences (packets not produced on silence). To work around this behavior, + // regenerate PTS based on the current time and the packet duration. + String codecName = mediaCodec.getCanonicalName(); + recreatePts = "c2.android.opus.encoder".equals(codecName) || "c2.android.flac.encoder".equals(codecName); + mediaCodecThread = new HandlerThread("media-codec"); mediaCodecThread.start(); @@ -275,7 +320,13 @@ public final class AudioEncoder implements AsyncProcessor { if (encoderName != null) { Ln.d("Creating audio encoder by name: '" + encoderName + "'"); try { - return MediaCodec.createByCodecName(encoderName); + MediaCodec mediaCodec = MediaCodec.createByCodecName(encoderName); + String mimeType = Codec.getMimeType(mediaCodec); + if (!codec.getMimeType().equals(mimeType)) { + Ln.e("Audio encoder type for \"" + encoderName + "\" (" + mimeType + ") does not match codec type (" + codec.getMimeType() + ")"); + throw new ConfigurationException("Incorrect encoder type: " + encoderName); + } + return mediaCodec; } catch (IllegalArgumentException e) { Ln.e("Audio encoder '" + encoderName + "' for " + codec.getName() + " not found\n" + LogUtils.buildAudioEncoderListMessage()); throw new ConfigurationException("Unknown encoder: " + encoderName); @@ -296,7 +347,7 @@ public final class AudioEncoder implements AsyncProcessor { } private final class EncoderCallback extends MediaCodec.Callback { - @TargetApi(Build.VERSION_CODES.N) + @TargetApi(AndroidVersions.API_24_ANDROID_7_0) @Override public void onInputBufferAvailable(MediaCodec codec, int index) { try { diff --git a/server/src/main/java/com/genymobile/scrcpy/audio/AudioPlaybackCapture.java b/server/src/main/java/com/genymobile/scrcpy/audio/AudioPlaybackCapture.java new file mode 100644 index 00000000..009a239a --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/audio/AudioPlaybackCapture.java @@ -0,0 +1,138 @@ +package com.genymobile.scrcpy.audio; + +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.FakeContext; +import com.genymobile.scrcpy.util.Ln; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.content.Context; +import android.media.AudioAttributes; +import android.media.AudioFormat; +import android.media.AudioManager; +import android.media.AudioRecord; +import android.media.MediaCodec; +import android.os.Build; + +import java.lang.reflect.Method; +import java.nio.ByteBuffer; + +public final class AudioPlaybackCapture implements AudioCapture { + + private final boolean keepPlayingOnDevice; + + private AudioRecord recorder; + private AudioRecordReader reader; + + public AudioPlaybackCapture(boolean keepPlayingOnDevice) { + this.keepPlayingOnDevice = keepPlayingOnDevice; + } + + @SuppressLint("PrivateApi") + private AudioRecord createAudioRecord() throws AudioCaptureException { + // See + try { + Class audioMixingRuleClass = Class.forName("android.media.audiopolicy.AudioMixingRule"); + Class audioMixingRuleBuilderClass = Class.forName("android.media.audiopolicy.AudioMixingRule$Builder"); + + // AudioMixingRule.Builder audioMixingRuleBuilder = new AudioMixingRule.Builder(); + Object audioMixingRuleBuilder = audioMixingRuleBuilderClass.getConstructor().newInstance(); + + // audioMixingRuleBuilder.setTargetMixRole(AudioMixingRule.MIX_ROLE_PLAYERS); + int mixRolePlayersConstant = audioMixingRuleClass.getField("MIX_ROLE_PLAYERS").getInt(null); + Method setTargetMixRoleMethod = audioMixingRuleBuilderClass.getMethod("setTargetMixRole", int.class); + setTargetMixRoleMethod.invoke(audioMixingRuleBuilder, mixRolePlayersConstant); + + AudioAttributes attributes = new AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA).build(); + + // audioMixingRuleBuilder.addMixRule(AudioMixingRule.RULE_MATCH_ATTRIBUTE_USAGE, attributes); + int ruleMatchAttributeUsageConstant = audioMixingRuleClass.getField("RULE_MATCH_ATTRIBUTE_USAGE").getInt(null); + Method addMixRuleMethod = audioMixingRuleBuilderClass.getMethod("addMixRule", int.class, Object.class); + addMixRuleMethod.invoke(audioMixingRuleBuilder, ruleMatchAttributeUsageConstant, attributes); + + // AudioMixingRule audioMixingRule = builder.build(); + Object audioMixingRule = audioMixingRuleBuilderClass.getMethod("build").invoke(audioMixingRuleBuilder); + + // audioMixingRuleBuilder.voiceCommunicationCaptureAllowed(true); + Method voiceCommunicationCaptureAllowedMethod = audioMixingRuleBuilderClass.getMethod("voiceCommunicationCaptureAllowed", boolean.class); + voiceCommunicationCaptureAllowedMethod.invoke(audioMixingRuleBuilder, true); + + Class audioMixClass = Class.forName("android.media.audiopolicy.AudioMix"); + Class audioMixBuilderClass = Class.forName("android.media.audiopolicy.AudioMix$Builder"); + + // AudioMix.Builder audioMixBuilder = new AudioMix.Builder(audioMixingRule); + Object audioMixBuilder = audioMixBuilderClass.getConstructor(audioMixingRuleClass).newInstance(audioMixingRule); + + // audioMixBuilder.setFormat(createAudioFormat()); + Method setFormat = audioMixBuilder.getClass().getMethod("setFormat", AudioFormat.class); + setFormat.invoke(audioMixBuilder, AudioConfig.createAudioFormat()); + + String routeFlagName = keepPlayingOnDevice ? "ROUTE_FLAG_LOOP_BACK_RENDER" : "ROUTE_FLAG_LOOP_BACK"; + int routeFlags = audioMixClass.getField(routeFlagName).getInt(null); + + // audioMixBuilder.setRouteFlags(routeFlag); + Method setRouteFlags = audioMixBuilder.getClass().getMethod("setRouteFlags", int.class); + setRouteFlags.invoke(audioMixBuilder, routeFlags); + + // AudioMix audioMix = audioMixBuilder.build(); + Object audioMix = audioMixBuilderClass.getMethod("build").invoke(audioMixBuilder); + + Class audioPolicyClass = Class.forName("android.media.audiopolicy.AudioPolicy"); + Class audioPolicyBuilderClass = Class.forName("android.media.audiopolicy.AudioPolicy$Builder"); + + // AudioPolicy.Builder audioPolicyBuilder = new AudioPolicy.Builder(); + Object audioPolicyBuilder = audioPolicyBuilderClass.getConstructor(Context.class).newInstance(FakeContext.get()); + + // audioPolicyBuilder.addMix(audioMix); + Method addMixMethod = audioPolicyBuilderClass.getMethod("addMix", audioMixClass); + addMixMethod.invoke(audioPolicyBuilder, audioMix); + + // AudioPolicy audioPolicy = audioPolicyBuilder.build(); + Object audioPolicy = audioPolicyBuilderClass.getMethod("build").invoke(audioPolicyBuilder); + + // AudioManager.registerAudioPolicyStatic(audioPolicy); + Method registerAudioPolicyStaticMethod = AudioManager.class.getDeclaredMethod("registerAudioPolicyStatic", audioPolicyClass); + registerAudioPolicyStaticMethod.setAccessible(true); + int result = (int) registerAudioPolicyStaticMethod.invoke(null, audioPolicy); + if (result != 0) { + throw new RuntimeException("registerAudioPolicy() returned " + result); + } + + // audioPolicy.createAudioRecordSink(audioPolicy); + Method createAudioRecordSinkClass = audioPolicyClass.getMethod("createAudioRecordSink", audioMixClass); + return (AudioRecord) createAudioRecordSinkClass.invoke(audioPolicy, audioMix); + } catch (Exception e) { + Ln.e("Could not capture audio playback", e); + throw new AudioCaptureException(); + } + } + + @Override + public void checkCompatibility() throws AudioCaptureException { + if (Build.VERSION.SDK_INT < AndroidVersions.API_33_ANDROID_13) { + Ln.w("Audio disabled: audio playback capture source not supported before Android 13"); + throw new AudioCaptureException(); + } + } + + @Override + public void start() throws AudioCaptureException { + recorder = createAudioRecord(); + recorder.startRecording(); + reader = new AudioRecordReader(recorder); + } + + @Override + public void stop() { + if (recorder != null) { + // Will call .stop() if necessary, without throwing an IllegalStateException + recorder.release(); + } + } + + @Override + @TargetApi(AndroidVersions.API_24_ANDROID_7_0) + public int read(ByteBuffer outDirectBuffer, MediaCodec.BufferInfo outBufferInfo) { + return reader.read(outDirectBuffer, outBufferInfo); + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/AudioRawRecorder.java b/server/src/main/java/com/genymobile/scrcpy/audio/AudioRawRecorder.java similarity index 84% rename from server/src/main/java/com/genymobile/scrcpy/AudioRawRecorder.java rename to server/src/main/java/com/genymobile/scrcpy/audio/AudioRawRecorder.java index 7e052f32..9645bbbd 100644 --- a/server/src/main/java/com/genymobile/scrcpy/AudioRawRecorder.java +++ b/server/src/main/java/com/genymobile/scrcpy/audio/AudioRawRecorder.java @@ -1,4 +1,10 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.audio; + +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.AsyncProcessor; +import com.genymobile.scrcpy.device.Streamer; +import com.genymobile.scrcpy.util.IO; +import com.genymobile.scrcpy.util.Ln; import android.media.MediaCodec; import android.os.Build; @@ -18,14 +24,14 @@ public final class AudioRawRecorder implements AsyncProcessor { this.streamer = streamer; } - private void record() throws IOException, AudioCaptureForegroundException { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + private void record() throws IOException, AudioCaptureException { + if (Build.VERSION.SDK_INT < AndroidVersions.API_30_ANDROID_11) { Ln.w("Audio disabled: it is not supported before Android 11"); streamer.writeDisableStream(false); return; } - final ByteBuffer buffer = ByteBuffer.allocateDirect(AudioCapture.MAX_READ_SIZE); + final ByteBuffer buffer = ByteBuffer.allocateDirect(AudioConfig.MAX_READ_SIZE); final MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); try { @@ -64,7 +70,7 @@ public final class AudioRawRecorder implements AsyncProcessor { boolean fatalError = false; try { record(); - } catch (AudioCaptureForegroundException e) { + } catch (AudioCaptureException e) { // Do not print stack trace, a user-friendly error-message has already been logged } catch (Throwable t) { Ln.e("Audio recording error", t); diff --git a/server/src/main/java/com/genymobile/scrcpy/audio/AudioRecordReader.java b/server/src/main/java/com/genymobile/scrcpy/audio/AudioRecordReader.java new file mode 100644 index 00000000..32b42257 --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/audio/AudioRecordReader.java @@ -0,0 +1,67 @@ +package com.genymobile.scrcpy.audio; + +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.util.Ln; + +import android.annotation.TargetApi; +import android.media.AudioRecord; +import android.media.AudioTimestamp; +import android.media.MediaCodec; + +import java.nio.ByteBuffer; + +public class AudioRecordReader { + + private static final long ONE_SAMPLE_US = + (1000000 + AudioConfig.SAMPLE_RATE - 1) / AudioConfig.SAMPLE_RATE; // 1 sample in microseconds (used for fixing PTS) + + private final AudioRecord recorder; + + private final AudioTimestamp timestamp = new AudioTimestamp(); + private long previousRecorderTimestamp = -1; + private long previousPts = 0; + private long nextPts = 0; + + public AudioRecordReader(AudioRecord recorder) { + this.recorder = recorder; + } + + @TargetApi(AndroidVersions.API_24_ANDROID_7_0) + public int read(ByteBuffer outDirectBuffer, MediaCodec.BufferInfo outBufferInfo) { + int r = recorder.read(outDirectBuffer, AudioConfig.MAX_READ_SIZE); + if (r <= 0) { + return r; + } + + long pts; + + int ret = recorder.getTimestamp(timestamp, AudioTimestamp.TIMEBASE_MONOTONIC); + if (ret == AudioRecord.SUCCESS && timestamp.nanoTime != previousRecorderTimestamp) { + pts = timestamp.nanoTime / 1000; + previousRecorderTimestamp = timestamp.nanoTime; + } else { + if (nextPts == 0) { + Ln.w("Could not get initial audio timestamp"); + nextPts = System.nanoTime() / 1000; + } + // compute from previous timestamp and packet size + pts = nextPts; + } + + long durationUs = r * 1000000L / (AudioConfig.CHANNELS * AudioConfig.BYTES_PER_SAMPLE * AudioConfig.SAMPLE_RATE); + nextPts = pts + durationUs; + + if (previousPts != 0 && pts < previousPts + ONE_SAMPLE_US) { + // Audio PTS may come from two sources: + // - recorder.getTimestamp() if the call works; + // - an estimation from the previous PTS and the packet size as a fallback. + // + // Therefore, the property that PTS are monotonically increasing is no guaranteed in corner cases, so enforce it. + pts = previousPts + ONE_SAMPLE_US; + } + previousPts = pts; + + outBufferInfo.set(0, r, pts, 0); + return r; + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/audio/AudioSource.java b/server/src/main/java/com/genymobile/scrcpy/audio/AudioSource.java new file mode 100644 index 00000000..d16b5e38 --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/audio/AudioSource.java @@ -0,0 +1,45 @@ +package com.genymobile.scrcpy.audio; + +import android.annotation.SuppressLint; +import android.media.MediaRecorder; + +@SuppressLint("InlinedApi") +public enum AudioSource { + OUTPUT("output", MediaRecorder.AudioSource.REMOTE_SUBMIX), + MIC("mic", MediaRecorder.AudioSource.MIC), + PLAYBACK("playback", -1), + MIC_UNPROCESSED("mic-unprocessed", MediaRecorder.AudioSource.UNPROCESSED), + MIC_CAMCORDER("mic-camcorder", MediaRecorder.AudioSource.CAMCORDER), + MIC_VOICE_RECOGNITION("mic-voice-recognition", MediaRecorder.AudioSource.VOICE_RECOGNITION), + MIC_VOICE_COMMUNICATION("mic-voice-communication", MediaRecorder.AudioSource.VOICE_COMMUNICATION), + VOICE_CALL("voice-call", MediaRecorder.AudioSource.VOICE_CALL), + VOICE_CALL_UPLINK("voice-call-uplink", MediaRecorder.AudioSource.VOICE_UPLINK), + VOICE_CALL_DOWNLINK("voice-call-downlink", MediaRecorder.AudioSource.VOICE_DOWNLINK), + VOICE_PERFORMANCE("voice-performance", MediaRecorder.AudioSource.VOICE_PERFORMANCE); + + private final String name; + private final int directAudioSource; + + AudioSource(String name, int directAudioSource) { + this.name = name; + this.directAudioSource = directAudioSource; + } + + public boolean isDirect() { + return this != PLAYBACK; + } + + public int getDirectAudioSource() { + return directAudioSource; + } + + public static AudioSource findByName(String name) { + for (AudioSource audioSource : AudioSource.values()) { + if (name.equals(audioSource.name)) { + return audioSource; + } + } + + return null; + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/control/ControlChannel.java b/server/src/main/java/com/genymobile/scrcpy/control/ControlChannel.java new file mode 100644 index 00000000..2f12cdb3 --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/control/ControlChannel.java @@ -0,0 +1,24 @@ +package com.genymobile.scrcpy.control; + +import android.net.LocalSocket; + +import java.io.IOException; + +public final class ControlChannel { + + private final ControlMessageReader reader; + private final DeviceMessageWriter writer; + + public ControlChannel(LocalSocket controlSocket) throws IOException { + reader = new ControlMessageReader(controlSocket.getInputStream()); + writer = new DeviceMessageWriter(controlSocket.getOutputStream()); + } + + public ControlMessage recv() throws IOException { + return reader.read(); + } + + public void send(DeviceMessage msg) throws IOException { + writer.write(msg); + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/ControlMessage.java b/server/src/main/java/com/genymobile/scrcpy/control/ControlMessage.java similarity index 70% rename from server/src/main/java/com/genymobile/scrcpy/ControlMessage.java rename to server/src/main/java/com/genymobile/scrcpy/control/ControlMessage.java index e1800374..0eb96adc 100644 --- a/server/src/main/java/com/genymobile/scrcpy/ControlMessage.java +++ b/server/src/main/java/com/genymobile/scrcpy/control/ControlMessage.java @@ -1,4 +1,6 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.control; + +import com.genymobile.scrcpy.device.Position; /** * Union of all supported event types, identified by their {@code type}. @@ -15,8 +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; @@ -27,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_* @@ -40,6 +48,11 @@ public final class ControlMessage { private boolean paste; private int repeat; private long sequence; + private int id; + private byte[] data; + private boolean on; + private int vendorId; + private int productId; private ControlMessage() { } @@ -107,13 +120,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; } @@ -123,6 +133,39 @@ public final class ControlMessage { return msg; } + public static ControlMessage createUhidCreate(int id, int vendorId, int productId, String name, byte[] reportDesc) { + ControlMessage msg = new ControlMessage(); + msg.type = TYPE_UHID_CREATE; + msg.id = id; + msg.vendorId = vendorId; + msg.productId = productId; + msg.text = name; + msg.data = reportDesc; + return msg; + } + + public static ControlMessage createUhidInput(int id, byte[] data) { + ControlMessage msg = new ControlMessage(); + msg.type = TYPE_UHID_INPUT; + msg.id = id; + msg.data = data; + return msg; + } + + public static ControlMessage createUhidDestroy(int id) { + ControlMessage msg = new ControlMessage(); + msg.type = TYPE_UHID_DESTROY; + msg.id = id; + 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; } @@ -186,4 +229,24 @@ public final class ControlMessage { public long getSequence() { return sequence; } + + public int getId() { + return id; + } + + public byte[] getData() { + return data; + } + + public boolean getOn() { + return on; + } + + public int getVendorId() { + return vendorId; + } + + public int getProductId() { + return productId; + } } diff --git a/server/src/main/java/com/genymobile/scrcpy/control/ControlMessageReader.java b/server/src/main/java/com/genymobile/scrcpy/control/ControlMessageReader.java new file mode 100644 index 00000000..830a7ec7 --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/control/ControlMessageReader.java @@ -0,0 +1,176 @@ +package com.genymobile.scrcpy.control; + +import com.genymobile.scrcpy.device.Position; +import com.genymobile.scrcpy.util.Binary; + +import java.io.BufferedInputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +public class ControlMessageReader { + + private static final int MESSAGE_MAX_SIZE = 1 << 18; // 256k + + public static final int CLIPBOARD_TEXT_MAX_LENGTH = MESSAGE_MAX_SIZE - 14; // type: 1 byte; sequence: 8 bytes; paste flag: 1 byte; length: 4 bytes + public static final int INJECT_TEXT_MAX_LENGTH = 300; + + private final DataInputStream dis; + + public ControlMessageReader(InputStream rawInputStream) { + dis = new DataInputStream(new BufferedInputStream(rawInputStream)); + } + + public ControlMessage read() throws IOException { + int type = dis.readUnsignedByte(); + switch (type) { + case ControlMessage.TYPE_INJECT_KEYCODE: + return parseInjectKeycode(); + case ControlMessage.TYPE_INJECT_TEXT: + return parseInjectText(); + case ControlMessage.TYPE_INJECT_TOUCH_EVENT: + return parseInjectTouchEvent(); + case ControlMessage.TYPE_INJECT_SCROLL_EVENT: + return parseInjectScrollEvent(); + case ControlMessage.TYPE_BACK_OR_SCREEN_ON: + return parseBackOrScreenOnEvent(); + case ControlMessage.TYPE_GET_CLIPBOARD: + return parseGetClipboard(); + case ControlMessage.TYPE_SET_CLIPBOARD: + return parseSetClipboard(); + 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(); + case ControlMessage.TYPE_UHID_INPUT: + return parseUhidInput(); + case ControlMessage.TYPE_UHID_DESTROY: + return parseUhidDestroy(); + case ControlMessage.TYPE_START_APP: + return parseStartApp(); + default: + throw new ControlProtocolException("Unknown event type: " + type); + } + } + + private ControlMessage parseInjectKeycode() throws IOException { + int action = dis.readUnsignedByte(); + int keycode = dis.readInt(); + int repeat = dis.readInt(); + int metaState = dis.readInt(); + return ControlMessage.createInjectKeycode(action, keycode, repeat, metaState); + } + + private int parseBufferLength(int sizeBytes) throws IOException { + assert sizeBytes > 0 && sizeBytes <= 4; + int value = 0; + for (int i = 0; i < sizeBytes; ++i) { + value = (value << 8) | dis.readUnsignedByte(); + } + return value; + } + + private String parseString(int sizeBytes) throws IOException { + assert sizeBytes > 0 && sizeBytes <= 4; + byte[] data = parseByteArray(sizeBytes); + return new String(data, StandardCharsets.UTF_8); + } + + private String parseString() throws IOException { + return parseString(4); + } + + private byte[] parseByteArray(int sizeBytes) throws IOException { + int len = parseBufferLength(sizeBytes); + byte[] data = new byte[len]; + dis.readFully(data); + return data; + } + + private ControlMessage parseInjectText() throws IOException { + String text = parseString(); + return ControlMessage.createInjectText(text); + } + + private ControlMessage parseInjectTouchEvent() throws IOException { + int action = dis.readUnsignedByte(); + long pointerId = dis.readLong(); + Position position = parsePosition(); + float pressure = Binary.u16FixedPointToFloat(dis.readShort()); + int actionButton = dis.readInt(); + int buttons = dis.readInt(); + return ControlMessage.createInjectTouchEvent(action, pointerId, position, pressure, actionButton, buttons); + } + + private ControlMessage parseInjectScrollEvent() throws IOException { + Position position = parsePosition(); + // Binary.i16FixedPointToFloat() decodes values assuming the full range is [-1, 1], but the actual range is [-16, 16]. + float hScroll = Binary.i16FixedPointToFloat(dis.readShort()) * 16; + float vScroll = Binary.i16FixedPointToFloat(dis.readShort()) * 16; + int buttons = dis.readInt(); + return ControlMessage.createInjectScrollEvent(position, hScroll, vScroll, buttons); + } + + private ControlMessage parseBackOrScreenOnEvent() throws IOException { + int action = dis.readUnsignedByte(); + return ControlMessage.createBackOrScreenOn(action); + } + + private ControlMessage parseGetClipboard() throws IOException { + int copyKey = dis.readUnsignedByte(); + return ControlMessage.createGetClipboard(copyKey); + } + + private ControlMessage parseSetClipboard() throws IOException { + long sequence = dis.readLong(); + boolean paste = dis.readByte() != 0; + String text = parseString(); + return ControlMessage.createSetClipboard(sequence, text, paste); + } + + private ControlMessage parseSetDisplayPower() throws IOException { + boolean on = dis.readBoolean(); + return ControlMessage.createSetDisplayPower(on); + } + + private ControlMessage parseUhidCreate() throws IOException { + int id = dis.readUnsignedShort(); + int vendorId = dis.readUnsignedShort(); + int productId = dis.readUnsignedShort(); + String name = parseString(1); + byte[] data = parseByteArray(2); + return ControlMessage.createUhidCreate(id, vendorId, productId, name, data); + } + + private ControlMessage parseUhidInput() throws IOException { + int id = dis.readUnsignedShort(); + byte[] data = parseByteArray(2); + return ControlMessage.createUhidInput(id, data); + } + + private ControlMessage parseUhidDestroy() throws IOException { + int id = dis.readUnsignedShort(); + 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(); + int screenWidth = dis.readUnsignedShort(); + int screenHeight = dis.readUnsignedShort(); + return new Position(x, y, screenWidth, screenHeight); + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/control/ControlProtocolException.java b/server/src/main/java/com/genymobile/scrcpy/control/ControlProtocolException.java new file mode 100644 index 00000000..cabf63ee --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/control/ControlProtocolException.java @@ -0,0 +1,9 @@ +package com.genymobile.scrcpy.control; + +import java.io.IOException; + +public class ControlProtocolException extends IOException { + public ControlProtocolException(String message) { + super(message); + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/control/Controller.java b/server/src/main/java/com/genymobile/scrcpy/control/Controller.java new file mode 100644 index 00000000..b4a8e3ca --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/control/Controller.java @@ -0,0 +1,757 @@ +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.DisplayInfo; +import com.genymobile.scrcpy.device.Point; +import com.genymobile.scrcpy.device.Position; +import com.genymobile.scrcpy.device.Size; +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.Intent; +import android.os.Build; +import android.os.SystemClock; +import android.util.Pair; +import android.view.InputDevice; +import android.view.KeyCharacterMap; +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, 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; + + // control_msg.h values of the pointerId field in inject_touch_event message + 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 int displayId; + private final boolean supportsInputEvents; + private final ControlChannel controlChannel; + private final CleanUp cleanUp; + private final DeviceMessageSender sender; + private final boolean clipboardAutosync; + private final boolean powerOn; + + private final KeyCharacterMap charMap = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD); + + private final AtomicBoolean isSettingClipboard = new AtomicBoolean(); + + private final AtomicReference 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 keepDisplayPowerOff; + + // 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 = 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"); + } + + // Make sure the clipboard manager is always created from the main thread (even if clipboardAutosync is disabled) + ClipboardManager clipboardManager = ServiceManager.getClipboardManager(); + if (clipboardAutosync) { + // If control and autosync are enabled, synchronize Android clipboard to the computer automatically + if (clipboardManager != null) { + clipboardManager.addPrimaryClipChangedListener(() -> { + 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() { + if (uhidManager == null) { + int uhidDisplayId = displayId; + if (Build.VERSION.SDK_INT >= AndroidVersions.API_35_ANDROID_15) { + if (displayId == Device.DISPLAY_ID_NONE) { + // Mirroring a new virtual display id (using --new-display-id feature) on Android >= 15, where the UHID mouse pointer can be + // associated to the virtual display + try { + // Wait for at most 1 second until a virtual display id is known + DisplayData data = waitDisplayData(1000); + if (data != null) { + uhidDisplayId = data.virtualDisplayId; + } + } catch (InterruptedException e) { + // do nothing + } + } + } + + String displayUniqueId = null; + if (uhidDisplayId > 0) { + // Ignore Device.DISPLAY_ID_NONE and 0 (main display) + DisplayInfo displayInfo = ServiceManager.getDisplayManager().getDisplayInfo(uhidDisplayId); + if (displayInfo != null) { + displayUniqueId = displayInfo.getUniqueId(); + } + } + uhidManager = new UhidManager(sender, displayUniqueId); + } + + return uhidManager; + } + + private void initPointers() { + for (int i = 0; i < PointersState.MAX_POINTERS; ++i) { + MotionEvent.PointerProperties props = new MotionEvent.PointerProperties(); + props.toolType = MotionEvent.TOOL_TYPE_FINGER; + + MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords(); + coords.orientation = 0; + coords.size = 0; + + pointerProperties[i] = props; + pointerCoords[i] = coords; + } + } + + private void control() throws IOException { + // on start, power on the device + 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. + // To turn the device screen off while mirroring, the client will send a message that + // would be handled before the device is actually powered on, so its effect would + // be "canceled" once the device is turned back on. + // Adding this delay prevents to handle the message before the device is actually + // powered on. + SystemClock.sleep(500); + } + + boolean alive = true; + while (!Thread.currentThread().isInterrupted() && alive) { + alive = handleEvent(); + } + } + + @Override + public void start(TerminationListener listener) { + thread = new Thread(() -> { + try { + control(); + } catch (IOException e) { + Ln.e("Controller error", e); + } finally { + Ln.d("Controller stopped"); + if (uhidManager != null) { + uhidManager.closeAll(); + } + listener.onTerminated(true); + } + }, "control-recv"); + thread.start(); + sender.start(); + } + + @Override + public void stop() { + if (thread != null) { + thread.interrupt(); + } + sender.stop(); + } + + @Override + public void join() throws InterruptedException { + if (thread != null) { + thread.join(); + } + sender.join(); + } + + private boolean handleEvent() throws IOException { + ControlMessage msg; + try { + msg = controlChannel.recv(); + } catch (IOException e) { + // this is expected on close + return false; + } + + switch (msg.getType()) { + case ControlMessage.TYPE_INJECT_KEYCODE: + if (supportsInputEvents) { + injectKeycode(msg.getAction(), msg.getKeycode(), msg.getRepeat(), msg.getMetaState()); + } + break; + case ControlMessage.TYPE_INJECT_TEXT: + if (supportsInputEvents) { + injectText(msg.getText()); + } + break; + case ControlMessage.TYPE_INJECT_TOUCH_EVENT: + if (supportsInputEvents) { + injectTouch(msg.getAction(), msg.getPointerId(), msg.getPosition(), msg.getPressure(), msg.getActionButton(), msg.getButtons()); + } + break; + case ControlMessage.TYPE_INJECT_SCROLL_EVENT: + if (supportsInputEvents) { + injectScroll(msg.getPosition(), msg.getHScroll(), msg.getVScroll(), msg.getButtons()); + } + break; + case ControlMessage.TYPE_BACK_OR_SCREEN_ON: + if (supportsInputEvents) { + pressBackOrTurnScreenOn(msg.getAction()); + } + break; + case ControlMessage.TYPE_EXPAND_NOTIFICATION_PANEL: + Device.expandNotificationPanel(); + break; + case ControlMessage.TYPE_EXPAND_SETTINGS_PANEL: + Device.expandSettingsPanel(); + break; + case ControlMessage.TYPE_COLLAPSE_PANELS: + Device.collapsePanels(); + break; + case ControlMessage.TYPE_GET_CLIPBOARD: + getClipboard(msg.getCopyKey()); + break; + case ControlMessage.TYPE_SET_CLIPBOARD: + setClipboard(msg.getText(), msg.getPaste(), msg.getSequence()); + break; + case ControlMessage.TYPE_SET_DISPLAY_POWER: + if (supportsInputEvents) { + setDisplayPower(msg.getOn()); + } + break; + case ControlMessage.TYPE_ROTATE_DEVICE: + Device.rotateDevice(getActionDisplayId()); + break; + case ControlMessage.TYPE_UHID_CREATE: + getUhidManager().open(msg.getId(), msg.getVendorId(), msg.getProductId(), msg.getText(), msg.getData()); + break; + case ControlMessage.TYPE_UHID_INPUT: + getUhidManager().writeInput(msg.getId(), msg.getData()); + break; + case ControlMessage.TYPE_UHID_DESTROY: + getUhidManager().close(msg.getId()); + break; + 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 + } + + return true; + } + + private boolean injectKeycode(int action, int keycode, int repeat, int metaState) { + if (keepDisplayPowerOff && action == KeyEvent.ACTION_UP && (keycode == KeyEvent.KEYCODE_POWER || keycode == KeyEvent.KEYCODE_WAKEUP)) { + assert displayId != Device.DISPLAY_ID_NONE; + scheduleDisplayPowerOff(displayId); + } + return injectKeyEvent(action, keycode, repeat, metaState, Device.INJECT_MODE_ASYNC); + } + + private boolean injectChar(char c) { + String decomposed = KeyComposition.decompose(c); + char[] chars = decomposed != null ? decomposed.toCharArray() : new char[]{c}; + KeyEvent[] events = charMap.getEvents(chars); + if (events == null) { + return false; + } + + int actionDisplayId = getActionDisplayId(); + for (KeyEvent event : events) { + if (!Device.injectEvent(event, actionDisplayId, Device.INJECT_MODE_ASYNC)) { + return false; + } + } + return true; + } + + private int injectText(String text) { + int successCount = 0; + for (char c : text.toCharArray()) { + if (!injectChar(c)) { + Ln.w("Could not inject char u+" + String.format("%04x", (int) c)); + continue; + } + successCount++; + } + return successCount; + } + + private Pair getEventPointAndDisplayId(Position position) { + // it hides the field on purpose, to read it with atomic access + @SuppressWarnings("checkstyle:HiddenField") + DisplayData displayData = this.displayData.get(); + // In scrcpy, displayData should never be null (a touch event can only be generated from the client when a video frame is present). + // However, it is possible to send events without video playback when using scrcpy-server alone (except for virtual displays). + assert displayData != null || displayId != Device.DISPLAY_ID_NONE : "Cannot receive a positional event without a display"; + + Point point; + int targetDisplayId; + if (displayData != null) { + point = displayData.positionMapper.map(position); + if (point == null) { + if (Ln.isEnabled(Ln.Level.VERBOSE)) { + Size eventSize = position.getScreenSize(); + Size currentSize = displayData.positionMapper.getVideoSize(); + Ln.v("Ignore positional event generated for size " + eventSize + " (current size is " + currentSize + ")"); + } + return null; + } + targetDisplayId = displayData.virtualDisplayId; + } else { + // No display, use the raw coordinates + point = position.getPoint(); + targetDisplayId = displayId; + } + + return Pair.create(point, targetDisplayId); + } + + private boolean injectTouch(int action, long pointerId, Position position, float pressure, int actionButton, int buttons) { + long now = SystemClock.uptimeMillis(); + + Pair pair = getEventPointAndDisplayId(position); + if (pair == null) { + return false; + } + + Point point = pair.first; + int targetDisplayId = pair.second; + + int pointerIndex = pointersState.getPointerIndex(pointerId); + if (pointerIndex == -1) { + Ln.w("Too many pointers for touch event"); + return false; + } + Pointer pointer = pointersState.get(pointerIndex); + pointer.setPoint(point); + pointer.setPressure(pressure); + + int source; + boolean activeSecondaryButtons = ((actionButton | buttons) & ~MotionEvent.BUTTON_PRIMARY) != 0; + if (pointerId == POINTER_ID_MOUSE && (action == MotionEvent.ACTION_HOVER_MOVE || activeSecondaryButtons)) { + // real mouse event, or event incompatible with a finger + pointerProperties[pointerIndex].toolType = MotionEvent.TOOL_TYPE_MOUSE; + source = InputDevice.SOURCE_MOUSE; + pointer.setUp(buttons == 0); + } else { + // POINTER_ID_GENERIC_FINGER, POINTER_ID_VIRTUAL_FINGER or real touch from device + pointerProperties[pointerIndex].toolType = MotionEvent.TOOL_TYPE_FINGER; + source = InputDevice.SOURCE_TOUCHSCREEN; + // Buttons must not be set for touch events + buttons = 0; + pointer.setUp(action == MotionEvent.ACTION_UP); + } + + int pointerCount = pointersState.update(pointerProperties, pointerCoords); + if (pointerCount == 1) { + if (action == MotionEvent.ACTION_DOWN) { + lastTouchDown = now; + } + } else { + // secondary pointers must use ACTION_POINTER_* ORed with the pointerIndex + if (action == MotionEvent.ACTION_UP) { + action = MotionEvent.ACTION_POINTER_UP | (pointerIndex << MotionEvent.ACTION_POINTER_INDEX_SHIFT); + } else if (action == MotionEvent.ACTION_DOWN) { + action = MotionEvent.ACTION_POINTER_DOWN | (pointerIndex << MotionEvent.ACTION_POINTER_INDEX_SHIFT); + } + } + + /* If the input device is a mouse (on API >= 23): + * - the first button pressed must first generate ACTION_DOWN; + * - all button pressed (including the first one) must generate ACTION_BUTTON_PRESS; + * - all button released (including the last one) must generate ACTION_BUTTON_RELEASE; + * - the last button released must in addition generate ACTION_UP. + * + * Otherwise, Chrome does not work properly: + */ + if (Build.VERSION.SDK_INT >= AndroidVersions.API_23_ANDROID_6_0 && source == InputDevice.SOURCE_MOUSE) { + if (action == MotionEvent.ACTION_DOWN) { + if (actionButton == buttons) { + // 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, targetDisplayId, Device.INJECT_MODE_ASYNC)) { + return false; + } + } + + // Any button pressed: ACTION_BUTTON_PRESS + MotionEvent pressEvent = MotionEvent.obtain(lastTouchDown, now, MotionEvent.ACTION_BUTTON_PRESS, pointerCount, pointerProperties, + pointerCoords, 0, buttons, 1f, 1f, DEFAULT_DEVICE_ID, 0, source, 0); + if (!InputManager.setActionButton(pressEvent, actionButton)) { + return false; + } + if (!Device.injectEvent(pressEvent, targetDisplayId, Device.INJECT_MODE_ASYNC)) { + return false; + } + + return true; + } + + if (action == MotionEvent.ACTION_UP) { + // Any button released: ACTION_BUTTON_RELEASE + MotionEvent releaseEvent = MotionEvent.obtain(lastTouchDown, now, MotionEvent.ACTION_BUTTON_RELEASE, pointerCount, pointerProperties, + pointerCoords, 0, buttons, 1f, 1f, DEFAULT_DEVICE_ID, 0, source, 0); + if (!InputManager.setActionButton(releaseEvent, actionButton)) { + return false; + } + if (!Device.injectEvent(releaseEvent, targetDisplayId, Device.INJECT_MODE_ASYNC)) { + return false; + } + + if (buttons == 0) { + // 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, targetDisplayId, Device.INJECT_MODE_ASYNC)) { + return false; + } + } + + return true; + } + } + + MotionEvent event = MotionEvent.obtain(lastTouchDown, now, action, pointerCount, pointerProperties, pointerCoords, 0, buttons, 1f, 1f, + DEFAULT_DEVICE_ID, 0, source, 0); + return Device.injectEvent(event, targetDisplayId, Device.INJECT_MODE_ASYNC); + } + + private boolean injectScroll(Position position, float hScroll, float vScroll, int buttons) { + long now = SystemClock.uptimeMillis(); + + Pair pair = getEventPointAndDisplayId(position); + if (pair == null) { + return false; + } + + Point point = pair.first; + int targetDisplayId = pair.second; + + MotionEvent.PointerProperties props = pointerProperties[0]; + props.id = 0; + + MotionEvent.PointerCoords coords = pointerCoords[0]; + coords.x = point.getX(); + coords.y = point.getY(); + coords.setAxisValue(MotionEvent.AXIS_HSCROLL, hScroll); + coords.setAxisValue(MotionEvent.AXIS_VSCROLL, vScroll); + + 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, targetDisplayId, Device.INJECT_MODE_ASYNC); + } + + /** + * Schedule a call to set display power to off after a small delay. + */ + private static void scheduleDisplayPowerOff(int displayId) { + EXECUTOR.schedule(() -> { + Ln.i("Forcing display off"); + Device.setDisplayPower(displayId, false); + }, 200, TimeUnit.MILLISECONDS); + } + + private boolean pressBackOrTurnScreenOn(int action) { + if (displayId == Device.DISPLAY_ID_NONE || Device.isScreenOn(displayId)) { + return injectKeyEvent(action, KeyEvent.KEYCODE_BACK, 0, 0, Device.INJECT_MODE_ASYNC); + } + + // Screen is off + // Only press POWER on ACTION_DOWN + if (action != KeyEvent.ACTION_DOWN) { + // do nothing, + return true; + } + + if (keepDisplayPowerOff) { + assert displayId != Device.DISPLAY_ID_NONE; + scheduleDisplayPowerOff(displayId); + } + 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 && 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 + 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 + // particular when COPY or CUT are injected, so it should not be synchronized twice. On Android < 7, do not synchronize at all rather than + // copying an old clipboard content. + if (!clipboardAutosync) { + String clipboardText = Device.getClipboardText(); + if (clipboardText != null) { + DeviceMessage msg = DeviceMessage.createClipboard(clipboardText); + sender.send(msg); + } + } + } + + private boolean setClipboard(String text, boolean paste, long sequence) { + 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 && supportsInputEvents) { + pressReleaseKeycode(KeyEvent.KEYCODE_PASTE, Device.INJECT_MODE_ASYNC); + } + + if (sequence != ControlMessage.SEQUENCE_INVALID) { + // Acknowledgement requested + DeviceMessage msg = DeviceMessage.createAckClipboard(sequence); + sender.send(msg); + } + + return ok; + } + + private void openHardKeyboardSettings() { + 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 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; + } + if (timeout > 0) { + displayDataAvailable.wait(timeout); + } + data = displayData.get(); + } + + return data; + } + } + + private void setDisplayPower(boolean on) { + // Change the power of the main display when mirroring a virtual display + int targetDisplayId = displayId != Device.DISPLAY_ID_NONE ? displayId : 0; + boolean setDisplayPowerOk = Device.setDisplayPower(targetDisplayId, on); + if (setDisplayPowerOk) { + // Do not keep display power off for virtual displays: MOD+p must wake up the physical device + keepDisplayPowerOff = displayId != Device.DISPLAY_ID_NONE && !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(); + } + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/DeviceMessage.java b/server/src/main/java/com/genymobile/scrcpy/control/DeviceMessage.java similarity index 64% rename from server/src/main/java/com/genymobile/scrcpy/DeviceMessage.java rename to server/src/main/java/com/genymobile/scrcpy/control/DeviceMessage.java index 5b7c4de5..079a7a04 100644 --- a/server/src/main/java/com/genymobile/scrcpy/DeviceMessage.java +++ b/server/src/main/java/com/genymobile/scrcpy/control/DeviceMessage.java @@ -1,15 +1,16 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.control; public final class DeviceMessage { public static final int TYPE_CLIPBOARD = 0; public static final int TYPE_ACK_CLIPBOARD = 1; - - public static final long SEQUENCE_INVALID = ControlMessage.SEQUENCE_INVALID; + public static final int TYPE_UHID_OUTPUT = 2; private int type; private String text; private long sequence; + private int id; + private byte[] data; private DeviceMessage() { } @@ -28,6 +29,14 @@ public final class DeviceMessage { return event; } + public static DeviceMessage createUhidOutput(int id, byte[] data) { + DeviceMessage event = new DeviceMessage(); + event.type = TYPE_UHID_OUTPUT; + event.id = id; + event.data = data; + return event; + } + public int getType() { return type; } @@ -39,4 +48,12 @@ public final class DeviceMessage { public long getSequence() { return sequence; } + + public int getId() { + return id; + } + + public byte[] getData() { + return data; + } } diff --git a/server/src/main/java/com/genymobile/scrcpy/control/DeviceMessageSender.java b/server/src/main/java/com/genymobile/scrcpy/control/DeviceMessageSender.java new file mode 100644 index 00000000..dc5e6be0 --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/control/DeviceMessageSender.java @@ -0,0 +1,57 @@ +package com.genymobile.scrcpy.control; + +import com.genymobile.scrcpy.util.Ln; + +import java.io.IOException; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; + +public final class DeviceMessageSender { + + private final ControlChannel controlChannel; + + private Thread thread; + private final BlockingQueue queue = new ArrayBlockingQueue<>(16); + + public DeviceMessageSender(ControlChannel controlChannel) { + this.controlChannel = controlChannel; + } + + public void send(DeviceMessage msg) { + if (!queue.offer(msg)) { + Ln.w("Device message dropped: " + msg.getType()); + } + } + + private void loop() throws IOException, InterruptedException { + while (!Thread.currentThread().isInterrupted()) { + DeviceMessage msg = queue.take(); + controlChannel.send(msg); + } + } + + public void start() { + thread = new Thread(() -> { + try { + loop(); + } catch (IOException | InterruptedException e) { + // this is expected on close + } finally { + Ln.d("Device message sender stopped"); + } + }, "control-send"); + thread.start(); + } + + public void stop() { + if (thread != null) { + thread.interrupt(); + } + } + + public void join() throws InterruptedException { + if (thread != null) { + thread.join(); + } + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/control/DeviceMessageWriter.java b/server/src/main/java/com/genymobile/scrcpy/control/DeviceMessageWriter.java new file mode 100644 index 00000000..a18a2e5d --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/control/DeviceMessageWriter.java @@ -0,0 +1,47 @@ +package com.genymobile.scrcpy.control; + +import com.genymobile.scrcpy.util.StringUtils; + +import java.io.BufferedOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; + +public class DeviceMessageWriter { + + private static final int MESSAGE_MAX_SIZE = 1 << 18; // 256k + public static final int CLIPBOARD_TEXT_MAX_LENGTH = MESSAGE_MAX_SIZE - 5; // type: 1 byte; length: 4 bytes + + private final DataOutputStream dos; + + public DeviceMessageWriter(OutputStream rawOutputStream) { + dos = new DataOutputStream(new BufferedOutputStream(rawOutputStream)); + } + + public void write(DeviceMessage msg) throws IOException { + int type = msg.getType(); + dos.writeByte(type); + switch (type) { + case DeviceMessage.TYPE_CLIPBOARD: + String text = msg.getText(); + byte[] raw = text.getBytes(StandardCharsets.UTF_8); + int len = StringUtils.getUtf8TruncationIndex(raw, CLIPBOARD_TEXT_MAX_LENGTH); + dos.writeInt(len); + dos.write(raw, 0, len); + break; + case DeviceMessage.TYPE_ACK_CLIPBOARD: + dos.writeLong(msg.getSequence()); + break; + case DeviceMessage.TYPE_UHID_OUTPUT: + dos.writeShort(msg.getId()); + byte[] data = msg.getData(); + dos.writeShort(data.length); + dos.write(data); + break; + default: + throw new ControlProtocolException("Unknown event type: " + type); + } + dos.flush(); + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/KeyComposition.java b/server/src/main/java/com/genymobile/scrcpy/control/KeyComposition.java similarity index 99% rename from server/src/main/java/com/genymobile/scrcpy/KeyComposition.java rename to server/src/main/java/com/genymobile/scrcpy/control/KeyComposition.java index 2f2835c9..5b988f53 100644 --- a/server/src/main/java/com/genymobile/scrcpy/KeyComposition.java +++ b/server/src/main/java/com/genymobile/scrcpy/control/KeyComposition.java @@ -1,4 +1,4 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.control; import java.util.HashMap; import java.util.Map; diff --git a/server/src/main/java/com/genymobile/scrcpy/Pointer.java b/server/src/main/java/com/genymobile/scrcpy/control/Pointer.java similarity index 92% rename from server/src/main/java/com/genymobile/scrcpy/Pointer.java rename to server/src/main/java/com/genymobile/scrcpy/control/Pointer.java index b89cc256..02e33e10 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Pointer.java +++ b/server/src/main/java/com/genymobile/scrcpy/control/Pointer.java @@ -1,4 +1,6 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.control; + +import com.genymobile.scrcpy.device.Point; public class Pointer { diff --git a/server/src/main/java/com/genymobile/scrcpy/PointersState.java b/server/src/main/java/com/genymobile/scrcpy/control/PointersState.java similarity index 97% rename from server/src/main/java/com/genymobile/scrcpy/PointersState.java rename to server/src/main/java/com/genymobile/scrcpy/control/PointersState.java index d8daaff2..a12da71d 100644 --- a/server/src/main/java/com/genymobile/scrcpy/PointersState.java +++ b/server/src/main/java/com/genymobile/scrcpy/control/PointersState.java @@ -1,4 +1,6 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.control; + +import com.genymobile.scrcpy.device.Point; import android.view.MotionEvent; diff --git a/server/src/main/java/com/genymobile/scrcpy/control/PositionMapper.java b/server/src/main/java/com/genymobile/scrcpy/control/PositionMapper.java new file mode 100644 index 00000000..60109b51 --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/control/PositionMapper.java @@ -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.util.AffineMatrix; + +public final class PositionMapper { + + private final Size videoSize; + private final AffineMatrix videoToDeviceMatrix; + + public PositionMapper(Size videoSize, AffineMatrix videoToDeviceMatrix) { + this.videoSize = videoSize; + this.videoToDeviceMatrix = videoToDeviceMatrix; + } + + public static PositionMapper create(Size videoSize, AffineMatrix filterTransform, Size targetSize) { + boolean convertToPixels = !videoSize.equals(targetSize) || filterTransform != null; + AffineMatrix transform = filterTransform; + if (convertToPixels) { + AffineMatrix inputTransform = AffineMatrix.ndcFromPixels(videoSize); + AffineMatrix outputTransform = AffineMatrix.ndcToPixels(targetSize); + transform = outputTransform.multiply(transform).multiply(inputTransform); + } + + return new PositionMapper(videoSize, transform); + } + + public Size getVideoSize() { + return videoSize; + } + + public Point map(Position position) { + Size clientVideoSize = position.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 = position.getPoint(); + if (videoToDeviceMatrix != null) { + point = videoToDeviceMatrix.apply(point); + } + return point; + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/control/UhidManager.java b/server/src/main/java/com/genymobile/scrcpy/control/UhidManager.java new file mode 100644 index 00000000..20532c0b --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/control/UhidManager.java @@ -0,0 +1,287 @@ +package com.genymobile.scrcpy.control; + +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.util.Ln; +import com.genymobile.scrcpy.util.StringUtils; +import com.genymobile.scrcpy.wrappers.ServiceManager; + +import android.os.Build; +import android.os.HandlerThread; +import android.os.MessageQueue; +import android.system.ErrnoException; +import android.system.Os; +import android.system.OsConstants; +import android.util.ArrayMap; + +import java.io.FileDescriptor; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; + +public final class UhidManager { + + // Linux: include/uapi/linux/uhid.h + private static final int UHID_OUTPUT = 6; + private static final int UHID_CREATE2 = 11; + private static final int UHID_INPUT2 = 12; + + // Linux: include/uapi/linux/input.h + private static final short BUS_VIRTUAL = 0x06; + + private static final int SIZE_OF_UHID_EVENT = 4380; // sizeof(struct uhid_event) + + // Must be unique across the system + private static final String INPUT_PORT = "scrcpy:" + Os.getpid(); + + private final String displayUniqueId; + + private final ArrayMap fds = new ArrayMap<>(); + private final ByteBuffer buffer = ByteBuffer.allocate(SIZE_OF_UHID_EVENT).order(ByteOrder.nativeOrder()); + + private final DeviceMessageSender sender; + private final MessageQueue queue; + + public UhidManager(DeviceMessageSender sender, String displayUniqueId) { + this.sender = sender; + this.displayUniqueId = displayUniqueId; + if (Build.VERSION.SDK_INT >= AndroidVersions.API_23_ANDROID_6_0) { + HandlerThread thread = new HandlerThread("UHidManager"); + thread.start(); + queue = thread.getLooper().getQueue(); + } else { + queue = null; + } + } + + public void open(int id, int vendorId, int productId, String name, byte[] reportDesc) throws IOException { + try { + FileDescriptor fd = Os.open("/dev/uhid", OsConstants.O_RDWR, 0); + try { + // First UHID device added + boolean firstDevice = fds.isEmpty(); + + FileDescriptor old = fds.put(id, fd); + if (old != null) { + Ln.w("Duplicate UHID id: " + id); + close(old); + } + + String phys = mustUseInputPort() ? INPUT_PORT : null; + byte[] req = buildUhidCreate2Req(vendorId, productId, name, reportDesc, phys); + Os.write(fd, req, 0, req.length); + + if (firstDevice) { + addUniqueIdAssociation(); + } + registerUhidListener(id, fd); + } catch (Exception e) { + close(fd); + throw e; + } + } catch (ErrnoException e) { + throw new IOException(e); + } + } + + private void registerUhidListener(int id, FileDescriptor fd) { + if (Build.VERSION.SDK_INT >= AndroidVersions.API_23_ANDROID_6_0) { + queue.addOnFileDescriptorEventListener(fd, MessageQueue.OnFileDescriptorEventListener.EVENT_INPUT, (fd2, events) -> { + try { + buffer.clear(); + int r = Os.read(fd2, buffer); + buffer.flip(); + if (r > 0) { + int type = buffer.getInt(); + if (type == UHID_OUTPUT) { + byte[] data = extractHidOutputData(buffer); + if (data != null) { + DeviceMessage msg = DeviceMessage.createUhidOutput(id, data); + sender.send(msg); + } + } + } + } catch (ErrnoException | InterruptedIOException e) { + Ln.e("Failed to read UHID output", e); + return 0; + } + return events; + }); + } + } + + private void unregisterUhidListener(FileDescriptor fd) { + if (Build.VERSION.SDK_INT >= AndroidVersions.API_23_ANDROID_6_0) { + queue.removeOnFileDescriptorEventListener(fd); + } + } + + private static byte[] extractHidOutputData(ByteBuffer buffer) { + /* + * #define UHID_DATA_MAX 4096 + * struct uhid_event { + * uint32_t type; + * union { + * // ... + * struct uhid_output_req { + * __u8 data[UHID_DATA_MAX]; + * __u16 size; + * __u8 rtype; + * }; + * }; + * } __attribute__((__packed__)); + */ + + if (buffer.remaining() < 4099) { + Ln.w("Incomplete HID output"); + return null; + } + int size = buffer.getShort(buffer.position() + 4096) & 0xFFFF; + if (size > 4096) { + Ln.w("Incorrect HID output size: " + size); + return null; + } + byte[] data = new byte[size]; + buffer.get(data); + return data; + } + + public void writeInput(int id, byte[] data) throws IOException { + FileDescriptor fd = fds.get(id); + if (fd == null) { + Ln.w("Unknown UHID id: " + id); + return; + } + + try { + byte[] req = buildUhidInput2Req(data); + Os.write(fd, req, 0, req.length); + } catch (ErrnoException e) { + throw new IOException(e); + } + } + + private static byte[] buildUhidCreate2Req(int vendorId, int productId, String name, byte[] reportDesc, String phys) { + /* + * struct uhid_event { + * uint32_t type; + * union { + * // ... + * struct uhid_create2_req { + * uint8_t name[128]; + * uint8_t phys[64]; + * uint8_t uniq[64]; + * uint16_t rd_size; + * uint16_t bus; + * uint32_t vendor; + * uint32_t product; + * uint32_t version; + * uint32_t country; + * uint8_t rd_data[HID_MAX_DESCRIPTOR_SIZE]; + * }; + * }; + * } __attribute__((__packed__)); + */ + + ByteBuffer buf = ByteBuffer.allocate(280 + reportDesc.length).order(ByteOrder.nativeOrder()); + buf.putInt(UHID_CREATE2); + + String actualName = name.isEmpty() ? "scrcpy" : name; + byte[] nameBytes = actualName.getBytes(StandardCharsets.UTF_8); + int nameLen = StringUtils.getUtf8TruncationIndex(nameBytes, 127); + assert nameLen <= 127; + buf.put(nameBytes, 0, nameLen); + + if (phys != null) { + buf.position(4 + 128); + byte[] physBytes = phys.getBytes(StandardCharsets.US_ASCII); + assert physBytes.length <= 63; + buf.put(physBytes); + } + + buf.position(4 + 256); + buf.putShort((short) reportDesc.length); + buf.putShort(BUS_VIRTUAL); + buf.putInt(vendorId); + buf.putInt(productId); + buf.putInt(0); // version + buf.putInt(0); // country; + buf.put(reportDesc); + return buf.array(); + } + + private static byte[] buildUhidInput2Req(byte[] data) { + /* + * struct uhid_event { + * uint32_t type; + * union { + * // ... + * struct uhid_input2_req { + * uint16_t size; + * uint8_t data[UHID_DATA_MAX]; + * }; + * }; + * } __attribute__((__packed__)); + */ + + ByteBuffer buf = ByteBuffer.allocate(6 + data.length).order(ByteOrder.nativeOrder()); + buf.putInt(UHID_INPUT2); + buf.putShort((short) data.length); + buf.put(data); + return buf.array(); + } + + public void close(int id) { + // Linux: Documentation/hid/uhid.rst + // If you close() the fd, the device is automatically unregistered and destroyed internally. + FileDescriptor fd = fds.remove(id); + if (fd != null) { + unregisterUhidListener(fd); + close(fd); + + if (fds.isEmpty()) { + // Last UHID device removed + removeUniqueIdAssociation(); + } + } else { + Ln.w("Closing unknown UHID device: " + id); + } + } + + public void closeAll() { + if (fds.isEmpty()) { + return; + } + + for (FileDescriptor fd : fds.values()) { + close(fd); + } + + removeUniqueIdAssociation(); + } + + private static void close(FileDescriptor fd) { + try { + Os.close(fd); + } catch (ErrnoException e) { + Ln.e("Failed to close uhid: " + e.getMessage()); + } + } + + private boolean mustUseInputPort() { + return Build.VERSION.SDK_INT >= AndroidVersions.API_35_ANDROID_15 && displayUniqueId != null; + } + + private void addUniqueIdAssociation() { + if (mustUseInputPort()) { + ServiceManager.getInputManager().addUniqueIdAssociationByPort(INPUT_PORT, displayUniqueId); + } + } + + private void removeUniqueIdAssociation() { + if (mustUseInputPort()) { + ServiceManager.getInputManager().removeUniqueIdAssociationByPort(INPUT_PORT); + } + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/ConfigurationException.java b/server/src/main/java/com/genymobile/scrcpy/device/ConfigurationException.java similarity index 78% rename from server/src/main/java/com/genymobile/scrcpy/ConfigurationException.java rename to server/src/main/java/com/genymobile/scrcpy/device/ConfigurationException.java index 76c8f52e..17729342 100644 --- a/server/src/main/java/com/genymobile/scrcpy/ConfigurationException.java +++ b/server/src/main/java/com/genymobile/scrcpy/device/ConfigurationException.java @@ -1,4 +1,4 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.device; public class ConfigurationException extends Exception { public ConfigurationException(String message) { diff --git a/server/src/main/java/com/genymobile/scrcpy/DesktopConnection.java b/server/src/main/java/com/genymobile/scrcpy/device/DesktopConnection.java similarity index 85% rename from server/src/main/java/com/genymobile/scrcpy/DesktopConnection.java rename to server/src/main/java/com/genymobile/scrcpy/device/DesktopConnection.java index 8bc743f8..db75aec6 100644 --- a/server/src/main/java/com/genymobile/scrcpy/DesktopConnection.java +++ b/server/src/main/java/com/genymobile/scrcpy/device/DesktopConnection.java @@ -1,4 +1,8 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.device; + +import com.genymobile.scrcpy.control.ControlChannel; +import com.genymobile.scrcpy.util.IO; +import com.genymobile.scrcpy.util.StringUtils; import android.net.LocalServerSocket; import android.net.LocalSocket; @@ -7,8 +11,6 @@ import android.net.LocalSocketAddress; import java.io.Closeable; import java.io.FileDescriptor; import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; import java.nio.charset.StandardCharsets; public final class DesktopConnection implements Closeable { @@ -24,25 +26,16 @@ public final class DesktopConnection implements Closeable { private final FileDescriptor audioFd; private final LocalSocket controlSocket; - private final InputStream controlInputStream; - private final OutputStream controlOutputStream; - - private final ControlMessageReader reader = new ControlMessageReader(); - private final DeviceMessageWriter writer = new DeviceMessageWriter(); + private final ControlChannel controlChannel; private DesktopConnection(LocalSocket videoSocket, LocalSocket audioSocket, LocalSocket controlSocket) throws IOException { this.videoSocket = videoSocket; - this.controlSocket = controlSocket; this.audioSocket = audioSocket; - if (controlSocket != null) { - controlInputStream = controlSocket.getInputStream(); - controlOutputStream = controlSocket.getOutputStream(); - } else { - controlInputStream = null; - controlOutputStream = null; - } + this.controlSocket = controlSocket; + videoFd = videoSocket != null ? videoSocket.getFileDescriptor() : null; audioFd = audioSocket != null ? audioSocket.getFileDescriptor() : null; + controlChannel = controlSocket != null ? new ControlChannel(controlSocket) : null; } private static LocalSocket connect(String abstractName) throws IOException { @@ -179,16 +172,7 @@ public final class DesktopConnection implements Closeable { return audioFd; } - public ControlMessage receiveControlMessage() throws IOException { - ControlMessage msg = reader.next(); - while (msg == null) { - reader.readFrom(controlInputStream); - msg = reader.next(); - } - return msg; - } - - public void sendDeviceMessage(DeviceMessage msg) throws IOException { - writer.writeTo(msg, controlOutputStream); + public ControlChannel getControlChannel() { + return controlChannel; } } diff --git a/server/src/main/java/com/genymobile/scrcpy/device/Device.java b/server/src/main/java/com/genymobile/scrcpy/device/Device.java new file mode 100644 index 00000000..3553dc27 --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/device/Device.java @@ -0,0 +1,315 @@ +package com.genymobile.scrcpy.device; + +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.FakeContext; +import com.genymobile.scrcpy.util.Ln; +import com.genymobile.scrcpy.wrappers.ActivityManager; +import com.genymobile.scrcpy.wrappers.ClipboardManager; +import com.genymobile.scrcpy.wrappers.DisplayControl; +import com.genymobile.scrcpy.wrappers.InputManager; +import com.genymobile.scrcpy.wrappers.ServiceManager; +import com.genymobile.scrcpy.wrappers.SurfaceControl; +import com.genymobile.scrcpy.wrappers.WindowManager; + +import android.annotation.SuppressLint; +import android.content.Intent; +import android.app.ActivityOptions; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.os.Build; +import android.os.Bundle; +import android.os.IBinder; +import android.os.SystemClock; +import android.view.InputDevice; +import android.view.InputEvent; +import android.view.KeyCharacterMap; +import android.view.KeyEvent; + +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; + + public static final int INJECT_MODE_ASYNC = InputManager.INJECT_INPUT_EVENT_MODE_ASYNC; + public static final int INJECT_MODE_WAIT_FOR_RESULT = InputManager.INJECT_INPUT_EVENT_MODE_WAIT_FOR_RESULT; + public static final int INJECT_MODE_WAIT_FOR_FINISH = InputManager.INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH; + + // The new display power method introduced in Android 15 does not work as expected: + // + private static final boolean USE_ANDROID_15_DISPLAY_POWER = false; + + private Device() { + // not instantiable + } + + public static String getDeviceName() { + return Build.MODEL; + } + + 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 static boolean injectEvent(InputEvent inputEvent, int displayId, int injectMode) { + if (!supportsInputEvents(displayId)) { + throw new AssertionError("Could not inject input event if !supportsInputEvents()"); + } + + if (displayId != 0 && !InputManager.setDisplayId(inputEvent, displayId)) { + return false; + } + + return ServiceManager.getInputManager().injectInputEvent(inputEvent, 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, + InputDevice.SOURCE_KEYBOARD); + return injectEvent(event, 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 static boolean isScreenOn(int displayId) { + assert displayId != DISPLAY_ID_NONE; + return ServiceManager.getPowerManager().isScreenOn(displayId); + } + + public static void expandNotificationPanel() { + ServiceManager.getStatusBarManager().expandNotificationsPanel(); + } + + public static void expandSettingsPanel() { + ServiceManager.getStatusBarManager().expandSettingsPanel(); + } + + public static void collapsePanels() { + ServiceManager.getStatusBarManager().collapsePanels(); + } + + public static String getClipboardText() { + ClipboardManager clipboardManager = ServiceManager.getClipboardManager(); + if (clipboardManager == null) { + return null; + } + CharSequence s = clipboardManager.getText(); + if (s == null) { + return null; + } + return s.toString(); + } + + public static boolean setClipboardText(String text) { + ClipboardManager clipboardManager = ServiceManager.getClipboardManager(); + if (clipboardManager == null) { + return false; + } + + String currentClipboard = getClipboardText(); + if (currentClipboard != null && currentClipboard.equals(text)) { + // The clipboard already contains the requested text. + // Since pasting text from the computer involves setting the device clipboard, it could be set twice on a copy-paste. This would cause + // the clipboard listeners to be notified twice, and that would flood the Android keyboard clipboard history. To workaround this + // problem, do not explicitly set the clipboard text if it already contains the expected content. + return false; + } + + return clipboardManager.setText(text); + } + + public static boolean setDisplayPower(int displayId, boolean on) { + assert displayId != Device.DISPLAY_ID_NONE; + + if (USE_ANDROID_15_DISPLAY_POWER && 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 + && Build.VERSION.SDK_INT >= AndroidVersions.API_34_ANDROID_14 + && Build.BRAND.equalsIgnoreCase("honor") + && SurfaceControl.hasGetBuildInDisplayMethod()) { + // Workaround for Honor devices with Android 14: + // - + // - + 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 = + Build.VERSION.SDK_INT >= AndroidVersions.API_34_ANDROID_14 && !SurfaceControl.hasGetPhysicalDisplayIdsMethod(); + + // Change the power mode for all physical displays + long[] physicalDisplayIds = useDisplayControl ? DisplayControl.getPhysicalDisplayIds() : SurfaceControl.getPhysicalDisplayIds(); + if (physicalDisplayIds == null) { + Ln.e("Could not get physical display ids"); + return false; + } + + boolean allOk = true; + for (long physicalDisplayId : physicalDisplayIds) { + IBinder binder = useDisplayControl ? DisplayControl.getPhysicalDisplayToken( + physicalDisplayId) : SurfaceControl.getPhysicalDisplayToken(physicalDisplayId); + allOk &= SurfaceControl.setDisplayPowerMode(binder, mode); + } + return allOk; + } + + // Older Android versions, only 1 display + IBinder d = SurfaceControl.getBuiltInDisplay(); + if (d == null) { + Ln.e("Could not get built-in display"); + return false; + } + return SurfaceControl.setDisplayPowerMode(d, mode); + } + + public static boolean powerOffScreen(int displayId) { + assert displayId != DISPLAY_ID_NONE; + + if (!isScreenOn(displayId)) { + return true; + } + return pressReleaseKeycode(KeyEvent.KEYCODE_POWER, displayId, Device.INJECT_MODE_ASYNC); + } + + /** + * Disable auto-rotation (if enabled), set the screen rotation and re-enable auto-rotation (if it was enabled). + */ + public static void rotateDevice(int displayId) { + assert displayId != DISPLAY_ID_NONE; + + WindowManager wm = ServiceManager.getWindowManager(); + + boolean accelerometerRotation = !wm.isRotationFrozen(displayId); + + int currentRotation = getCurrentRotation(displayId); + int newRotation = (currentRotation & 1) ^ 1; // 0->1, 1->0, 2->1, 3->0 + String newRotationString = newRotation == 0 ? "portrait" : "landscape"; + + Ln.i("Device rotation requested: " + newRotationString); + wm.freezeRotation(displayId, newRotation); + + // restore auto-rotate if necessary + if (accelerometerRotation) { + wm.thawRotation(displayId); + } + } + + private static int getCurrentRotation(int displayId) { + assert displayId != DISPLAY_ID_NONE; + + if (displayId == 0) { + return ServiceManager.getWindowManager().getRotation(); + } + + DisplayInfo displayInfo = ServiceManager.getDisplayManager().getDisplayInfo(displayId); + return displayInfo.getRotation(); + } + + public static List listApps() { + List 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 getLaunchableApps(PackageManager pm) { + List 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 findByName(String searchName) { + List 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); + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/device/DeviceApp.java b/server/src/main/java/com/genymobile/scrcpy/device/DeviceApp.java new file mode 100644 index 00000000..ed292efa --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/device/DeviceApp.java @@ -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; + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/DisplayInfo.java b/server/src/main/java/com/genymobile/scrcpy/device/DisplayInfo.java similarity index 71% rename from server/src/main/java/com/genymobile/scrcpy/DisplayInfo.java rename to server/src/main/java/com/genymobile/scrcpy/device/DisplayInfo.java index 4b8036f8..8d26b7ce 100644 --- a/server/src/main/java/com/genymobile/scrcpy/DisplayInfo.java +++ b/server/src/main/java/com/genymobile/scrcpy/device/DisplayInfo.java @@ -1,4 +1,4 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.device; public final class DisplayInfo { private final int displayId; @@ -6,15 +6,19 @@ public final class DisplayInfo { private final int rotation; private final int layerStack; private final int flags; + private final int dpi; + private final String uniqueId; 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, String uniqueId) { this.displayId = displayId; this.size = size; this.rotation = rotation; this.layerStack = layerStack; this.flags = flags; + this.dpi = dpi; + this.uniqueId = uniqueId; } public int getDisplayId() { @@ -36,5 +40,12 @@ public final class DisplayInfo { public int getFlags() { return flags; } -} + public int getDpi() { + return dpi; + } + + public String getUniqueId() { + return uniqueId; + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/device/NewDisplay.java b/server/src/main/java/com/genymobile/scrcpy/device/NewDisplay.java new file mode 100644 index 00000000..3aa2996a --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/device/NewDisplay.java @@ -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; + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/device/Orientation.java b/server/src/main/java/com/genymobile/scrcpy/device/Orientation.java new file mode 100644 index 00000000..81168aae --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/device/Orientation.java @@ -0,0 +1,49 @@ +package com.genymobile.scrcpy.device; + +public enum Orientation { + + // @formatter:off + Orient0("0"), + Orient90("90"), + Orient180("180"), + Orient270("270"), + Flip0("flip0"), + Flip90("flip90"), + Flip180("flip180"), + Flip270("flip270"); + + public enum Lock { + Unlocked, LockedInitial, LockedValue, + } + + private final String name; + + Orientation(String name) { + this.name = name; + } + + public static Orientation getByName(String name) { + for (Orientation orientation : values()) { + if (orientation.name.equals(name)) { + return orientation; + } + } + + throw new IllegalArgumentException("Unknown orientation: " + name); + } + + public static Orientation fromRotation(int ccwRotation) { + assert ccwRotation >= 0 && ccwRotation < 4; + // Display rotation is expressed counter-clockwise, orientation is expressed clockwise + int cwRotation = (4 - ccwRotation) % 4; + return values()[cwRotation]; + } + + public boolean isFlipped() { + return (ordinal() & 4) != 0; + } + + public int getRotation() { + return ordinal() & 3; + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/Point.java b/server/src/main/java/com/genymobile/scrcpy/device/Point.java similarity index 95% rename from server/src/main/java/com/genymobile/scrcpy/Point.java rename to server/src/main/java/com/genymobile/scrcpy/device/Point.java index c2a30fa8..361b9958 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Point.java +++ b/server/src/main/java/com/genymobile/scrcpy/device/Point.java @@ -1,4 +1,4 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.device; import java.util.Objects; diff --git a/server/src/main/java/com/genymobile/scrcpy/Position.java b/server/src/main/java/com/genymobile/scrcpy/device/Position.java similarity index 97% rename from server/src/main/java/com/genymobile/scrcpy/Position.java rename to server/src/main/java/com/genymobile/scrcpy/device/Position.java index 2d298645..7ce4e256 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Position.java +++ b/server/src/main/java/com/genymobile/scrcpy/device/Position.java @@ -1,4 +1,4 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.device; import java.util.Objects; diff --git a/server/src/main/java/com/genymobile/scrcpy/device/Size.java b/server/src/main/java/com/genymobile/scrcpy/device/Size.java new file mode 100644 index 00000000..b448273d --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/device/Size.java @@ -0,0 +1,112 @@ +package com.genymobile.scrcpy.device; + +import android.graphics.Rect; + +import java.util.Objects; + +public final class Size { + private final int width; + private final int height; + + public Size(int width, int height) { + this.width = width; + this.height = height; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } + + public int getMax() { + return Math.max(width, height); + } + + public Size rotate() { + return new Size(height, width); + } + + public Size limit(int maxSize) { + assert maxSize >= 0 : "Max size may not be negative"; + assert maxSize % 8 == 0 : "Max size must be a multiple of 8"; + + if (maxSize == 0) { + // No limit + return this; + } + + boolean portrait = height > width; + int major = portrait ? height : width; + if (major <= maxSize) { + return this; + } + + int minor = portrait ? width : height; + + int newMajor = maxSize; + int newMinor = maxSize * minor / major; + + int w = portrait ? newMinor : newMajor; + int h = portrait ? newMajor : newMinor; + return new Size(w, h); + } + + /** + * Round both dimensions of this size to be a multiple of 8 (as required by many encoders). + * + * @return The current size rounded. + */ + public Size round8() { + if (isMultipleOf8()) { + // Already a multiple of 8 + return this; + } + + boolean portrait = height > width; + int major = portrait ? height : width; + int minor = portrait ? width : height; + + major &= ~7; // round down to not exceed the initial size + minor = (minor + 4) & ~7; // round to the nearest to minimize aspect ratio distortion + if (minor > major) { + minor = major; + } + + int w = portrait ? minor : major; + int h = portrait ? major : minor; + return new Size(w, h); + } + + public boolean isMultipleOf8() { + return (width & 7) == 0 && (height & 7) == 0; + } + + public Rect toRect() { + return new Rect(0, 0, width, height); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Size size = (Size) o; + return width == size.width && height == size.height; + } + + @Override + public int hashCode() { + return Objects.hash(width, height); + } + + @Override + public String toString() { + return width + "x" + height; + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/Streamer.java b/server/src/main/java/com/genymobile/scrcpy/device/Streamer.java similarity index 97% rename from server/src/main/java/com/genymobile/scrcpy/Streamer.java rename to server/src/main/java/com/genymobile/scrcpy/device/Streamer.java index 8b6c9dcc..f54d0567 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Streamer.java +++ b/server/src/main/java/com/genymobile/scrcpy/device/Streamer.java @@ -1,4 +1,8 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.device; + +import com.genymobile.scrcpy.audio.AudioCodec; +import com.genymobile.scrcpy.util.Codec; +import com.genymobile.scrcpy.util.IO; import android.media.MediaCodec; diff --git a/server/src/main/java/com/genymobile/scrcpy/opengl/AffineOpenGLFilter.java b/server/src/main/java/com/genymobile/scrcpy/opengl/AffineOpenGLFilter.java new file mode 100644 index 00000000..7608a574 --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/opengl/AffineOpenGLFilter.java @@ -0,0 +1,135 @@ +package com.genymobile.scrcpy.opengl; + +import com.genymobile.scrcpy.util.AffineMatrix; + +import android.opengl.GLES11Ext; +import android.opengl.GLES20; + +import java.nio.FloatBuffer; + +public class AffineOpenGLFilter implements OpenGLFilter { + + private int program; + private FloatBuffer vertexBuffer; + private FloatBuffer texCoordsBuffer; + private final float[] userMatrix; + + private int vertexPosLoc; + private int texCoordsInLoc; + + private int texLoc; + private int texMatrixLoc; + private int userMatrixLoc; + + public AffineOpenGLFilter(AffineMatrix transform) { + userMatrix = transform.to4x4(); + } + + @Override + public void init() throws OpenGLException { + // @formatter:off + String vertexShaderCode = "#version 100\n" + + "attribute vec4 vertex_pos;\n" + + "attribute vec4 tex_coords_in;\n" + + "varying vec2 tex_coords;\n" + + "uniform mat4 tex_matrix;\n" + + "uniform mat4 user_matrix;\n" + + "void main() {\n" + + " gl_Position = vertex_pos;\n" + + " tex_coords = (tex_matrix * user_matrix * tex_coords_in).xy;\n" + + "}"; + + // @formatter:off + String fragmentShaderCode = "#version 100\n" + + "#extension GL_OES_EGL_image_external : require\n" + + "precision highp float;\n" + + "uniform samplerExternalOES tex;\n" + + "varying vec2 tex_coords;\n" + + "void main() {\n" + + " if (tex_coords.x >= 0.0 && tex_coords.x <= 1.0\n" + + " && tex_coords.y >= 0.0 && tex_coords.y <= 1.0) {\n" + + " gl_FragColor = texture2D(tex, tex_coords);\n" + + " } else {\n" + + " gl_FragColor = vec4(0.0);\n" + + " }\n" + + "}"; + + program = GLUtils.createProgram(vertexShaderCode, fragmentShaderCode); + if (program == 0) { + throw new OpenGLException("Cannot create OpenGL program"); + } + + float[] vertices = { + -1, -1, // Bottom-left + 1, -1, // Bottom-right + -1, 1, // Top-left + 1, 1, // Top-right + }; + + float[] texCoords = { + 0, 0, // Bottom-left + 1, 0, // Bottom-right + 0, 1, // Top-left + 1, 1, // Top-right + }; + + // OpenGL will fill the 3rd and 4th coordinates of the vec4 automatically with 0.0 and 1.0 respectively + vertexBuffer = GLUtils.createFloatBuffer(vertices); + texCoordsBuffer = GLUtils.createFloatBuffer(texCoords); + + vertexPosLoc = GLES20.glGetAttribLocation(program, "vertex_pos"); + assert vertexPosLoc != -1; + + texCoordsInLoc = GLES20.glGetAttribLocation(program, "tex_coords_in"); + assert texCoordsInLoc != -1; + + texLoc = GLES20.glGetUniformLocation(program, "tex"); + assert texLoc != -1; + + texMatrixLoc = GLES20.glGetUniformLocation(program, "tex_matrix"); + assert texMatrixLoc != -1; + + userMatrixLoc = GLES20.glGetUniformLocation(program, "user_matrix"); + assert userMatrixLoc != -1; + } + + @Override + public void draw(int textureId, float[] texMatrix) { + GLES20.glUseProgram(program); + GLUtils.checkGlError(); + + GLES20.glEnableVertexAttribArray(vertexPosLoc); + GLUtils.checkGlError(); + GLES20.glEnableVertexAttribArray(texCoordsInLoc); + GLUtils.checkGlError(); + + GLES20.glVertexAttribPointer(vertexPosLoc, 2, GLES20.GL_FLOAT, false, 0, vertexBuffer); + GLUtils.checkGlError(); + GLES20.glVertexAttribPointer(texCoordsInLoc, 2, GLES20.GL_FLOAT, false, 0, texCoordsBuffer); + GLUtils.checkGlError(); + + GLES20.glActiveTexture(GLES20.GL_TEXTURE0); + GLUtils.checkGlError(); + GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId); + GLUtils.checkGlError(); + GLES20.glUniform1i(texLoc, 0); + GLUtils.checkGlError(); + + GLES20.glUniformMatrix4fv(texMatrixLoc, 1, false, texMatrix, 0); + GLUtils.checkGlError(); + + GLES20.glUniformMatrix4fv(userMatrixLoc, 1, false, userMatrix, 0); + GLUtils.checkGlError(); + + GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT); + GLUtils.checkGlError(); + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4); + GLUtils.checkGlError(); + } + + @Override + public void release() { + GLES20.glDeleteProgram(program); + GLUtils.checkGlError(); + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/opengl/GLUtils.java b/server/src/main/java/com/genymobile/scrcpy/opengl/GLUtils.java new file mode 100644 index 00000000..72a3f400 --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/opengl/GLUtils.java @@ -0,0 +1,124 @@ +package com.genymobile.scrcpy.opengl; + +import com.genymobile.scrcpy.BuildConfig; +import com.genymobile.scrcpy.util.Ln; + +import android.opengl.GLES20; +import android.opengl.GLU; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.FloatBuffer; + +public final class GLUtils { + + private static final boolean DEBUG = BuildConfig.DEBUG; + + private GLUtils() { + // not instantiable + } + + public static int createProgram(String vertexSource, String fragmentSource) { + int vertexShader = createShader(GLES20.GL_VERTEX_SHADER, vertexSource); + if (vertexShader == 0) { + return 0; + } + + int fragmentShader = createShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource); + if (fragmentShader == 0) { + GLES20.glDeleteShader(vertexShader); + return 0; + } + + int program = GLES20.glCreateProgram(); + if (program == 0) { + GLES20.glDeleteShader(fragmentShader); + GLES20.glDeleteShader(vertexShader); + return 0; + } + + GLES20.glAttachShader(program, vertexShader); + checkGlError(); + GLES20.glAttachShader(program, fragmentShader); + checkGlError(); + GLES20.glLinkProgram(program); + checkGlError(); + + int[] linkStatus = new int[1]; + GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0); + if (linkStatus[0] == 0) { + Ln.e("Could not link program: " + GLES20.glGetProgramInfoLog(program)); + GLES20.glDeleteProgram(program); + GLES20.glDeleteShader(fragmentShader); + GLES20.glDeleteShader(vertexShader); + return 0; + } + + return program; + } + + public static int createShader(int type, String source) { + int shader = GLES20.glCreateShader(type); + if (shader == 0) { + Ln.e(getGlErrorMessage("Could not create shader")); + return 0; + } + + GLES20.glShaderSource(shader, source); + GLES20.glCompileShader(shader); + + int[] compileStatus = new int[1]; + GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compileStatus, 0); + if (compileStatus[0] == 0) { + Ln.e("Could not compile " + getShaderTypeString(type) + ": " + GLES20.glGetShaderInfoLog(shader)); + GLES20.glDeleteShader(shader); + return 0; + } + + return shader; + } + + private static String getShaderTypeString(int type) { + switch (type) { + case GLES20.GL_VERTEX_SHADER: + return "vertex shader"; + case GLES20.GL_FRAGMENT_SHADER: + return "fragment shader"; + default: + return "shader"; + } + } + + /** + * Throws a runtime exception if {@link GLES20#glGetError()} returns an error (useful for debugging). + */ + public static void checkGlError() { + if (DEBUG) { + int error = GLES20.glGetError(); + if (error != GLES20.GL_NO_ERROR) { + throw new RuntimeException(toErrorString(error)); + } + } + } + + public static String getGlErrorMessage(String userError) { + int glError = GLES20.glGetError(); + if (glError == GLES20.GL_NO_ERROR) { + return userError; + } + + return userError + " (" + toErrorString(glError) + ")"; + } + + private static String toErrorString(int glError) { + String errorString = GLU.gluErrorString(glError); + return "glError 0x" + Integer.toHexString(glError) + " " + errorString; + } + + public static FloatBuffer createFloatBuffer(float[] values) { + FloatBuffer fb = ByteBuffer.allocateDirect(values.length * 4).order(ByteOrder.nativeOrder()).asFloatBuffer(); + fb.put(values); + fb.position(0); + return fb; + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/opengl/OpenGLException.java b/server/src/main/java/com/genymobile/scrcpy/opengl/OpenGLException.java new file mode 100644 index 00000000..cbc9539b --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/opengl/OpenGLException.java @@ -0,0 +1,13 @@ +package com.genymobile.scrcpy.opengl; + +import java.io.IOException; + +public class OpenGLException extends IOException { + public OpenGLException(String message) { + super(message); + } + + public OpenGLException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/opengl/OpenGLFilter.java b/server/src/main/java/com/genymobile/scrcpy/opengl/OpenGLFilter.java new file mode 100644 index 00000000..6f27777e --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/opengl/OpenGLFilter.java @@ -0,0 +1,21 @@ +package com.genymobile.scrcpy.opengl; + +public interface OpenGLFilter { + + /** + * Initialize the OpenGL filter (typically compile the shaders and create the program). + * + * @throws OpenGLException if an initialization error occurs + */ + void init() throws OpenGLException; + + /** + * Render a frame (call for each frame). + */ + void draw(int textureId, float[] texMatrix); + + /** + * Release resources. + */ + void release(); +} diff --git a/server/src/main/java/com/genymobile/scrcpy/opengl/OpenGLRunner.java b/server/src/main/java/com/genymobile/scrcpy/opengl/OpenGLRunner.java new file mode 100644 index 00000000..86bd1859 --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/opengl/OpenGLRunner.java @@ -0,0 +1,258 @@ +package com.genymobile.scrcpy.opengl; + +import com.genymobile.scrcpy.device.Size; + +import android.graphics.SurfaceTexture; +import android.opengl.EGL14; +import android.opengl.EGLConfig; +import android.opengl.EGLContext; +import android.opengl.EGLDisplay; +import android.opengl.EGLExt; +import android.opengl.EGLSurface; +import android.opengl.GLES11Ext; +import android.opengl.GLES20; +import android.os.Handler; +import android.os.HandlerThread; +import android.view.Surface; + +import java.util.concurrent.Semaphore; + +public final class OpenGLRunner { + + private static HandlerThread handlerThread; + private static Handler handler; + private static boolean quit; + + private EGLDisplay eglDisplay; + private EGLContext eglContext; + private EGLSurface eglSurface; + + private final OpenGLFilter filter; + private final float[] overrideTransformMatrix; + + private SurfaceTexture surfaceTexture; + private Surface inputSurface; + private int textureId; + + private boolean stopped; + + public OpenGLRunner(OpenGLFilter filter, float[] overrideTransformMatrix) { + this.filter = filter; + this.overrideTransformMatrix = overrideTransformMatrix; + } + + public OpenGLRunner(OpenGLFilter filter) { + this(filter, null); + } + + public static synchronized void initOnce() { + if (handlerThread == null) { + if (quit) { + throw new IllegalStateException("Could not init OpenGLRunner after it is quit"); + } + handlerThread = new HandlerThread("OpenGLRunner"); + handlerThread.start(); + handler = new Handler(handlerThread.getLooper()); + } + } + + public static void quit() { + HandlerThread thread; + synchronized (OpenGLRunner.class) { + thread = handlerThread; + quit = true; + } + if (thread != null) { + thread.quitSafely(); + } + } + + public static void join() throws InterruptedException { + HandlerThread thread; + synchronized (OpenGLRunner.class) { + thread = handlerThread; + } + if (thread != null) { + thread.join(); + } + } + + public Surface start(Size inputSize, Size outputSize, Surface outputSurface) throws OpenGLException { + initOnce(); + + // Simulate CompletableFuture, but working for all Android versions + final Semaphore sem = new Semaphore(0); + Throwable[] throwableRef = new Throwable[1]; + + // The whole OpenGL execution must be performed on a Handler, so that SurfaceTexture.setOnFrameAvailableListener() works correctly. + // See + handler.post(() -> { + try { + run(inputSize, outputSize, outputSurface); + } catch (Throwable throwable) { + throwableRef[0] = throwable; + } finally { + sem.release(); + } + }); + + try { + sem.acquire(); + } catch (InterruptedException e) { + // Behave as if this method call was synchronous + Thread.currentThread().interrupt(); + } + + Throwable throwable = throwableRef[0]; + if (throwable != null) { + if (throwable instanceof OpenGLException) { + throw (OpenGLException) throwable; + } + throw new OpenGLException("Asynchronous OpenGL runner init failed", throwable); + } + + // Synchronization is ok: inputSurface is written before sem.release() and read after sem.acquire() + return inputSurface; + } + + private void run(Size inputSize, Size outputSize, Surface outputSurface) throws OpenGLException { + eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY); + if (eglDisplay == EGL14.EGL_NO_DISPLAY) { + throw new OpenGLException("Unable to get EGL14 display"); + } + + int[] version = new int[2]; + if (!EGL14.eglInitialize(eglDisplay, version, 0, version, 1)) { + throw new OpenGLException("Unable to initialize EGL14"); + } + + // @formatter:off + int[] attribList = { + EGL14.EGL_RED_SIZE, 8, + EGL14.EGL_GREEN_SIZE, 8, + EGL14.EGL_BLUE_SIZE, 8, + EGL14.EGL_ALPHA_SIZE, 8, + EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT, + EGL14.EGL_NONE + }; + + EGLConfig[] configs = new EGLConfig[1]; + int[] numConfigs = new int[1]; + EGL14.eglChooseConfig(eglDisplay, attribList, 0, configs, 0, configs.length, numConfigs, 0); + if (numConfigs[0] <= 0) { + EGL14.eglTerminate(eglDisplay); + throw new OpenGLException("Unable to find ES2 EGL config"); + } + EGLConfig eglConfig = configs[0]; + + // @formatter:off + int[] contextAttribList = { + EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, + EGL14.EGL_NONE + }; + eglContext = EGL14.eglCreateContext(eglDisplay, eglConfig, EGL14.EGL_NO_CONTEXT, contextAttribList, 0); + if (eglContext == null) { + EGL14.eglTerminate(eglDisplay); + throw new OpenGLException("Failed to create EGL context"); + } + + int[] surfaceAttribList = { + EGL14.EGL_NONE + }; + eglSurface = EGL14.eglCreateWindowSurface(eglDisplay, eglConfig, outputSurface, surfaceAttribList, 0); + if (eglSurface == null) { + EGL14.eglDestroyContext(eglDisplay, eglContext); + EGL14.eglTerminate(eglDisplay); + throw new OpenGLException("Failed to create EGL window surface"); + } + + if (!EGL14.eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext)) { + EGL14.eglDestroySurface(eglDisplay, eglSurface); + EGL14.eglDestroyContext(eglDisplay, eglContext); + EGL14.eglTerminate(eglDisplay); + throw new OpenGLException("Failed to make EGL context current"); + } + + int[] textures = new int[1]; + GLES20.glGenTextures(1, textures, 0); + GLUtils.checkGlError(); + textureId = textures[0]; + + GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR); + GLUtils.checkGlError(); + GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR); + GLUtils.checkGlError(); + GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE); + GLUtils.checkGlError(); + GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE); + GLUtils.checkGlError(); + + surfaceTexture = new SurfaceTexture(textureId); + surfaceTexture.setDefaultBufferSize(inputSize.getWidth(), inputSize.getHeight()); + inputSurface = new Surface(surfaceTexture); + + filter.init(); + + surfaceTexture.setOnFrameAvailableListener(surfaceTexture -> { + if (stopped) { + // Make sure to never render after resources have been released + return; + } + + render(outputSize); + }, handler); + } + + private void render(Size outputSize) { + GLES20.glViewport(0, 0, outputSize.getWidth(), outputSize.getHeight()); + GLUtils.checkGlError(); + + surfaceTexture.updateTexImage(); + + float[] matrix; + if (overrideTransformMatrix != null) { + matrix = overrideTransformMatrix; + } else { + matrix = new float[16]; + surfaceTexture.getTransformMatrix(matrix); + } + + filter.draw(textureId, matrix); + + EGLExt.eglPresentationTimeANDROID(eglDisplay, eglSurface, surfaceTexture.getTimestamp()); + EGL14.eglSwapBuffers(eglDisplay, eglSurface); + } + + public void stopAndRelease() { + final Semaphore sem = new Semaphore(0); + + handler.post(() -> { + stopped = true; + surfaceTexture.setOnFrameAvailableListener(null, handler); + + filter.release(); + + int[] textures = {textureId}; + GLES20.glDeleteTextures(1, textures, 0); + GLUtils.checkGlError(); + + EGL14.eglDestroySurface(eglDisplay, eglSurface); + EGL14.eglDestroyContext(eglDisplay, eglContext); + EGL14.eglTerminate(eglDisplay); + eglDisplay = EGL14.EGL_NO_DISPLAY; + eglContext = EGL14.EGL_NO_CONTEXT; + eglSurface = EGL14.EGL_NO_SURFACE; + surfaceTexture.release(); + inputSurface.release(); + + sem.release(); + }); + + try { + sem.acquire(); + } catch (InterruptedException e) { + // Behave as if this method call was synchronous + Thread.currentThread().interrupt(); + } + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/util/AffineMatrix.java b/server/src/main/java/com/genymobile/scrcpy/util/AffineMatrix.java new file mode 100644 index 00000000..0db74af6 --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/util/AffineMatrix.java @@ -0,0 +1,368 @@ +package com.genymobile.scrcpy.util; + +import com.genymobile.scrcpy.device.Point; +import com.genymobile.scrcpy.device.Size; + +/** + * Represents a 2D affine transform (a 3x3 matrix): + * + *

+ *     / a c e \
+ *     | b d f |
+ *     \ 0 0 1 /
+ * 
+ *

+ * Or, a 4x4 matrix if we add a z axis: + * + *

+ *     / a c 0 e \
+ *     | b d 0 f |
+ *     | 0 0 1 0 |
+ *     \ 0 0 0 1 /
+ * 
+ */ +public class AffineMatrix { + + private final double a, b, c, d, e, f; + + /** + * The identity matrix. + */ + public static final AffineMatrix IDENTITY = new AffineMatrix(1, 0, 0, 1, 0, 0); + + /** + * Create a new matrix: + * + *
+     *     / a c e \
+     *     | b d f |
+     *     \ 0 0 1 /
+     * 
+ */ + public AffineMatrix(double a, double b, double c, double d, double e, double f) { + this.a = a; + this.b = b; + this.c = c; + this.d = d; + this.e = e; + this.f = f; + } + + @Override + public String toString() { + return "[" + a + ", " + c + ", " + e + "; " + b + ", " + d + ", " + f + "]"; + } + + /** + * Return a matrix which converts from Normalized Device Coordinates to pixels. + * + * @param size the target size + * @return the transform matrix + */ + public static AffineMatrix ndcFromPixels(Size size) { + double w = size.getWidth(); + double h = size.getHeight(); + return new AffineMatrix(1 / w, 0, 0, -1 / h, 0, 1); + } + + /** + * Return a matrix which converts from pixels to Normalized Device Coordinates. + * + * @param size the source size + * @return the transform matrix + */ + public static AffineMatrix ndcToPixels(Size size) { + double w = size.getWidth(); + double h = size.getHeight(); + return new AffineMatrix(w, 0, 0, -h, 0, h); + } + + /** + * Apply the transform to a point ({@code this} should be a matrix converted to pixels coordinates via {@link #ndcToPixels(Size)}). + * + * @param point the source point + * @return the converted point + */ + public Point apply(Point point) { + int x = point.getX(); + int y = point.getY(); + int xx = (int) (a * x + c * y + e); + int yy = (int) (b * x + d * y + f); + return new Point(xx, yy); + } + + /** + * Compute this * rhs. + * + * @param rhs the matrix to multiply + * @return the product + */ + public AffineMatrix multiply(AffineMatrix rhs) { + if (rhs == null) { + // For convenience + return this; + } + + double aa = this.a * rhs.a + this.c * rhs.b; + double bb = this.b * rhs.a + this.d * rhs.b; + double cc = this.a * rhs.c + this.c * rhs.d; + double dd = this.b * rhs.c + this.d * rhs.d; + double ee = this.a * rhs.e + this.c * rhs.f + this.e; + double ff = this.b * rhs.e + this.d * rhs.f + this.f; + return new AffineMatrix(aa, bb, cc, dd, ee, ff); + } + + /** + * Multiply all matrices from left to right, ignoring any {@code null} matrix (for convenience). + * + * @param matrices the matrices + * @return the product + */ + public static AffineMatrix multiplyAll(AffineMatrix... matrices) { + AffineMatrix result = null; + for (AffineMatrix matrix : matrices) { + if (result == null) { + result = matrix; + } else { + result = result.multiply(matrix); + } + } + return result; + } + + /** + * Invert the matrix. + * + * @return the inverse matrix (or {@code null} if not invertible). + */ + public AffineMatrix invert() { + // The 3x3 matrix M can be decomposed into M = M1 * M2: + // M1 M2 + // / 1 0 e \ / a c 0 \ + // | 0 1 f | * | b d 0 | + // \ 0 0 1 / \ 0 0 1 / + // + // The inverse of an invertible 2x2 matrix is given by this formula: + // + // / A B \⁻¹ 1 / D -B \ + // \ C D / = ----- \ -C A / + // AD-BC + // + // Let B=c and C=b (to apply the general formula with the same letters). + // + // M⁻¹ = (M1 * M2)⁻¹ = M2⁻¹ * M1⁻¹ + // + // M2⁻¹ M1⁻¹ + // /----------------\ + // 1 / d -B 0 \ / 1 0 -e \ + // = ----- | -C a 0 | * | 0 1 -f | + // ad-BC \ 0 0 1 / \ 0 0 1 / + // + // With the original letters: + // + // 1 / d -c 0 \ / 1 0 -e \ + // M⁻¹ = ----- | -b a 0 | * | 0 1 -f | + // ad-cb \ 0 0 1 / \ 0 0 1 / + // + // 1 / d -c cf-de \ + // = ----- | -b a be-af | + // ad-cb \ 0 0 1 / + + double det = a * d - c * b; + if (det == 0) { + // Not invertible + return null; + } + + double aa = d / det; + double bb = -b / det; + double cc = -c / det; + double dd = a / det; + double ee = (c * f - d * e) / det; + double ff = (b * e - a * f) / det; + + return new AffineMatrix(aa, bb, cc, dd, ee, ff); + } + + /** + * Return this transform applied from the center (0.5, 0.5). + * + * @return the resulting matrix + */ + public AffineMatrix fromCenter() { + return translate(0.5, 0.5).multiply(this).multiply(translate(-0.5, -0.5)); + } + + /** + * Return this transform with the specified aspect ratio. + * + * @param ar the aspect ratio + * @return the resulting matrix + */ + public AffineMatrix withAspectRatio(double ar) { + return scale(1 / ar, 1).multiply(this).multiply(scale(ar, 1)); + } + + /** + * Return this transform with the specified aspect ratio. + * + * @param size the size describing the aspect ratio + * @return the transform + */ + public AffineMatrix withAspectRatio(Size size) { + double ar = (double) size.getWidth() / size.getHeight(); + return withAspectRatio(ar); + } + + /** + * Return a translation matrix. + * + * @param x the horizontal translation + * @param y the vertical translation + * @return the matrix + */ + public static AffineMatrix translate(double x, double y) { + return new AffineMatrix(1, 0, 0, 1, x, y); + } + + /** + * Return a scaling matrix. + * + * @param x the horizontal scaling + * @param y the vertical scaling + * @return the matrix + */ + public static AffineMatrix scale(double x, double y) { + return new AffineMatrix(x, 0, 0, y, 0, 0); + } + + /** + * Return a scaling matrix. + * + * @param from the source size + * @param to the destination size + * @return the matrix + */ + public static AffineMatrix scale(Size from, Size to) { + double scaleX = (double) to.getWidth() / from.getWidth(); + double scaleY = (double) to.getHeight() / from.getHeight(); + return scale(scaleX, scaleY); + } + + /** + * Return a matrix applying a "reframing" (cropping a rectangle). + *

+ * (x, y) is the bottom-left corner, (w, h) is the size of the rectangle. + * + * @param x horizontal coordinate (increasing to the right) + * @param y vertical coordinate (increasing upwards) + * @param w width + * @param h height + * @return the matrix + */ + public static AffineMatrix reframe(double x, double y, double w, double h) { + if (w == 0 || h == 0) { + throw new IllegalArgumentException("Cannot reframe to an empty area: " + w + "x" + h); + } + return scale(1 / w, 1 / h).multiply(translate(-x, -y)); + } + + /** + * Return an orthogonal rotation matrix. + * + * @param ccwRotation the counter-clockwise rotation + * @return the matrix + */ + public static AffineMatrix rotateOrtho(int ccwRotation) { + switch (ccwRotation) { + case 0: + return IDENTITY; + case 1: + // 90° counter-clockwise + return new AffineMatrix(0, 1, -1, 0, 1, 0); + case 2: + // 180° + return new AffineMatrix(-1, 0, 0, -1, 1, 1); + case 3: + // 90° clockwise + return new AffineMatrix(0, -1, 1, 0, 0, 1); + default: + throw new IllegalArgumentException("Invalid rotation: " + ccwRotation); + } + } + + /** + * Return an horizontal flip matrix. + * + * @return the matrix + */ + public static AffineMatrix hflip() { + return new AffineMatrix(-1, 0, 0, 1, 1, 0); + } + + /** + * Return a vertical flip matrix. + * + * @return the matrix + */ + public static AffineMatrix vflip() { + return new AffineMatrix(1, 0, 0, -1, 0, 1); + } + + /** + * Return a rotation matrix. + * + * @param ccwDegrees the angle, in degrees (counter-clockwise) + * @return the matrix + */ + public static AffineMatrix rotate(double ccwDegrees) { + double radians = Math.toRadians(ccwDegrees); + double cos = Math.cos(radians); + double sin = Math.sin(radians); + return new AffineMatrix(cos, sin, -sin, cos, 0, 0); + } + + /** + * Export this affine transform to a 4x4 column-major order matrix. + * + * @param matrix output 4x4 matrix + */ + public void to4x4(float[] matrix) { + // matrix is a 4x4 matrix in column-major order + + // Column 0 + matrix[0] = (float) a; + matrix[1] = (float) b; + matrix[2] = 0; + matrix[3] = 0; + + // Column 1 + matrix[4] = (float) c; + matrix[5] = (float) d; + matrix[6] = 0; + matrix[7] = 0; + + // Column 2 + matrix[8] = 0; + matrix[9] = 0; + matrix[10] = 1; + matrix[11] = 0; + + // Column 3 + matrix[12] = (float) e; + matrix[13] = (float) f; + matrix[14] = 0; + matrix[15] = 1; + } + + /** + * Export this affine transform to a 4x4 column-major order matrix. + * + * @return 4x4 matrix + */ + public float[] to4x4() { + float[] matrix = new float[16]; + to4x4(matrix); + return matrix; + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/Binary.java b/server/src/main/java/com/genymobile/scrcpy/util/Binary.java similarity index 96% rename from server/src/main/java/com/genymobile/scrcpy/Binary.java rename to server/src/main/java/com/genymobile/scrcpy/util/Binary.java index 29534f59..f46ba695 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Binary.java +++ b/server/src/main/java/com/genymobile/scrcpy/util/Binary.java @@ -1,4 +1,4 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.util; public final class Binary { private Binary() { diff --git a/server/src/main/java/com/genymobile/scrcpy/util/Codec.java b/server/src/main/java/com/genymobile/scrcpy/util/Codec.java new file mode 100644 index 00000000..b350409b --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/util/Codec.java @@ -0,0 +1,24 @@ +package com.genymobile.scrcpy.util; + +import android.media.MediaCodec; + +public interface Codec { + + enum Type { + VIDEO, + AUDIO, + } + + Type getType(); + + int getId(); + + String getName(); + + String getMimeType(); + + static String getMimeType(MediaCodec codec) { + String[] types = codec.getCodecInfo().getSupportedTypes(); + return types.length > 0 ? types[0] : null; + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/CodecOption.java b/server/src/main/java/com/genymobile/scrcpy/util/CodecOption.java similarity index 98% rename from server/src/main/java/com/genymobile/scrcpy/CodecOption.java rename to server/src/main/java/com/genymobile/scrcpy/util/CodecOption.java index 22c45a90..bed2be9a 100644 --- a/server/src/main/java/com/genymobile/scrcpy/CodecOption.java +++ b/server/src/main/java/com/genymobile/scrcpy/util/CodecOption.java @@ -1,4 +1,4 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.util; import java.util.ArrayList; import java.util.List; diff --git a/server/src/main/java/com/genymobile/scrcpy/util/CodecUtils.java b/server/src/main/java/com/genymobile/scrcpy/util/CodecUtils.java new file mode 100644 index 00000000..3a01256a --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/util/CodecUtils.java @@ -0,0 +1,38 @@ +package com.genymobile.scrcpy.util; + +import android.media.MediaCodecInfo; +import android.media.MediaCodecList; +import android.media.MediaFormat; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public final class CodecUtils { + + private CodecUtils() { + // not instantiable + } + + public static void setCodecOption(MediaFormat format, String key, Object value) { + if (value instanceof Integer) { + format.setInteger(key, (Integer) value); + } else if (value instanceof Long) { + format.setLong(key, (Long) value); + } else if (value instanceof Float) { + format.setFloat(key, (Float) value); + } else if (value instanceof String) { + format.setString(key, (String) value); + } + } + + public static MediaCodecInfo[] getEncoders(MediaCodecList codecs, String mimeType) { + List result = new ArrayList<>(); + for (MediaCodecInfo codecInfo : codecs.getCodecInfos()) { + if (codecInfo.isEncoder() && Arrays.asList(codecInfo.getSupportedTypes()).contains(mimeType)) { + result.add(codecInfo); + } + } + return result.toArray(new MediaCodecInfo[result.size()]); + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/Command.java b/server/src/main/java/com/genymobile/scrcpy/util/Command.java similarity index 97% rename from server/src/main/java/com/genymobile/scrcpy/Command.java rename to server/src/main/java/com/genymobile/scrcpy/util/Command.java index 362504ff..b26158e6 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Command.java +++ b/server/src/main/java/com/genymobile/scrcpy/util/Command.java @@ -1,4 +1,4 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.util; import java.io.IOException; import java.util.Arrays; diff --git a/server/src/main/java/com/genymobile/scrcpy/HandlerExecutor.java b/server/src/main/java/com/genymobile/scrcpy/util/HandlerExecutor.java similarity index 93% rename from server/src/main/java/com/genymobile/scrcpy/HandlerExecutor.java rename to server/src/main/java/com/genymobile/scrcpy/util/HandlerExecutor.java index 1f5f0a4f..03309989 100644 --- a/server/src/main/java/com/genymobile/scrcpy/HandlerExecutor.java +++ b/server/src/main/java/com/genymobile/scrcpy/util/HandlerExecutor.java @@ -1,4 +1,4 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.util; import android.os.Handler; diff --git a/server/src/main/java/com/genymobile/scrcpy/IO.java b/server/src/main/java/com/genymobile/scrcpy/util/IO.java similarity index 58% rename from server/src/main/java/com/genymobile/scrcpy/IO.java rename to server/src/main/java/com/genymobile/scrcpy/util/IO.java index 4a55c152..16ddaedd 100644 --- a/server/src/main/java/com/genymobile/scrcpy/IO.java +++ b/server/src/main/java/com/genymobile/scrcpy/util/IO.java @@ -1,5 +1,9 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.util; +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.BuildConfig; + +import android.os.Build; import android.system.ErrnoException; import android.system.Os; import android.system.OsConstants; @@ -15,23 +19,38 @@ public final class IO { // not instantiable } - public static void writeFully(FileDescriptor fd, ByteBuffer from) throws IOException { - // ByteBuffer position is not updated as expected by Os.write() on old Android versions, so - // count the remaining bytes manually. - // See . - int remaining = from.remaining(); - while (remaining > 0) { + private static int write(FileDescriptor fd, ByteBuffer from) throws IOException { + while (true) { try { - int w = Os.write(fd, from); + return Os.write(fd, from); + } catch (ErrnoException e) { + if (e.errno != OsConstants.EINTR) { + throw new IOException(e); + } + } + } + } + + public static void writeFully(FileDescriptor fd, ByteBuffer from) throws IOException { + if (Build.VERSION.SDK_INT >= AndroidVersions.API_23_ANDROID_6_0) { + while (from.hasRemaining()) { + write(fd, from); + } + } else { + // ByteBuffer position is not updated as expected by Os.write() on old Android versions, so + // handle the position and the remaining bytes manually. + // See . + int position = from.position(); + int remaining = from.remaining(); + while (remaining > 0) { + int w = write(fd, from); if (BuildConfig.DEBUG && w < 0) { // w should not be negative, since an exception is thrown on error throw new AssertionError("Os.write() returned a negative value (" + w + ")"); } remaining -= w; - } catch (ErrnoException e) { - if (e.errno != OsConstants.EINTR) { - throw new IOException(e); - } + position += w; + from.position(position); } } } @@ -53,4 +72,8 @@ public final class IO { Throwable cause = e.getCause(); return cause instanceof ErrnoException && ((ErrnoException) cause).errno == OsConstants.EPIPE; } + + public static boolean isBrokenPipe(Exception e) { + return e instanceof IOException && isBrokenPipe((IOException) e); + } } diff --git a/server/src/main/java/com/genymobile/scrcpy/Ln.java b/server/src/main/java/com/genymobile/scrcpy/util/Ln.java similarity index 98% rename from server/src/main/java/com/genymobile/scrcpy/Ln.java rename to server/src/main/java/com/genymobile/scrcpy/util/Ln.java index cdd57b9f..c0700125 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Ln.java +++ b/server/src/main/java/com/genymobile/scrcpy/util/Ln.java @@ -1,4 +1,4 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.util; import android.util.Log; @@ -19,7 +19,7 @@ public final class Ln { private static final PrintStream CONSOLE_OUT = new PrintStream(new FileOutputStream(FileDescriptor.out)); private static final PrintStream CONSOLE_ERR = new PrintStream(new FileOutputStream(FileDescriptor.err)); - enum Level { + public enum Level { VERBOSE, DEBUG, INFO, WARN, ERROR } diff --git a/server/src/main/java/com/genymobile/scrcpy/util/LogUtils.java b/server/src/main/java/com/genymobile/scrcpy/util/LogUtils.java new file mode 100644 index 00000000..4f8927ec --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/util/LogUtils.java @@ -0,0 +1,268 @@ +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; + +public final class LogUtils { + + private LogUtils() { + // not instantiable + } + + 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() { + return buildEncoderListMessage("audio", AudioCodec.values()); + } + + @TargetApi(AndroidVersions.API_29_ANDROID_10) + private static String getHwCodecType(MediaCodecInfo info) { + if (info.isSoftwareOnly()) { + return "sw"; + } + if (info.isHardwareAccelerated()) { + return "hw"; + } + return "hybrid"; + } + + public static String buildDisplayListMessage() { + StringBuilder builder = new StringBuilder("List of displays:"); + DisplayManager displayManager = ServiceManager.getDisplayManager(); + int[] displayIds = displayManager.getDisplayIds(); + if (displayIds == null || displayIds.length == 0) { + builder.append("\n (none)"); + } else { + for (int id : displayIds) { + builder.append("\n --display-id=").append(id).append(" ("); + DisplayInfo displayInfo = displayManager.getDisplayInfo(id); + if (displayInfo != null) { + Size size = displayInfo.getSize(); + builder.append(size.getWidth()).append("x").append(size.getHeight()); + } else { + builder.append("size unknown"); + } + builder.append(")"); + } + } + return builder.toString(); + } + + private static String getCameraFacingName(int facing) { + switch (facing) { + case CameraCharacteristics.LENS_FACING_FRONT: + return "front"; + case CameraCharacteristics.LENS_FACING_BACK: + return "back"; + case CameraCharacteristics.LENS_FACING_EXTERNAL: + return "external"; + default: + return "unknown"; + } + } + + private static boolean isCameraBackwardCompatible(CameraCharacteristics characteristics) { + int[] capabilities = characteristics.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES); + if (capabilities == null) { + return false; + } + + for (int capability : capabilities) { + if (capability == CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_BACKWARD_COMPATIBLE) { + return true; + } + } + + return false; + } + + public static String buildCameraListMessage(boolean includeSizes) { + StringBuilder builder = new StringBuilder("List of cameras:"); + CameraManager cameraManager = ServiceManager.getCameraManager(); + try { + String[] cameraIds = cameraManager.getCameraIdList(); + if (cameraIds.length == 0) { + builder.append("\n (none)"); + } else { + for (String id : cameraIds) { + CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(id); + + if (!isCameraBackwardCompatible(characteristics)) { + // Ignore depth cameras as suggested by official documentation + // + continue; + } + + builder.append("\n --camera-id=").append(id); + + int facing = characteristics.get(CameraCharacteristics.LENS_FACING); + builder.append(" (").append(getCameraFacingName(facing)).append(", "); + + Rect activeSize = characteristics.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE); + builder.append(activeSize.width()).append("x").append(activeSize.height()); + + try { + // Capture frame rates for low-FPS mode are the same for every resolution + Range[] lowFpsRanges = characteristics.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES); + if (lowFpsRanges != null) { + SortedSet uniqueLowFps = getUniqueSet(lowFpsRanges); + builder.append(", fps=").append(uniqueLowFps); + } + } catch (Exception e) { + // Some devices may provide invalid ranges, causing an IllegalArgumentException "lower must be less than or equal to upper" + Ln.w("Could not get available frame rates for camera " + id, e); + } + + builder.append(')'); + + if (includeSizes) { + StreamConfigurationMap configs = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); + + android.util.Size[] sizes = configs.getOutputSizes(MediaCodec.class); + if (sizes == null || sizes.length == 0) { + builder.append("\n (none)"); + } else { + for (android.util.Size size : sizes) { + builder.append("\n - ").append(size.getWidth()).append('x').append(size.getHeight()); + } + } + + android.util.Size[] highSpeedSizes = configs.getHighSpeedVideoSizes(); + if (highSpeedSizes != null && highSpeedSizes.length > 0) { + builder.append("\n High speed capture (--camera-high-speed):"); + for (android.util.Size size : highSpeedSizes) { + Range[] highFpsRanges = configs.getHighSpeedVideoFpsRanges(); + SortedSet uniqueHighFps = getUniqueSet(highFpsRanges); + builder.append("\n - ").append(size.getWidth()).append("x").append(size.getHeight()); + builder.append(" (fps=").append(uniqueHighFps).append(')'); + } + } + } + } + } + } catch (CameraAccessException e) { + builder.append("\n (access denied)"); + } + return builder.toString(); + } + + private static SortedSet getUniqueSet(Range[] ranges) { + SortedSet set = new TreeSet<>(); + for (Range range : ranges) { + set.add(range.getUpper()); + } + return set; + } + + + public static String buildAppListMessage() { + List apps = Device.listApps(); + return buildAppListMessage("List of apps:", apps); + } + + @SuppressLint("QueryPermissionsNeeded") + public static String buildAppListMessage(String title, List 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(); + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/Settings.java b/server/src/main/java/com/genymobile/scrcpy/util/Settings.java similarity index 91% rename from server/src/main/java/com/genymobile/scrcpy/Settings.java rename to server/src/main/java/com/genymobile/scrcpy/util/Settings.java index 1b5e5f98..e6465525 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Settings.java +++ b/server/src/main/java/com/genymobile/scrcpy/util/Settings.java @@ -1,5 +1,6 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.util; +import com.genymobile.scrcpy.AndroidVersions; import com.genymobile.scrcpy.wrappers.ContentProvider; import com.genymobile.scrcpy.wrappers.ServiceManager; @@ -34,7 +35,7 @@ public final class Settings { } public static String getValue(String table, String key) throws SettingsException { - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { + if (Build.VERSION.SDK_INT <= AndroidVersions.API_30_ANDROID_11) { // on Android >= 12, it always fails: try (ContentProvider provider = ServiceManager.getActivityManager().createSettingsProvider()) { return provider.getValue(table, key); @@ -47,7 +48,7 @@ public final class Settings { } public static void putValue(String table, String key, String value) throws SettingsException { - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { + if (Build.VERSION.SDK_INT <= AndroidVersions.API_30_ANDROID_11) { // on Android >= 12, it always fails: try (ContentProvider provider = ServiceManager.getActivityManager().createSettingsProvider()) { provider.putValue(table, key, value); @@ -60,7 +61,7 @@ public final class Settings { } public static String getAndPutValue(String table, String key, String value) throws SettingsException { - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { + if (Build.VERSION.SDK_INT <= AndroidVersions.API_30_ANDROID_11) { // on Android >= 12, it always fails: try (ContentProvider provider = ServiceManager.getActivityManager().createSettingsProvider()) { String oldValue = provider.getValue(table, key); diff --git a/server/src/main/java/com/genymobile/scrcpy/SettingsException.java b/server/src/main/java/com/genymobile/scrcpy/util/SettingsException.java similarity index 92% rename from server/src/main/java/com/genymobile/scrcpy/SettingsException.java rename to server/src/main/java/com/genymobile/scrcpy/util/SettingsException.java index 36ef63ee..87fa3884 100644 --- a/server/src/main/java/com/genymobile/scrcpy/SettingsException.java +++ b/server/src/main/java/com/genymobile/scrcpy/util/SettingsException.java @@ -1,4 +1,4 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.util; public class SettingsException extends Exception { private static String createMessage(String method, String table, String key, String value) { diff --git a/server/src/main/java/com/genymobile/scrcpy/StringUtils.java b/server/src/main/java/com/genymobile/scrcpy/util/StringUtils.java similarity index 94% rename from server/src/main/java/com/genymobile/scrcpy/StringUtils.java rename to server/src/main/java/com/genymobile/scrcpy/util/StringUtils.java index dac05466..8b19ca3d 100644 --- a/server/src/main/java/com/genymobile/scrcpy/StringUtils.java +++ b/server/src/main/java/com/genymobile/scrcpy/util/StringUtils.java @@ -1,4 +1,4 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.util; public final class StringUtils { private StringUtils() { diff --git a/server/src/main/java/com/genymobile/scrcpy/CameraAspectRatio.java b/server/src/main/java/com/genymobile/scrcpy/video/CameraAspectRatio.java similarity index 96% rename from server/src/main/java/com/genymobile/scrcpy/CameraAspectRatio.java rename to server/src/main/java/com/genymobile/scrcpy/video/CameraAspectRatio.java index 4fdf4c74..bf1cba5d 100644 --- a/server/src/main/java/com/genymobile/scrcpy/CameraAspectRatio.java +++ b/server/src/main/java/com/genymobile/scrcpy/video/CameraAspectRatio.java @@ -1,4 +1,4 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.video; public final class CameraAspectRatio { private static final float SENSOR = -1; diff --git a/server/src/main/java/com/genymobile/scrcpy/CameraCapture.java b/server/src/main/java/com/genymobile/scrcpy/video/CameraCapture.java similarity index 75% rename from server/src/main/java/com/genymobile/scrcpy/CameraCapture.java rename to server/src/main/java/com/genymobile/scrcpy/video/CameraCapture.java index a1003829..0e147cb7 100644 --- a/server/src/main/java/com/genymobile/scrcpy/CameraCapture.java +++ b/server/src/main/java/com/genymobile/scrcpy/video/CameraCapture.java @@ -1,5 +1,17 @@ -package com.genymobile.scrcpy; +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.Orientation; +import com.genymobile.scrcpy.device.Size; +import com.genymobile.scrcpy.opengl.AffineOpenGLFilter; +import com.genymobile.scrcpy.opengl.OpenGLFilter; +import com.genymobile.scrcpy.opengl.OpenGLRunner; +import com.genymobile.scrcpy.util.AffineMatrix; +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; @@ -17,7 +29,6 @@ import android.hardware.camera2.params.OutputConfiguration; import android.hardware.camera2.params.SessionConfiguration; import android.hardware.camera2.params.StreamConfigurationMap; import android.media.MediaCodec; -import android.os.Build; import android.os.Handler; import android.os.HandlerThread; import android.util.Range; @@ -35,6 +46,13 @@ import java.util.stream.Stream; public class CameraCapture extends SurfaceCapture { + public static final float[] VFLIP_MATRIX = { + 1, 0, 0, 0, // column 1 + 0, -1, 0, 0, // column 2 + 0, 0, 1, 0, // column 3 + 0, 1, 0, 1, // column 4 + }; + private final String explicitCameraId; private final CameraFacing cameraFacing; private final Size explicitSize; @@ -42,9 +60,16 @@ public class CameraCapture extends SurfaceCapture { private final CameraAspectRatio aspectRatio; private final int fps; private final boolean highSpeed; + private final Rect crop; + private final Orientation captureOrientation; + private final float angle; private String cameraId; - private Size size; + private Size captureSize; + private Size videoSize; // after OpenGL transforms + + private AffineMatrix transform; + private OpenGLRunner glRunner; private HandlerThread cameraThread; private Handler cameraHandler; @@ -53,19 +78,22 @@ 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(); + this.crop = options.getCrop(); + this.captureOrientation = options.getCaptureOrientation(); + assert captureOrientation != null; + this.angle = options.getAngle(); } @Override - public void init() throws IOException { + protected void init() throws ConfigurationException, IOException { cameraThread = new HandlerThread("camera"); cameraThread.start(); cameraHandler = new Handler(cameraThread.getLooper()); @@ -74,12 +102,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 + "'"); @@ -89,14 +112,45 @@ 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 { + captureSize = selectSize(cameraId, explicitSize, maxSize, aspectRatio, highSpeed); + if (captureSize == null) { + throw new IOException("Could not select camera size"); + } + } catch (CameraAccessException e) { + throw new IOException(e); } + VideoFilter filter = new VideoFilter(captureSize); + + if (crop != null) { + filter.addCrop(crop, false); + } + + if (captureOrientation != Orientation.Orient0) { + filter.addOrientation(captureOrientation); + } + + filter.addAngle(angle); + + transform = filter.getInverseTransform(); + videoSize = filter.getOutputSize().limit(maxSize).round8(); + } + + 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; @@ -115,7 +169,7 @@ public class CameraCapture extends SurfaceCapture { return null; } - @TargetApi(Build.VERSION_CODES.N) + @TargetApi(AndroidVersions.API_24_ANDROID_7_0) private static Size selectSize(String cameraId, Size explicitSize, int maxSize, CameraAspectRatio aspectRatio, boolean highSpeed) throws CameraAccessException { if (explicitSize != null) { @@ -127,6 +181,10 @@ public class CameraCapture extends SurfaceCapture { StreamConfigurationMap configs = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); android.util.Size[] sizes = highSpeed ? configs.getHighSpeedVideoSizes() : configs.getOutputSizes(MediaCodec.class); + if (sizes == null) { + return null; + } + Stream stream = Arrays.stream(sizes); if (maxSize > 0) { stream = stream.filter(it -> it.getWidth() <= maxSize && it.getHeight() <= maxSize); @@ -194,15 +252,33 @@ public class CameraCapture extends SurfaceCapture { @Override public void start(Surface surface) throws IOException { + if (transform != null) { + assert glRunner == null; + OpenGLFilter glFilter = new AffineOpenGLFilter(transform); + // The transform matrix returned by SurfaceTexture is incorrect for camera capture (it often contains an additional unexpected 90° + // rotation). Use a vertical flip transform matrix instead. + glRunner = new OpenGLRunner(glFilter, VFLIP_MATRIX); + surface = glRunner.start(captureSize, videoSize, surface); + } + try { CameraCaptureSession session = createCaptureSession(cameraDevice, surface); CaptureRequest request = createCaptureRequest(surface); setRepeatingRequest(session, request); } catch (CameraAccessException | InterruptedException e) { + stop(); throw new IOException(e); } } + @Override + public void stop() { + if (glRunner != null) { + glRunner.stopAndRelease(); + glRunner = null; + } + } + @Override public void release() { if (cameraDevice != null) { @@ -215,7 +291,7 @@ public class CameraCapture extends SurfaceCapture { @Override public Size getSize() { - return size; + return videoSize; } @Override @@ -225,17 +301,11 @@ 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") - @TargetApi(Build.VERSION_CODES.S) + @TargetApi(AndroidVersions.API_31_ANDROID_12) private CameraDevice openCamera(String id) throws CameraAccessException, InterruptedException { CompletableFuture future = new CompletableFuture<>(); ServiceManager.getCameraManager().openCamera(id, new CameraDevice.StateCallback() { @@ -249,7 +319,7 @@ public class CameraCapture extends SurfaceCapture { public void onDisconnected(CameraDevice camera) { Ln.w("Camera disconnected"); disconnected.set(true); - requestReset(); + invalidate(); } @Override @@ -282,7 +352,7 @@ public class CameraCapture extends SurfaceCapture { } } - @TargetApi(Build.VERSION_CODES.S) + @TargetApi(AndroidVersions.API_31_ANDROID_12) private CameraCaptureSession createCaptureSession(CameraDevice camera, Surface surface) throws CameraAccessException, InterruptedException { CompletableFuture future = new CompletableFuture<>(); OutputConfiguration outputConfig = new OutputConfiguration(surface); @@ -321,7 +391,7 @@ public class CameraCapture extends SurfaceCapture { return requestBuilder.build(); } - @TargetApi(Build.VERSION_CODES.S) + @TargetApi(AndroidVersions.API_31_ANDROID_12) private void setRepeatingRequest(CameraCaptureSession session, CaptureRequest request) throws CameraAccessException, InterruptedException { CameraCaptureSession.CaptureCallback callback = new CameraCaptureSession.CaptureCallback() { @Override @@ -348,4 +418,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) + } } diff --git a/server/src/main/java/com/genymobile/scrcpy/CameraFacing.java b/server/src/main/java/com/genymobile/scrcpy/video/CameraFacing.java similarity index 89% rename from server/src/main/java/com/genymobile/scrcpy/CameraFacing.java rename to server/src/main/java/com/genymobile/scrcpy/video/CameraFacing.java index b7e8daa5..f818e665 100644 --- a/server/src/main/java/com/genymobile/scrcpy/CameraFacing.java +++ b/server/src/main/java/com/genymobile/scrcpy/video/CameraFacing.java @@ -1,4 +1,4 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.video; import android.annotation.SuppressLint; import android.hardware.camera2.CameraCharacteristics; @@ -21,7 +21,7 @@ public enum CameraFacing { return value; } - static CameraFacing findByName(String name) { + public static CameraFacing findByName(String name) { for (CameraFacing facing : CameraFacing.values()) { if (name.equals(facing.name)) { return facing; diff --git a/server/src/main/java/com/genymobile/scrcpy/video/CaptureReset.java b/server/src/main/java/com/genymobile/scrcpy/video/CaptureReset.java new file mode 100644 index 00000000..79d32d7c --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/video/CaptureReset.java @@ -0,0 +1,37 @@ +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) { + try { + runningMediaCodec.signalEndOfInputStream(); + } catch (IllegalStateException e) { + // ignore + } + } + } + + public synchronized void setRunningMediaCodec(MediaCodec runningMediaCodec) { + this.runningMediaCodec = runningMediaCodec; + } + + @Override + public void onInvalidated() { + reset(); + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/video/DisplaySizeMonitor.java b/server/src/main/java/com/genymobile/scrcpy/video/DisplaySizeMonitor.java new file mode 100644 index 00000000..3d7cccfe --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/video/DisplaySizeMonitor.java @@ -0,0 +1,141 @@ +package com.genymobile.scrcpy.video; + +import com.genymobile.scrcpy.AndroidVersions; +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.wrappers.DisplayManager; +import com.genymobile.scrcpy.wrappers.DisplayWindowListener; +import com.genymobile.scrcpy.wrappers.ServiceManager; + +import android.content.res.Configuration; +import android.os.Build; +import android.os.Handler; +import android.os.HandlerThread; +import android.view.IDisplayWindowListener; + +public class DisplaySizeMonitor { + + public interface Listener { + void onDisplaySizeChanged(); + } + + // On Android 14, 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 DisplayWindowListener (introduced in Android 11) to listen to configuration changes instead. + // It has been broken again after an Android 15 upgrade: + // So use the default method only before Android 14. + private static final boolean USE_DEFAULT_METHOD = Build.VERSION.SDK_INT < AndroidVersions.API_34_ANDROID_14; + + private DisplayManager.DisplayListenerHandle displayListenerHandle; + private HandlerThread handlerThread; + + private IDisplayWindowListener displayWindowListener; + + private int displayId = Device.DISPLAY_ID_NONE; + + private Size sessionDisplaySize; + + private Listener listener; + + public void start(int displayId, Listener listener) { + // Once started, the listener and the displayId must never change + assert listener != null; + this.listener = listener; + + assert this.displayId == Device.DISPLAY_ID_NONE; + this.displayId = displayId; + + if (USE_DEFAULT_METHOD) { + handlerThread = new HandlerThread("DisplayListener"); + handlerThread.start(); + Handler handler = new Handler(handlerThread.getLooper()); + displayListenerHandle = ServiceManager.getDisplayManager().registerDisplayListener(eventDisplayId -> { + if (Ln.isEnabled(Ln.Level.VERBOSE)) { + Ln.v("DisplaySizeMonitor: onDisplayChanged(" + eventDisplayId + ")"); + } + + if (eventDisplayId == displayId) { + checkDisplaySizeChanged(); + } + }, handler); + } else { + displayWindowListener = new DisplayWindowListener() { + @Override + public void onDisplayConfigurationChanged(int eventDisplayId, Configuration newConfig) { + if (Ln.isEnabled(Ln.Level.VERBOSE)) { + Ln.v("DisplaySizeMonitor: onDisplayConfigurationChanged(" + eventDisplayId + ")"); + } + + if (eventDisplayId == displayId) { + checkDisplaySizeChanged(); + } + } + }; + ServiceManager.getWindowManager().registerDisplayWindowListener(displayWindowListener); + } + } + + /** + * Stop and release the monitor. + *

+ * It must not be used anymore. + * It is ok to call this method even if {@link #start(int, Listener)} was not called. + */ + public void stopAndRelease() { + if (USE_DEFAULT_METHOD) { + // displayListenerHandle may be null if registration failed + if (displayListenerHandle != null) { + ServiceManager.getDisplayManager().unregisterDisplayListener(displayListenerHandle); + displayListenerHandle = null; + } + + if (handlerThread != null) { + handlerThread.quitSafely(); + } + } else if (displayWindowListener != null) { + ServiceManager.getWindowManager().unregisterDisplayWindowListener(displayWindowListener); + } + } + + private synchronized Size getSessionDisplaySize() { + return sessionDisplaySize; + } + + public synchronized void setSessionDisplaySize(Size sessionDisplaySize) { + this.sessionDisplaySize = sessionDisplaySize; + } + + private void checkDisplaySizeChanged() { + 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("DisplaySizeMonitor: requestReset(): " + getSessionDisplaySize() + " -> (unknown)"); + } + setSessionDisplaySize(null); + listener.onDisplaySizeChanged(); + } 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("DisplaySizeMonitor: 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); + listener.onDisplaySizeChanged(); + } else if (Ln.isEnabled(Ln.Level.VERBOSE)) { + Ln.v("DisplaySizeMonitor: Size not changed (" + size + "): do not requestReset()"); + } + } + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/video/NewDisplayCapture.java b/server/src/main/java/com/genymobile/scrcpy/video/NewDisplayCapture.java new file mode 100644 index 00000000..792b3a8a --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/video/NewDisplayCapture.java @@ -0,0 +1,267 @@ +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.Orientation; +import com.genymobile.scrcpy.device.Size; +import com.genymobile.scrcpy.opengl.AffineOpenGLFilter; +import com.genymobile.scrcpy.opengl.OpenGLFilter; +import com.genymobile.scrcpy.opengl.OpenGLRunner; +import com.genymobile.scrcpy.util.AffineMatrix; +import com.genymobile.scrcpy.util.Ln; +import com.genymobile.scrcpy.wrappers.ServiceManager; + +import android.graphics.Rect; +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 = android.hardware.display.DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC; + private static final int VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY = android.hardware.display.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 final DisplaySizeMonitor displaySizeMonitor = new DisplaySizeMonitor(); + + private AffineMatrix displayTransform; + private AffineMatrix eventTransform; + private OpenGLRunner glRunner; + + private Size mainDisplaySize; + private int mainDisplayDpi; + private int maxSize; + private int displayImePolicy; + private final Rect crop; + private final boolean captureOrientationLocked; + private final Orientation captureOrientation; + private final float angle; + private final boolean vdDestroyContent; + private final boolean vdSystemDecorations; + + private VirtualDisplay virtualDisplay; + private Size videoSize; + private Size displaySize; // the logical size of the display (including rotation) + private Size physicalSize; // the physical size of the display (without rotation) + + private int dpi; + + public NewDisplayCapture(VirtualDisplayListener vdListener, Options options) { + this.vdListener = vdListener; + this.newDisplay = options.getNewDisplay(); + assert newDisplay != null; + this.maxSize = options.getMaxSize(); + this.displayImePolicy = options.getDisplayImePolicy(); + this.crop = options.getCrop(); + assert options.getCaptureOrientationLock() != null; + this.captureOrientationLocked = options.getCaptureOrientationLock() != Orientation.Lock.Unlocked; + this.captureOrientation = options.getCaptureOrientation(); + assert captureOrientation != null; + this.angle = options.getAngle(); + this.vdDestroyContent = options.getVDDestroyContent(); + this.vdSystemDecorations = options.getVDSystemDecorations(); + } + + @Override + protected void init() { + displaySize = newDisplay.getSize(); + dpi = newDisplay.getDpi(); + if (displaySize == null || dpi == 0) { + DisplayInfo displayInfo = ServiceManager.getDisplayManager().getDisplayInfo(0); + if (displayInfo != null) { + mainDisplaySize = displayInfo.getSize(); + if ((displayInfo.getRotation() % 2) != 0) { + mainDisplaySize = mainDisplaySize.rotate(); // Use the natural device orientation (at rotation 0), not the current one + } + 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() { + int displayRotation; + if (virtualDisplay == null) { + if (!newDisplay.hasExplicitSize()) { + displaySize = mainDisplaySize; + } + if (!newDisplay.hasExplicitDpi()) { + dpi = scaleDpi(mainDisplaySize, mainDisplayDpi, displaySize); + } + + videoSize = displaySize; + displayRotation = 0; + // Set the current display size to avoid an unnecessary call to invalidate() + displaySizeMonitor.setSessionDisplaySize(displaySize); + } else { + DisplayInfo displayInfo = ServiceManager.getDisplayManager().getDisplayInfo(virtualDisplay.getDisplay().getDisplayId()); + displaySize = displayInfo.getSize(); + dpi = displayInfo.getDpi(); + displayRotation = displayInfo.getRotation(); + } + + VideoFilter filter = new VideoFilter(displaySize); + + if (crop != null) { + boolean transposed = (displayRotation % 2) != 0; + filter.addCrop(crop, transposed); + } + + filter.addOrientation(displayRotation, captureOrientationLocked, captureOrientation); + filter.addAngle(angle); + + Size filteredSize = filter.getOutputSize(); + if (!filteredSize.isMultipleOf8() || (maxSize != 0 && filteredSize.getMax() > maxSize)) { + if (maxSize != 0) { + filteredSize = filteredSize.limit(maxSize); + } + filteredSize = filteredSize.round8(); + filter.addResize(filteredSize); + } + + eventTransform = filter.getInverseTransform(); + + // DisplayInfo gives the oriented size (so videoSize includes the display rotation) + videoSize = filter.getOutputSize(); + + // But the virtual display video always remains in the origin orientation (the video itself is not rotated, so it must rotated manually). + // This additional display rotation must not be included in the input events transform (the expected coordinates are already in the + // physical display size) + if ((displayRotation % 2) == 0) { + physicalSize = displaySize; + } else { + physicalSize = displaySize.rotate(); + } + VideoFilter displayFilter = new VideoFilter(physicalSize); + displayFilter.addRotation(displayRotation); + AffineMatrix displayRotationMatrix = displayFilter.getInverseTransform(); + + // Take care of multiplication order: + // displayTransform = (FILTER_MATRIX * DISPLAY_FILTER_MATRIX)⁻¹ + // = DISPLAY_FILTER_MATRIX⁻¹ * FILTER_MATRIX⁻¹ + // = displayRotationMatrix * eventTransform + displayTransform = AffineMatrix.multiplyAll(displayRotationMatrix, eventTransform); + } + + 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; + if (vdDestroyContent) { + flags |= VIRTUAL_DISPLAY_FLAG_DESTROY_CONTENT_ON_REMOVAL; + } + if (vdSystemDecorations) { + flags |= 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", displaySize.getWidth(), displaySize.getHeight(), dpi, surface, flags); + virtualDisplayId = virtualDisplay.getDisplay().getDisplayId(); + Ln.i("New display: " + displaySize.getWidth() + "x" + displaySize.getHeight() + "/" + dpi + " (id=" + virtualDisplayId + ")"); + + if (displayImePolicy != -1) { + ServiceManager.getWindowManager().setDisplayImePolicy(virtualDisplayId, displayImePolicy); + } + + displaySizeMonitor.start(virtualDisplayId, this::invalidate); + } catch (Exception e) { + Ln.e("Could not create display", e); + throw new AssertionError("Could not create display"); + } + } + + @Override + public void start(Surface surface) throws IOException { + if (displayTransform != null) { + assert glRunner == null; + OpenGLFilter glFilter = new AffineOpenGLFilter(displayTransform); + glRunner = new OpenGLRunner(glFilter); + surface = glRunner.start(physicalSize, videoSize, surface); + } + + if (virtualDisplay == null) { + startNew(surface); + } else { + virtualDisplay.setSurface(surface); + } + + if (vdListener != null) { + PositionMapper positionMapper = PositionMapper.create(videoSize, eventTransform, displaySize); + vdListener.onNewVirtualDisplay(virtualDisplay.getDisplay().getDisplayId(), positionMapper); + } + } + + @Override + public void stop() { + if (glRunner != null) { + glRunner.stopAndRelease(); + glRunner = null; + } + } + + @Override + public void release() { + displaySizeMonitor.stopAndRelease(); + + if (virtualDisplay != null) { + virtualDisplay.release(); + virtualDisplay = null; + } + } + + @Override + public synchronized Size getSize() { + return videoSize; + } + + @Override + public synchronized boolean setMaxSize(int newMaxSize) { + 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(); + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/video/ScreenCapture.java b/server/src/main/java/com/genymobile/scrcpy/video/ScreenCapture.java new file mode 100644 index 00000000..5f4e1803 --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/video/ScreenCapture.java @@ -0,0 +1,219 @@ +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.Orientation; +import com.genymobile.scrcpy.device.Size; +import com.genymobile.scrcpy.opengl.AffineOpenGLFilter; +import com.genymobile.scrcpy.opengl.OpenGLFilter; +import com.genymobile.scrcpy.opengl.OpenGLRunner; +import com.genymobile.scrcpy.util.AffineMatrix; +import com.genymobile.scrcpy.util.Ln; +import com.genymobile.scrcpy.util.LogUtils; +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.IBinder; +import android.view.Surface; + +import java.io.IOException; + +public class ScreenCapture extends SurfaceCapture { + + private final VirtualDisplayListener vdListener; + private final int displayId; + private int maxSize; + private final Rect crop; + private Orientation.Lock captureOrientationLock; + private Orientation captureOrientation; + private final float angle; + + private DisplayInfo displayInfo; + private Size videoSize; + + private final DisplaySizeMonitor displaySizeMonitor = new DisplaySizeMonitor(); + + private IBinder display; + private VirtualDisplay virtualDisplay; + + private AffineMatrix transform; + private OpenGLRunner glRunner; + + 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.captureOrientationLock = options.getCaptureOrientationLock(); + this.captureOrientation = options.getCaptureOrientation(); + assert captureOrientationLock != null; + assert captureOrientation != null; + this.angle = options.getAngle(); + } + + @Override + public void init() { + displaySizeMonitor.start(displayId, this::invalidate); + } + + @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"); + } + + Size displaySize = displayInfo.getSize(); + displaySizeMonitor.setSessionDisplaySize(displaySize); + + if (captureOrientationLock == Orientation.Lock.LockedInitial) { + // The user requested to lock the video orientation to the current orientation + captureOrientationLock = Orientation.Lock.LockedValue; + captureOrientation = Orientation.fromRotation(displayInfo.getRotation()); + } + + VideoFilter filter = new VideoFilter(displaySize); + + if (crop != null) { + boolean transposed = (displayInfo.getRotation() % 2) != 0; + filter.addCrop(crop, transposed); + } + + boolean locked = captureOrientationLock != Orientation.Lock.Unlocked; + filter.addOrientation(displayInfo.getRotation(), locked, captureOrientation); + filter.addAngle(angle); + + transform = filter.getInverseTransform(); + videoSize = filter.getOutputSize().limit(maxSize).round8(); + } + + @Override + public void start(Surface surface) throws IOException { + if (display != null) { + SurfaceControl.destroyDisplay(display); + display = null; + } + if (virtualDisplay != null) { + virtualDisplay.release(); + virtualDisplay = null; + } + + Size inputSize; + if (transform != null) { + // If there is a filter, it must receive the full display content + inputSize = displayInfo.getSize(); + assert glRunner == null; + OpenGLFilter glFilter = new AffineOpenGLFilter(transform); + glRunner = new OpenGLRunner(glFilter); + surface = glRunner.start(inputSize, videoSize, surface); + } else { + // If there is no filter, the display must be rendered at target video size directly + inputSize = videoSize; + } + + try { + virtualDisplay = ServiceManager.getDisplayManager() + .createVirtualDisplay("scrcpy", inputSize.getWidth(), inputSize.getHeight(), displayId, surface); + Ln.d("Display: using DisplayManager API"); + } catch (Exception displayManagerException) { + try { + display = createDisplay(); + + Size deviceSize = displayInfo.getSize(); + int layerStack = displayInfo.getLayerStack(); + setDisplaySurface(display, surface, deviceSize.toRect(), inputSize.toRect(), layerStack); + Ln.d("Display: using SurfaceControl API"); + } catch (Exception surfaceControlException) { + Ln.e("Could not create display using DisplayManager", displayManagerException); + Ln.e("Could not create display using SurfaceControl", surfaceControlException); + throw new AssertionError("Could not create display"); + } + } + + if (vdListener != null) { + int virtualDisplayId; + PositionMapper positionMapper; + if (virtualDisplay == null || displayId == 0) { + // Surface control or main display: send all events to the original display, relative to the device size + Size deviceSize = displayInfo.getSize(); + positionMapper = PositionMapper.create(videoSize, transform, deviceSize); + virtualDisplayId = displayId; + } else { + // The positions are relative to the virtual display, not the original display (so use inputSize, not deviceSize!) + positionMapper = PositionMapper.create(videoSize, transform, inputSize); + virtualDisplayId = virtualDisplay.getDisplay().getDisplayId(); + } + vdListener.onNewVirtualDisplay(virtualDisplayId, positionMapper); + } + } + + @Override + public void stop() { + if (glRunner != null) { + glRunner.stopAndRelease(); + glRunner = null; + } + } + + @Override + public void release() { + displaySizeMonitor.stopAndRelease(); + + if (display != null) { + SurfaceControl.destroyDisplay(display); + display = null; + } + if (virtualDisplay != null) { + virtualDisplay.release(); + virtualDisplay = null; + } + } + + @Override + public Size getSize() { + return videoSize; + } + + @Override + public boolean setMaxSize(int newMaxSize) { + maxSize = newMaxSize; + return true; + } + + 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". + boolean secure = Build.VERSION.SDK_INT < AndroidVersions.API_30_ANDROID_11 || (Build.VERSION.SDK_INT == AndroidVersions.API_30_ANDROID_11 + && !"S".equals(Build.VERSION.CODENAME)); + return SurfaceControl.createDisplay("scrcpy", secure); + } + + private static void setDisplaySurface(IBinder display, Surface surface, Rect deviceRect, Rect displayRect, int layerStack) { + SurfaceControl.openTransaction(); + try { + SurfaceControl.setDisplaySurface(display, surface); + SurfaceControl.setDisplayProjection(display, 0, deviceRect, displayRect); + SurfaceControl.setDisplayLayerStack(display, layerStack); + } finally { + SurfaceControl.closeTransaction(); + } + } + + @Override + public void requestInvalidate() { + invalidate(); + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/video/SurfaceCapture.java b/server/src/main/java/com/genymobile/scrcpy/video/SurfaceCapture.java new file mode 100644 index 00000000..39d3bdb8 --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/video/SurfaceCapture.java @@ -0,0 +1,96 @@ +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; + +/** + * A video source which can be rendered on a Surface for encoding. + */ +public abstract class SurfaceCapture { + + public interface CaptureListener { + void onInvalidated(); + } + + private CaptureListener listener; + + /** + * Notify the listener that the capture has been invalidated (for example, because its size changed). + */ + protected void invalidate() { + listener.onInvalidated(); + } + + /** + * Called once before the first capture starts. + */ + public final void init(CaptureListener listener) throws ConfigurationException, IOException { + this.listener = listener; + init(); + } + + /** + * Called once before the first capture starts. + */ + protected abstract void init() throws ConfigurationException, IOException; + + /** + * 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. + * + * @param surface the surface which will be encoded + */ + public abstract void start(Surface surface) throws IOException; + + /** + * Stop the capture. + */ + public void stop() { + // Do nothing by default + } + + /** + * Return the video size + * + * @return the video size + */ + public abstract Size getSize(); + + /** + * Set the maximum capture size (set by the encoder if it does not support the current size). + * + * @param maxSize Maximum size + */ + public abstract boolean setMaxSize(int maxSize); + + /** + * Indicate if the capture has been closed internally. + * + * @return {@code true} is the capture is closed, {@code false} otherwise. + */ + public boolean isClosed() { + return false; + } + + /** + * Manually request to invalidate (typically a user request). + *

+ * The capture implementation is free to ignore the request and do nothing. + */ + public abstract void requestInvalidate(); +} diff --git a/server/src/main/java/com/genymobile/scrcpy/SurfaceEncoder.java b/server/src/main/java/com/genymobile/scrcpy/video/SurfaceEncoder.java similarity index 67% rename from server/src/main/java/com/genymobile/scrcpy/SurfaceEncoder.java rename to server/src/main/java/com/genymobile/scrcpy/video/SurfaceEncoder.java index 28435c09..236a5f48 100644 --- a/server/src/main/java/com/genymobile/scrcpy/SurfaceEncoder.java +++ b/server/src/main/java/com/genymobile/scrcpy/video/SurfaceEncoder.java @@ -1,8 +1,22 @@ -package com.genymobile.scrcpy; +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; +import com.genymobile.scrcpy.util.Codec; +import com.genymobile.scrcpy.util.CodecOption; +import com.genymobile.scrcpy.util.CodecUtils; +import com.genymobile.scrcpy.util.IO; +import com.genymobile.scrcpy.util.Ln; +import com.genymobile.scrcpy.util.LogUtils; import android.media.MediaCodec; import android.media.MediaCodecInfo; import android.media.MediaFormat; +import android.os.Build; import android.os.Looper; import android.os.SystemClock; import android.view.Surface; @@ -27,7 +41,7 @@ public class SurfaceEncoder implements AsyncProcessor { private final String encoderName; private final List codecOptions; private final int videoBitRate; - private final int maxFps; + private final float maxFps; private final boolean downsizeOnError; private boolean firstFrameSent; @@ -36,54 +50,90 @@ public class SurfaceEncoder implements AsyncProcessor { private Thread thread; private final AtomicBoolean stopped = new AtomicBoolean(); - public SurfaceEncoder(SurfaceCapture capture, Streamer streamer, int videoBitRate, int maxFps, List 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 streamScreen() throws IOException, ConfigurationException { + private void streamCapture() throws IOException, ConfigurationException { Codec codec = streamer.getCodec(); 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()); Surface surface = null; + boolean mediaCodecStarted = false; + boolean captureStarted = false; try { mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); surface = mediaCodec.createInputSurface(); capture.start(surface); + captureStarted = true; mediaCodec.start(); + mediaCodecStarted = true; - alive = encode(mediaCodec, streamer); - // do not call stop() on exception, it would trigger an IllegalStateException - mediaCodec.stop(); - } catch (IllegalStateException | IllegalArgumentException e) { - Ln.e("Encoding error: " + e.getClass().getName() + ": " + e.getMessage()); + // 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(); + } + } catch (IllegalStateException | IllegalArgumentException | IOException e) { + if (IO.isBrokenPipe(e)) { + // Do not retry on broken pipe, which is expected on close because the socket is closed by the client + throw e; + } + Ln.e("Capture/encoding error: " + e.getClass().getName() + ": " + e.getMessage()); if (!prepareRetry(size)) { throw e; } - Ln.i("Retrying..."); alive = true; } finally { + reset.setRunningMediaCodec(null); + if (captureStarted) { + capture.stop(); + } + if (mediaCodecStarted) { + try { + mediaCodec.stop(); + } catch (IllegalStateException e) { + // ignore (just in case) + } + } mediaCodec.reset(); if (surface != null) { surface.release(); @@ -144,25 +194,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; @@ -179,21 +220,20 @@ 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 { if (encoderName != null) { Ln.d("Creating encoder by name: '" + encoderName + "'"); try { - return MediaCodec.createByCodecName(encoderName); + MediaCodec mediaCodec = MediaCodec.createByCodecName(encoderName); + String mimeType = Codec.getMimeType(mediaCodec); + if (!codec.getMimeType().equals(mimeType)) { + Ln.e("Video encoder type for \"" + encoderName + "\" (" + mimeType + ") does not match codec type (" + codec.getMimeType() + ")"); + throw new ConfigurationException("Incorrect encoder type: " + encoderName); + } + return mediaCodec; } catch (IllegalArgumentException e) { Ln.e("Video encoder '" + encoderName + "' for " + codec.getName() + " not found\n" + LogUtils.buildVideoEncoderListMessage()); throw new ConfigurationException("Unknown encoder: " + encoderName); @@ -213,13 +253,16 @@ public class SurfaceEncoder implements AsyncProcessor { } } - private static MediaFormat createFormat(String videoMimeType, int bitRate, int maxFps, List codecOptions) { + private static MediaFormat createFormat(String videoMimeType, int bitRate, float maxFps, List codecOptions) { MediaFormat format = new MediaFormat(); format.setString(MediaFormat.KEY_MIME, videoMimeType); format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate); // must be present to configure the encoder, but does not impact the actual frame rate, which is variable format.setInteger(MediaFormat.KEY_FRAME_RATE, 60); format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); + if (Build.VERSION.SDK_INT >= AndroidVersions.API_24_ANDROID_7_0) { + format.setInteger(MediaFormat.KEY_COLOR_RANGE, MediaFormat.COLOR_RANGE_LIMITED); + } format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, DEFAULT_I_FRAME_INTERVAL); // display the very first frame, and recover from bad quality when no new frames format.setLong(MediaFormat.KEY_REPEAT_PREVIOUS_FRAME_AFTER, REPEAT_FRAME_DELAY_US); // µs @@ -250,7 +293,7 @@ public class SurfaceEncoder implements AsyncProcessor { Looper.prepare(); try { - streamScreen(); + streamCapture(); } catch (ConfigurationException e) { // Do not print stack trace, a user-friendly error-message has already been logged } catch (IOException e) { @@ -270,6 +313,7 @@ public class SurfaceEncoder implements AsyncProcessor { public void stop() { if (thread != null) { stopped.set(true); + reset.reset(); } } diff --git a/server/src/main/java/com/genymobile/scrcpy/VideoCodec.java b/server/src/main/java/com/genymobile/scrcpy/video/VideoCodec.java similarity index 93% rename from server/src/main/java/com/genymobile/scrcpy/VideoCodec.java rename to server/src/main/java/com/genymobile/scrcpy/video/VideoCodec.java index fa787a99..5d528da1 100644 --- a/server/src/main/java/com/genymobile/scrcpy/VideoCodec.java +++ b/server/src/main/java/com/genymobile/scrcpy/video/VideoCodec.java @@ -1,4 +1,6 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.video; + +import com.genymobile.scrcpy.util.Codec; import android.annotation.SuppressLint; import android.media.MediaFormat; diff --git a/server/src/main/java/com/genymobile/scrcpy/video/VideoFilter.java b/server/src/main/java/com/genymobile/scrcpy/video/VideoFilter.java new file mode 100644 index 00000000..a27915ee --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/video/VideoFilter.java @@ -0,0 +1,119 @@ +package com.genymobile.scrcpy.video; + +import com.genymobile.scrcpy.device.Orientation; +import com.genymobile.scrcpy.device.Size; +import com.genymobile.scrcpy.util.AffineMatrix; + +import android.graphics.Rect; + +public class VideoFilter { + + private Size size; + private AffineMatrix transform; + + public VideoFilter(Size inputSize) { + this.size = inputSize; + } + + public Size getOutputSize() { + return size; + } + + public AffineMatrix getTransform() { + return transform; + } + + /** + * Return the inverse transform. + *

+ * The direct affine transform describes how the input image is transformed. + *

+ * It is often useful to retrieve the inverse transform instead: + *

    + *
  • The OpenGL filter expects the matrix to transform the image coordinates, which is the inverse transform;
  • + *
  • The click positions must be transformed back to the device positions, using the inverse transform too.
  • + *
+ * + * @return the inverse transform + */ + public AffineMatrix getInverseTransform() { + if (transform == null) { + return null; + } + return transform.invert(); + } + + private static Rect transposeRect(Rect rect) { + return new Rect(rect.top, rect.left, rect.bottom, rect.right); + } + + public void addCrop(Rect crop, boolean transposed) { + if (transposed) { + crop = transposeRect(crop); + } + + double inputWidth = size.getWidth(); + double inputHeight = size.getHeight(); + + if (crop.left < 0 || crop.top < 0 || crop.right > inputWidth || crop.bottom > inputHeight) { + throw new IllegalArgumentException("Crop " + crop + " exceeds the input area (" + size + ")"); + } + + double x = crop.left / inputWidth; + double y = 1 - (crop.bottom / inputHeight); // OpenGL origin is bottom-left + double w = crop.width() / inputWidth; + double h = crop.height() / inputHeight; + + transform = AffineMatrix.reframe(x, y, w, h).multiply(transform); + size = new Size(crop.width(), crop.height()); + } + + public void addRotation(int ccwRotation) { + if (ccwRotation == 0) { + return; + } + + transform = AffineMatrix.rotateOrtho(ccwRotation).multiply(transform); + if (ccwRotation % 2 != 0) { + size = size.rotate(); + } + } + + public void addOrientation(Orientation captureOrientation) { + if (captureOrientation.isFlipped()) { + transform = AffineMatrix.hflip().multiply(transform); + } + int ccwRotation = (4 - captureOrientation.getRotation()) % 4; + addRotation(ccwRotation); + } + + public void addOrientation(int displayRotation, boolean locked, Orientation captureOrientation) { + if (locked) { + // flip/rotate the current display from the natural device orientation (i.e. where display rotation is 0) + int reverseDisplayRotation = (4 - displayRotation) % 4; + addRotation(reverseDisplayRotation); + } + addOrientation(captureOrientation); + } + + public void addAngle(double cwAngle) { + if (cwAngle == 0) { + return; + } + double ccwAngle = -cwAngle; + transform = AffineMatrix.rotate(ccwAngle).withAspectRatio(size).fromCenter().multiply(transform); + } + + public void addResize(Size targetSize) { + if (size.equals(targetSize)) { + return; + } + + if (transform == null) { + // The requested scaling is performed by the viewport (by changing the output size), but the OpenGL filter must still run, even if + // resizing is not performed by the shader. So transform MUST NOT be null. + transform = AffineMatrix.IDENTITY; + } + size = targetSize; + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/VideoSource.java b/server/src/main/java/com/genymobile/scrcpy/video/VideoSource.java similarity index 80% rename from server/src/main/java/com/genymobile/scrcpy/VideoSource.java rename to server/src/main/java/com/genymobile/scrcpy/video/VideoSource.java index b5a74fbe..53b54a52 100644 --- a/server/src/main/java/com/genymobile/scrcpy/VideoSource.java +++ b/server/src/main/java/com/genymobile/scrcpy/video/VideoSource.java @@ -1,4 +1,4 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.video; public enum VideoSource { DISPLAY("display"), @@ -10,7 +10,7 @@ public enum VideoSource { this.name = name; } - static VideoSource findByName(String name) { + public static VideoSource findByName(String name) { for (VideoSource videoSource : VideoSource.values()) { if (name.equals(videoSource.name)) { return videoSource; diff --git a/server/src/main/java/com/genymobile/scrcpy/video/VirtualDisplayListener.java b/server/src/main/java/com/genymobile/scrcpy/video/VirtualDisplayListener.java new file mode 100644 index 00000000..c079265e --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/video/VirtualDisplayListener.java @@ -0,0 +1,7 @@ +package com.genymobile.scrcpy.video; + +import com.genymobile.scrcpy.control.PositionMapper; + +public interface VirtualDisplayListener { + void onNewVirtualDisplay(int displayId, PositionMapper positionMapper); +} diff --git a/server/src/main/java/com/genymobile/scrcpy/wrappers/ActivityManager.java b/server/src/main/java/com/genymobile/scrcpy/wrappers/ActivityManager.java index 75115618..255483c6 100644 --- a/server/src/main/java/com/genymobile/scrcpy/wrappers/ActivityManager.java +++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/ActivityManager.java @@ -1,19 +1,19 @@ package com.genymobile.scrcpy.wrappers; +import com.genymobile.scrcpy.AndroidVersions; import com.genymobile.scrcpy.FakeContext; -import com.genymobile.scrcpy.Ln; +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.Build; import android.os.Bundle; import android.os.IBinder; import android.os.IInterface; import java.lang.reflect.Field; -import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; @SuppressLint("PrivateApi,DiscouragedPrivateApi") @@ -23,10 +23,23 @@ public final class ActivityManager { private Method getContentProviderExternalMethod; private boolean getContentProviderExternalMethodNewVersion = true; private Method removeContentProviderExternalMethod; - private Method startActivityAsUserWithFeatureMethod; + private Method startActivityAsUserMethod; private Method forceStopPackageMethod; - public ActivityManager(IInterface manager) { + static ActivityManager create() { + try { + // On old Android versions, the ActivityManager is not exposed via AIDL, + // so use ActivityManagerNative.getDefault() + Class cls = Class.forName("android.app.ActivityManagerNative"); + Method getDefaultMethod = cls.getDeclaredMethod("getDefault"); + IInterface am = (IInterface) getDefaultMethod.invoke(null); + return new ActivityManager(am); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } + + private ActivityManager(IInterface manager) { this.manager = manager; } @@ -51,8 +64,8 @@ public final class ActivityManager { return removeContentProviderExternalMethod; } - @TargetApi(Build.VERSION_CODES.Q) - private ContentProvider getContentProviderExternal(String name, IBinder token) { + @TargetApi(AndroidVersions.API_29_ANDROID_10) + public IContentProvider getContentProviderExternal(String name, IBinder token) { try { Method method = getGetContentProviderExternalMethod(); Object[] args; @@ -71,12 +84,8 @@ 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); - } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException | NoSuchFieldException e) { + return (IContentProvider) providerField.get(providerHolder); + } catch (ReflectiveOperationException e) { Ln.e("Could not invoke method", e); return null; } @@ -86,35 +95,43 @@ public final class ActivityManager { try { Method method = getRemoveContentProviderExternalMethod(); method.invoke(manager, name, token); - } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { + } catch (ReflectiveOperationException e) { Ln.e("Could not invoke method", e); } } 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 getStartActivityAsUserWithFeatureMethod() throws NoSuchMethodException, ClassNotFoundException { - if (startActivityAsUserWithFeatureMethod == null) { + private Method getStartActivityAsUserMethod() throws NoSuchMethodException, ClassNotFoundException { + if (startActivityAsUserMethod == null) { Class iApplicationThreadClass = Class.forName("android.app.IApplicationThread"); Class profilerInfo = Class.forName("android.app.ProfilerInfo"); - startActivityAsUserWithFeatureMethod = manager.getClass() - .getMethod("startActivityAsUserWithFeature", iApplicationThreadClass, String.class, String.class, Intent.class, String.class, - IBinder.class, String.class, int.class, int.class, profilerInfo, Bundle.class, int.class); + startActivityAsUserMethod = manager.getClass() + .getMethod("startActivityAsUser", iApplicationThreadClass, String.class, Intent.class, String.class, IBinder.class, String.class, + int.class, int.class, profilerInfo, Bundle.class, int.class); } - return startActivityAsUserWithFeatureMethod; + return startActivityAsUserMethod; + } + + public int startActivity(Intent intent) { + return startActivity(intent, null); } @SuppressWarnings("ConstantConditions") - public int startActivityAsUserWithFeature(Intent intent) { + public int startActivity(Intent intent, Bundle options) { try { - Method method = getStartActivityAsUserWithFeatureMethod(); + Method method = getStartActivityAsUserMethod(); return (int) method.invoke( /* this */ manager, /* caller */ null, /* callingPackage */ FakeContext.PACKAGE_NAME, - /* callingFeatureId */ null, /* intent */ intent, /* resolvedType */ null, /* resultTo */ null, @@ -122,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); diff --git a/server/src/main/java/com/genymobile/scrcpy/wrappers/ClipboardManager.java b/server/src/main/java/com/genymobile/scrcpy/wrappers/ClipboardManager.java index 783a3407..54936122 100644 --- a/server/src/main/java/com/genymobile/scrcpy/wrappers/ClipboardManager.java +++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/ClipboardManager.java @@ -1,199 +1,43 @@ package com.genymobile.scrcpy.wrappers; import com.genymobile.scrcpy.FakeContext; -import com.genymobile.scrcpy.Ln; import android.content.ClipData; -import android.content.IOnPrimaryClipChangedListener; -import android.os.Build; -import android.os.IInterface; - -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; +import android.content.Context; public final class ClipboardManager { - private final IInterface manager; - private Method getPrimaryClipMethod; - private Method setPrimaryClipMethod; - private Method addPrimaryClipChangedListener; - private int getMethodVersion; - private int setMethodVersion; - private int addListenerMethodVersion; + private final android.content.ClipboardManager manager; - public ClipboardManager(IInterface manager) { + static ClipboardManager create() { + android.content.ClipboardManager manager = (android.content.ClipboardManager) FakeContext.get().getSystemService(Context.CLIPBOARD_SERVICE); + if (manager == null) { + // Some devices have no clipboard manager + // + // + return null; + } + return new ClipboardManager(manager); + } + + private ClipboardManager(android.content.ClipboardManager manager) { this.manager = manager; } - private Method getGetPrimaryClipMethod() throws NoSuchMethodException { - if (getPrimaryClipMethod == null) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - getPrimaryClipMethod = manager.getClass().getMethod("getPrimaryClip", String.class); - } else { - try { - getPrimaryClipMethod = manager.getClass().getMethod("getPrimaryClip", String.class, int.class); - getMethodVersion = 0; - } catch (NoSuchMethodException e1) { - try { - getPrimaryClipMethod = manager.getClass().getMethod("getPrimaryClip", String.class, String.class, int.class); - getMethodVersion = 1; - } catch (NoSuchMethodException e2) { - try { - getPrimaryClipMethod = manager.getClass().getMethod("getPrimaryClip", String.class, String.class, int.class, int.class); - getMethodVersion = 2; - } catch (NoSuchMethodException e3) { - getPrimaryClipMethod = manager.getClass().getMethod("getPrimaryClip", String.class, int.class, String.class); - getMethodVersion = 3; - } - } - } - } - } - return getPrimaryClipMethod; - } - - private Method getSetPrimaryClipMethod() throws NoSuchMethodException { - if (setPrimaryClipMethod == null) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - setPrimaryClipMethod = manager.getClass().getMethod("setPrimaryClip", ClipData.class, String.class); - } else { - try { - setPrimaryClipMethod = manager.getClass().getMethod("setPrimaryClip", ClipData.class, String.class, int.class); - setMethodVersion = 0; - } catch (NoSuchMethodException e1) { - try { - setPrimaryClipMethod = manager.getClass().getMethod("setPrimaryClip", ClipData.class, String.class, String.class, int.class); - setMethodVersion = 1; - } catch (NoSuchMethodException e2) { - setPrimaryClipMethod = manager.getClass() - .getMethod("setPrimaryClip", ClipData.class, String.class, String.class, int.class, int.class); - setMethodVersion = 2; - } - } - } - } - return setPrimaryClipMethod; - } - - private static ClipData getPrimaryClip(Method method, int methodVersion, IInterface manager) - throws InvocationTargetException, IllegalAccessException { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - return (ClipData) method.invoke(manager, FakeContext.PACKAGE_NAME); - } - - switch (methodVersion) { - case 0: - return (ClipData) method.invoke(manager, FakeContext.PACKAGE_NAME, FakeContext.ROOT_UID); - case 1: - return (ClipData) method.invoke(manager, FakeContext.PACKAGE_NAME, null, FakeContext.ROOT_UID); - case 2: - return (ClipData) method.invoke(manager, FakeContext.PACKAGE_NAME, null, FakeContext.ROOT_UID, 0); - default: - return (ClipData) method.invoke(manager, FakeContext.PACKAGE_NAME, FakeContext.ROOT_UID, null); - } - } - - private static void setPrimaryClip(Method method, int methodVersion, IInterface manager, ClipData clipData) - throws InvocationTargetException, IllegalAccessException { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - method.invoke(manager, clipData, FakeContext.PACKAGE_NAME); - return; - } - - switch (methodVersion) { - case 0: - method.invoke(manager, clipData, FakeContext.PACKAGE_NAME, FakeContext.ROOT_UID); - break; - case 1: - method.invoke(manager, clipData, FakeContext.PACKAGE_NAME, null, FakeContext.ROOT_UID); - break; - default: - method.invoke(manager, clipData, FakeContext.PACKAGE_NAME, null, FakeContext.ROOT_UID, 0); - break; - } - } - public CharSequence getText() { - try { - Method method = getGetPrimaryClipMethod(); - ClipData clipData = getPrimaryClip(method, getMethodVersion, manager); - if (clipData == null || clipData.getItemCount() == 0) { - return null; - } - return clipData.getItemAt(0).getText(); - } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { - Ln.e("Could not invoke method", e); + ClipData clipData = manager.getPrimaryClip(); + if (clipData == null || clipData.getItemCount() == 0) { return null; } + return clipData.getItemAt(0).getText(); } public boolean setText(CharSequence text) { - try { - Method method = getSetPrimaryClipMethod(); - ClipData clipData = ClipData.newPlainText(null, text); - setPrimaryClip(method, setMethodVersion, manager, clipData); - return true; - } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { - Ln.e("Could not invoke method", e); - return false; - } + ClipData clipData = ClipData.newPlainText(null, text); + manager.setPrimaryClip(clipData); + return true; } - private static void addPrimaryClipChangedListener(Method method, int methodVersion, IInterface manager, IOnPrimaryClipChangedListener listener) - throws InvocationTargetException, IllegalAccessException { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - method.invoke(manager, listener, FakeContext.PACKAGE_NAME); - return; - } - - switch (methodVersion) { - case 0: - method.invoke(manager, listener, FakeContext.PACKAGE_NAME, FakeContext.ROOT_UID); - break; - case 1: - method.invoke(manager, listener, FakeContext.PACKAGE_NAME, null, FakeContext.ROOT_UID); - break; - default: - method.invoke(manager, listener, FakeContext.PACKAGE_NAME, null, FakeContext.ROOT_UID, 0); - break; - } - } - - private Method getAddPrimaryClipChangedListener() throws NoSuchMethodException { - if (addPrimaryClipChangedListener == null) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - addPrimaryClipChangedListener = manager.getClass() - .getMethod("addPrimaryClipChangedListener", IOnPrimaryClipChangedListener.class, String.class); - } else { - try { - addPrimaryClipChangedListener = manager.getClass() - .getMethod("addPrimaryClipChangedListener", IOnPrimaryClipChangedListener.class, String.class, int.class); - addListenerMethodVersion = 0; - } catch (NoSuchMethodException e1) { - try { - addPrimaryClipChangedListener = manager.getClass() - .getMethod("addPrimaryClipChangedListener", IOnPrimaryClipChangedListener.class, String.class, String.class, - int.class); - addListenerMethodVersion = 1; - } catch (NoSuchMethodException e2) { - addPrimaryClipChangedListener = manager.getClass() - .getMethod("addPrimaryClipChangedListener", IOnPrimaryClipChangedListener.class, String.class, String.class, - int.class, int.class); - addListenerMethodVersion = 2; - } - } - } - } - return addPrimaryClipChangedListener; - } - - public boolean addPrimaryClipChangedListener(IOnPrimaryClipChangedListener listener) { - try { - Method method = getAddPrimaryClipChangedListener(); - addPrimaryClipChangedListener(method, addListenerMethodVersion, manager, listener); - return true; - } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { - Ln.e("Could not invoke method", e); - return false; - } + public void addPrimaryClipChangedListener(android.content.ClipboardManager.OnPrimaryClipChangedListener listener) { + manager.addPrimaryClipChangedListener(listener); } } diff --git a/server/src/main/java/com/genymobile/scrcpy/wrappers/ContentProvider.java b/server/src/main/java/com/genymobile/scrcpy/wrappers/ContentProvider.java index 8171988e..f625b398 100644 --- a/server/src/main/java/com/genymobile/scrcpy/wrappers/ContentProvider.java +++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/ContentProvider.java @@ -1,8 +1,9 @@ package com.genymobile.scrcpy.wrappers; +import com.genymobile.scrcpy.AndroidVersions; import com.genymobile.scrcpy.FakeContext; -import com.genymobile.scrcpy.Ln; -import com.genymobile.scrcpy.SettingsException; +import com.genymobile.scrcpy.util.Ln; +import com.genymobile.scrcpy.util.SettingsException; import android.annotation.SuppressLint; import android.content.AttributionSource; @@ -11,7 +12,6 @@ import android.os.Bundle; import android.os.IBinder; import java.io.Closeable; -import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; public final class ContentProvider implements Closeable { @@ -42,8 +42,6 @@ public final class ContentProvider implements Closeable { private Method callMethod; private int callMethodVersion; - private Object attributionSource; - ContentProvider(ActivityManager manager, Object provider, String name, IBinder token) { this.manager = manager; this.provider = provider; @@ -54,7 +52,7 @@ public final class ContentProvider implements Closeable { @SuppressLint("PrivateApi") private Method getCallMethod() throws NoSuchMethodException { if (callMethod == null) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (Build.VERSION.SDK_INT >= AndroidVersions.API_31_ANDROID_12) { callMethod = provider.getClass().getMethod("call", AttributionSource.class, String.class, String.class, String.class, Bundle.class); callMethodVersion = 0; } else { @@ -77,13 +75,12 @@ public final class ContentProvider implements Closeable { return callMethod; } - private Bundle call(String callMethod, String arg, Bundle extras) - throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { + private Bundle call(String callMethod, String arg, Bundle extras) throws ReflectiveOperationException { try { Method method = getCallMethod(); Object[] args; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && callMethodVersion == 0) { + if (Build.VERSION.SDK_INT >= AndroidVersions.API_31_ANDROID_12 && callMethodVersion == 0) { args = new Object[]{FakeContext.get().getAttributionSource(), "settings", callMethod, arg, extras}; } else { switch (callMethodVersion) { @@ -99,7 +96,7 @@ public final class ContentProvider implements Closeable { } } return (Bundle) method.invoke(provider, args); - } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { + } catch (ReflectiveOperationException e) { Ln.e("Could not invoke method", e); throw e; } diff --git a/server/src/main/java/com/genymobile/scrcpy/wrappers/DisplayControl.java b/server/src/main/java/com/genymobile/scrcpy/wrappers/DisplayControl.java index 4e19beb9..88ca3d3d 100644 --- a/server/src/main/java/com/genymobile/scrcpy/wrappers/DisplayControl.java +++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/DisplayControl.java @@ -1,17 +1,17 @@ package com.genymobile.scrcpy.wrappers; -import com.genymobile.scrcpy.Ln; +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.util.Ln; import android.annotation.SuppressLint; import android.annotation.TargetApi; -import android.os.Build; import android.os.IBinder; +import android.system.Os; -import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; @SuppressLint({"PrivateApi", "SoonBlockedPrivateApi", "BlockedPrivateApi"}) -@TargetApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +@TargetApi(AndroidVersions.API_34_ANDROID_14) public final class DisplayControl { private static final Class CLASS; @@ -22,7 +22,9 @@ public final class DisplayControl { Class classLoaderFactoryClass = Class.forName("com.android.internal.os.ClassLoaderFactory"); Method createClassLoaderMethod = classLoaderFactoryClass.getDeclaredMethod("createClassLoader", String.class, String.class, String.class, ClassLoader.class, int.class, boolean.class, String.class); - ClassLoader classLoader = (ClassLoader) createClassLoaderMethod.invoke(null, "/system/framework/services.jar", null, null, + + String systemServerClasspath = Os.getenv("SYSTEMSERVERCLASSPATH"); + ClassLoader classLoader = (ClassLoader) createClassLoaderMethod.invoke(null, systemServerClasspath, null, null, ClassLoader.getSystemClassLoader(), 0, true, null); displayControlClass = classLoader.loadClass("com.android.server.display.DisplayControl"); @@ -55,7 +57,7 @@ public final class DisplayControl { try { Method method = getGetPhysicalDisplayTokenMethod(); return (IBinder) method.invoke(null, physicalDisplayId); - } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { + } catch (ReflectiveOperationException e) { Ln.e("Could not invoke method", e); return null; } @@ -72,7 +74,7 @@ public final class DisplayControl { try { Method method = getGetPhysicalDisplayIdsMethod(); return (long[]) method.invoke(null); - } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { + } catch (ReflectiveOperationException e) { Ln.e("Could not invoke method", e); return null; } diff --git a/server/src/main/java/com/genymobile/scrcpy/wrappers/DisplayManager.java b/server/src/main/java/com/genymobile/scrcpy/wrappers/DisplayManager.java index 17b9ae4d..a12470a4 100644 --- a/server/src/main/java/com/genymobile/scrcpy/wrappers/DisplayManager.java +++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/DisplayManager.java @@ -1,20 +1,67 @@ package com.genymobile.scrcpy.wrappers; -import com.genymobile.scrcpy.Command; -import com.genymobile.scrcpy.DisplayInfo; -import com.genymobile.scrcpy.Ln; -import com.genymobile.scrcpy.Size; +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 { - private final Object manager; // instance of hidden class android.hardware.display.DisplayManagerGlobal - public DisplayManager(Object manager) { + // 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 getDisplayInfoMethod; + private Method createVirtualDisplayMethod; + private Method requestDisplayPowerMethod; + + static DisplayManager create() { + try { + Class clazz = Class.forName("android.hardware.display.DisplayManagerGlobal"); + Method getInstanceMethod = clazz.getDeclaredMethod("getInstance"); + Object dmg = getInstanceMethod.invoke(null); + return new DisplayManager(dmg); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } + + private DisplayManager(Object manager) { this.manager = manager; } @@ -22,7 +69,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()) { @@ -32,9 +79,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, null); } private static DisplayInfo getDisplayInfoFromDumpsysDisplay(int displayId) { @@ -48,28 +96,37 @@ public final class DisplayManager { } private static int parseDisplayFlags(String text) { - Pattern regex = Pattern.compile("FLAG_[A-Z_]+"); if (text == null) { return 0; } int flags = 0; + Pattern regex = Pattern.compile("FLAG_[A-Z_]+"); Matcher m = regex.matcher(text); while (m.find()) { String flagString = m.group(); try { Field filed = Display.class.getDeclaredField(flagString); flags |= filed.getInt(null); - } catch (NoSuchFieldException | IllegalAccessException e) { + } catch (ReflectiveOperationException e) { // Silently ignore, some flags reported by "dumpsys display" are @TestApi } } return flags; } + // getDisplayInfo() may be used from both the Controller thread and the video (main) thread + private synchronized Method getGetDisplayInfoMethod() throws NoSuchMethodException { + if (getDisplayInfoMethod == null) { + getDisplayInfoMethod = manager.getClass().getMethod("getDisplayInfo", int.class); + } + return getDisplayInfoMethod; + } + public DisplayInfo getDisplayInfo(int displayId) { try { - Object displayInfo = manager.getClass().getMethod("getDisplayInfo", int.class).invoke(manager, displayId); + Method method = getGetDisplayInfoMethod(); + Object displayInfo = method.invoke(manager, displayId); if (displayInfo == null) { // fallback when displayInfo is null return getDisplayInfoFromDumpsysDisplay(displayId); @@ -81,8 +138,10 @@ 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); - } catch (Exception e) { + int dpi = cls.getDeclaredField("logicalDensityDpi").getInt(displayInfo); + String uniqueId = (String) cls.getDeclaredField("uniqueId").get(displayInfo); + return new DisplayInfo(displayId, new Size(width, height), rotation, layerStack, flags, dpi, uniqueId); + } catch (ReflectiveOperationException e) { throw new AssertionError(e); } } @@ -90,8 +149,96 @@ public final class DisplayManager { public int[] getDisplayIds() { try { return (int[]) manager.getClass().getMethod("getDisplayIds").invoke(manager); - } catch (Exception e) { + } catch (ReflectiveOperationException e) { throw new AssertionError(e); } } + + private Method getCreateVirtualDisplayMethod() throws NoSuchMethodException { + if (createVirtualDisplayMethod == null) { + createVirtualDisplayMethod = android.hardware.display.DisplayManager.class + .getMethod("createVirtualDisplay", String.class, int.class, int.class, int.class, Surface.class); + } + return createVirtualDisplayMethod; + } + + public VirtualDisplay createVirtualDisplay(String name, int width, int height, int displayIdToMirror, Surface surface) throws Exception { + 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 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]); + } + if ("toString".equals(method.getName())) { + return "DisplayListener"; + } + 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); + } + } } diff --git a/server/src/main/java/com/genymobile/scrcpy/wrappers/DisplayWindowListener.java b/server/src/main/java/com/genymobile/scrcpy/wrappers/DisplayWindowListener.java new file mode 100644 index 00000000..f2ecb158 --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/DisplayWindowListener.java @@ -0,0 +1,39 @@ +package com.genymobile.scrcpy.wrappers; + +import android.content.res.Configuration; +import android.graphics.Rect; +import android.view.IDisplayWindowListener; + +import java.util.List; + +public class DisplayWindowListener extends IDisplayWindowListener.Stub { + @Override + public void onDisplayAdded(int displayId) { + // empty default implementation + } + + @Override + public void onDisplayConfigurationChanged(int displayId, Configuration newConfig) { + // empty default implementation + } + + @Override + public void onDisplayRemoved(int displayId) { + // empty default implementation + } + + @Override + public void onFixedRotationStarted(int displayId, int newRotation) { + // empty default implementation + } + + @Override + public void onFixedRotationFinished(int displayId) { + // empty default implementation + } + + @Override + public void onKeepClearAreasChanged(int displayId, List restricted, List unrestricted) { + // empty default implementation + } +} diff --git a/server/src/main/java/com/genymobile/scrcpy/wrappers/InputManager.java b/server/src/main/java/com/genymobile/scrcpy/wrappers/InputManager.java index ef0a4f50..f55648d5 100644 --- a/server/src/main/java/com/genymobile/scrcpy/wrappers/InputManager.java +++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/InputManager.java @@ -1,32 +1,46 @@ package com.genymobile.scrcpy.wrappers; -import com.genymobile.scrcpy.Ln; +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.FakeContext; +import com.genymobile.scrcpy.util.Ln; +import android.annotation.SuppressLint; +import android.annotation.TargetApi; import android.view.InputEvent; import android.view.MotionEvent; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +@SuppressLint("PrivateApi,DiscouragedPrivateApi") public final class InputManager { public static final int INJECT_INPUT_EVENT_MODE_ASYNC = 0; public static final int INJECT_INPUT_EVENT_MODE_WAIT_FOR_RESULT = 1; public static final int INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH = 2; - private final Object manager; - private Method injectInputEventMethod; + private final android.hardware.input.InputManager manager; + private long lastPermissionLogDate; + private static Method injectInputEventMethod; private static Method setDisplayIdMethod; private static Method setActionButtonMethod; + private static Method addUniqueIdAssociationByPortMethod; + private static Method removeUniqueIdAssociationByPortMethod; - public InputManager(Object manager) { + static InputManager create() { + android.hardware.input.InputManager manager = (android.hardware.input.InputManager) FakeContext.get() + .getSystemService(FakeContext.INPUT_SERVICE); + return new InputManager(manager); + } + + private InputManager(android.hardware.input.InputManager manager) { this.manager = manager; } - private Method getInjectInputEventMethod() throws NoSuchMethodException { + private static Method getInjectInputEventMethod() throws NoSuchMethodException { if (injectInputEventMethod == null) { - injectInputEventMethod = manager.getClass().getMethod("injectInputEvent", InputEvent.class, int.class); + injectInputEventMethod = android.hardware.input.InputManager.class.getMethod("injectInputEvent", InputEvent.class, int.class); } return injectInputEventMethod; } @@ -35,7 +49,24 @@ public final class InputManager { try { Method method = getInjectInputEventMethod(); return (boolean) method.invoke(manager, inputEvent, mode); - } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { + } catch (ReflectiveOperationException e) { + if (e instanceof InvocationTargetException) { + Throwable cause = e.getCause(); + if (cause instanceof SecurityException) { + String message = e.getCause().getMessage(); + if (message != null && message.contains("INJECT_EVENTS permission")) { + // Do not flood the console, limit to one permission error log every 3 seconds + long now = System.currentTimeMillis(); + if (lastPermissionLogDate <= now - 3000) { + Ln.e(message); + Ln.e("Make sure you have enabled \"USB debugging (Security Settings)\" and then rebooted your device."); + lastPermissionLogDate = now; + } + // Do not print the stack trace + return false; + } + } + } Ln.e("Could not invoke method", e); return false; } @@ -53,7 +84,7 @@ public final class InputManager { Method method = getSetDisplayIdMethod(); method.invoke(inputEvent, displayId); return true; - } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { + } catch (ReflectiveOperationException e) { Ln.e("Cannot associate a display id to the input event", e); return false; } @@ -71,9 +102,45 @@ public final class InputManager { Method method = getSetActionButtonMethod(); method.invoke(motionEvent, actionButton); return true; - } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { + } catch (ReflectiveOperationException e) { Ln.e("Cannot set action button on MotionEvent", e); return false; } } + + private static Method getAddUniqueIdAssociationByPortMethod() throws NoSuchMethodException { + if (addUniqueIdAssociationByPortMethod == null) { + addUniqueIdAssociationByPortMethod = android.hardware.input.InputManager.class.getMethod( + "addUniqueIdAssociationByPort", String.class, String.class); + } + return addUniqueIdAssociationByPortMethod; + } + + @TargetApi(AndroidVersions.API_35_ANDROID_15) + public void addUniqueIdAssociationByPort(String inputPort, String uniqueId) { + try { + Method method = getAddUniqueIdAssociationByPortMethod(); + method.invoke(manager, inputPort, uniqueId); + } catch (ReflectiveOperationException e) { + Ln.e("Cannot add unique id association by port", e); + } + } + + private static Method getRemoveUniqueIdAssociationByPortMethod() throws NoSuchMethodException { + if (removeUniqueIdAssociationByPortMethod == null) { + removeUniqueIdAssociationByPortMethod = android.hardware.input.InputManager.class.getMethod( + "removeUniqueIdAssociationByPort", String.class); + } + return removeUniqueIdAssociationByPortMethod; + } + + @TargetApi(AndroidVersions.API_35_ANDROID_15) + public void removeUniqueIdAssociationByPort(String inputPort) { + try { + Method method = getRemoveUniqueIdAssociationByPortMethod(); + method.invoke(manager, inputPort); + } catch (ReflectiveOperationException e) { + Ln.e("Cannot remove unique id association by port", e); + } + } } diff --git a/server/src/main/java/com/genymobile/scrcpy/wrappers/PowerManager.java b/server/src/main/java/com/genymobile/scrcpy/wrappers/PowerManager.java index 93722687..b5fefdd8 100644 --- a/server/src/main/java/com/genymobile/scrcpy/wrappers/PowerManager.java +++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/PowerManager.java @@ -1,36 +1,46 @@ package com.genymobile.scrcpy.wrappers; -import com.genymobile.scrcpy.Ln; +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.util.Ln; -import android.annotation.SuppressLint; import android.os.Build; import android.os.IInterface; -import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; public final class PowerManager { private final IInterface manager; private Method isScreenOnMethod; - public PowerManager(IInterface manager) { + static PowerManager create() { + IInterface manager = ServiceManager.getService("power", "android.os.IPowerManager"); + return new PowerManager(manager); + } + + private PowerManager(IInterface manager) { this.manager = manager; } private Method getIsScreenOnMethod() throws NoSuchMethodException { if (isScreenOnMethod == null) { - @SuppressLint("ObsoleteSdkInt") // we may lower minSdkVersion in the future - String methodName = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH ? "isInteractive" : "isScreenOn"; - isScreenOnMethod = manager.getClass().getMethod(methodName); + 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 (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { + } catch (ReflectiveOperationException e) { Ln.e("Could not invoke method", e); return false; } diff --git a/server/src/main/java/com/genymobile/scrcpy/wrappers/ServiceManager.java b/server/src/main/java/com/genymobile/scrcpy/wrappers/ServiceManager.java index 85602c19..b1123b55 100644 --- a/server/src/main/java/com/genymobile/scrcpy/wrappers/ServiceManager.java +++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/ServiceManager.java @@ -9,7 +9,6 @@ import android.os.IBinder; import android.os.IInterface; import java.lang.reflect.Constructor; -import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; @SuppressLint("PrivateApi,DiscouragedPrivateApi") @@ -38,7 +37,7 @@ public final class ServiceManager { /* not instantiable */ } - private static IInterface getService(String service, String type) { + static IInterface getService(String service, String type) { try { IBinder binder = (IBinder) GET_SERVICE_METHOD.invoke(null, service); Method asInterfaceMethod = Class.forName(type + "$Stub").getMethod("asInterface", IBinder.class); @@ -50,90 +49,52 @@ public final class ServiceManager { public static WindowManager getWindowManager() { if (windowManager == null) { - windowManager = new WindowManager(getService("window", "android.view.IWindowManager")); + windowManager = WindowManager.create(); } return windowManager; } - public static DisplayManager getDisplayManager() { + // The DisplayManager may be used from both the Controller thread and the video (main) thread + public static synchronized DisplayManager getDisplayManager() { if (displayManager == null) { - try { - Class clazz = Class.forName("android.hardware.display.DisplayManagerGlobal"); - Method getInstanceMethod = clazz.getDeclaredMethod("getInstance"); - Object dmg = getInstanceMethod.invoke(null); - displayManager = new DisplayManager(dmg); - } catch (ClassNotFoundException | NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { - throw new AssertionError(e); - } + displayManager = DisplayManager.create(); } return displayManager; } - public static Class getInputManagerClass() { - try { - // Parts of the InputManager class have been moved to a new InputManagerGlobal class in Android 14 preview - return Class.forName("android.hardware.input.InputManagerGlobal"); - } catch (ClassNotFoundException e) { - return android.hardware.input.InputManager.class; - } - } - public static InputManager getInputManager() { if (inputManager == null) { - try { - Class inputManagerClass = getInputManagerClass(); - Method getInstanceMethod = inputManagerClass.getDeclaredMethod("getInstance"); - Object im = getInstanceMethod.invoke(null); - inputManager = new InputManager(im); - } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { - throw new AssertionError(e); - } + inputManager = InputManager.create(); } return inputManager; } public static PowerManager getPowerManager() { if (powerManager == null) { - powerManager = new PowerManager(getService("power", "android.os.IPowerManager")); + powerManager = PowerManager.create(); } return powerManager; } public static StatusBarManager getStatusBarManager() { if (statusBarManager == null) { - statusBarManager = new StatusBarManager(getService("statusbar", "com.android.internal.statusbar.IStatusBarService")); + statusBarManager = StatusBarManager.create(); } return statusBarManager; } public static ClipboardManager getClipboardManager() { if (clipboardManager == null) { - IInterface clipboard = getService("clipboard", "android.content.IClipboard"); - if (clipboard == null) { - // Some devices have no clipboard manager - // - // - return null; - } - clipboardManager = new ClipboardManager(clipboard); + // May be null, some devices have no clipboard manager + clipboardManager = ClipboardManager.create(); } return clipboardManager; } public static ActivityManager getActivityManager() { if (activityManager == null) { - try { - // On old Android versions, the ActivityManager is not exposed via AIDL, - // so use ActivityManagerNative.getDefault() - Class cls = Class.forName("android.app.ActivityManagerNative"); - Method getDefaultMethod = cls.getDeclaredMethod("getDefault"); - IInterface am = (IInterface) getDefaultMethod.invoke(null); - activityManager = new ActivityManager(am); - } catch (Exception e) { - throw new AssertionError(e); - } + activityManager = ActivityManager.create(); } - return activityManager; } diff --git a/server/src/main/java/com/genymobile/scrcpy/wrappers/StatusBarManager.java b/server/src/main/java/com/genymobile/scrcpy/wrappers/StatusBarManager.java index 9126d5ed..ca80dde2 100644 --- a/server/src/main/java/com/genymobile/scrcpy/wrappers/StatusBarManager.java +++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/StatusBarManager.java @@ -1,10 +1,9 @@ package com.genymobile.scrcpy.wrappers; -import com.genymobile.scrcpy.Ln; +import com.genymobile.scrcpy.util.Ln; import android.os.IInterface; -import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; public final class StatusBarManager { @@ -16,7 +15,12 @@ public final class StatusBarManager { private boolean expandSettingsPanelMethodNewVersion = true; private Method collapsePanelsMethod; - public StatusBarManager(IInterface manager) { + static StatusBarManager create() { + IInterface manager = ServiceManager.getService("statusbar", "com.android.internal.statusbar.IStatusBarService"); + return new StatusBarManager(manager); + } + + private StatusBarManager(IInterface manager) { this.manager = manager; } @@ -62,7 +66,7 @@ public final class StatusBarManager { } else { method.invoke(manager); } - } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { + } catch (ReflectiveOperationException e) { Ln.e("Could not invoke method", e); } } @@ -77,7 +81,7 @@ public final class StatusBarManager { // old version method.invoke(manager); } - } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { + } catch (ReflectiveOperationException e) { Ln.e("Could not invoke method", e); } } @@ -86,7 +90,7 @@ public final class StatusBarManager { try { Method method = getCollapsePanelsMethod(); method.invoke(manager); - } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { + } catch (ReflectiveOperationException e) { Ln.e("Could not invoke method", e); } } diff --git a/server/src/main/java/com/genymobile/scrcpy/wrappers/SurfaceControl.java b/server/src/main/java/com/genymobile/scrcpy/wrappers/SurfaceControl.java index 98259e7f..3bae4a37 100644 --- a/server/src/main/java/com/genymobile/scrcpy/wrappers/SurfaceControl.java +++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/SurfaceControl.java @@ -1,6 +1,7 @@ package com.genymobile.scrcpy.wrappers; -import com.genymobile.scrcpy.Ln; +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.util.Ln; import android.annotation.SuppressLint; import android.graphics.Rect; @@ -8,7 +9,6 @@ import android.os.Build; import android.os.IBinder; import android.view.Surface; -import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; @SuppressLint("PrivateApi") @@ -78,19 +78,15 @@ public final class SurfaceControl { } } - public static IBinder createDisplay(String name, boolean secure) { - try { - return (IBinder) CLASS.getMethod("createDisplay", String.class, boolean.class).invoke(null, name, secure); - } catch (Exception e) { - throw new AssertionError(e); - } + public static IBinder createDisplay(String name, boolean secure) throws Exception { + return (IBinder) CLASS.getMethod("createDisplay", String.class, boolean.class).invoke(null, name, secure); } private static Method getGetBuiltInDisplayMethod() throws NoSuchMethodException { if (getBuiltInDisplayMethod == null) { - // the method signature has changed in Android Q + // the method signature has changed in Android 10 // - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + if (Build.VERSION.SDK_INT < AndroidVersions.API_29_ANDROID_10) { getBuiltInDisplayMethod = CLASS.getMethod("getBuiltInDisplay", int.class); } else { getBuiltInDisplayMethod = CLASS.getMethod("getInternalDisplayToken"); @@ -99,17 +95,26 @@ public final class SurfaceControl { return getBuiltInDisplayMethod; } + public static boolean hasGetBuildInDisplayMethod() { + try { + getGetBuiltInDisplayMethod(); + return true; + } catch (NoSuchMethodException e) { + return false; + } + } + public static IBinder getBuiltInDisplay() { try { Method method = getGetBuiltInDisplayMethod(); - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + if (Build.VERSION.SDK_INT < AndroidVersions.API_29_ANDROID_10) { // call getBuiltInDisplay(0) return (IBinder) method.invoke(null, 0); } // call getInternalDisplayToken() return (IBinder) method.invoke(null); - } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { + } catch (ReflectiveOperationException e) { Ln.e("Could not invoke method", e); return null; } @@ -126,7 +131,7 @@ public final class SurfaceControl { try { Method method = getGetPhysicalDisplayTokenMethod(); return (IBinder) method.invoke(null, physicalDisplayId); - } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { + } catch (ReflectiveOperationException e) { Ln.e("Could not invoke method", e); return null; } @@ -139,7 +144,7 @@ public final class SurfaceControl { return getPhysicalDisplayIdsMethod; } - public static boolean hasPhysicalDisplayIdsMethod() { + public static boolean hasGetPhysicalDisplayIdsMethod() { try { getGetPhysicalDisplayIdsMethod(); return true; @@ -152,7 +157,7 @@ public final class SurfaceControl { try { Method method = getGetPhysicalDisplayIdsMethod(); return (long[]) method.invoke(null); - } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { + } catch (ReflectiveOperationException e) { Ln.e("Could not invoke method", e); return null; } @@ -170,7 +175,7 @@ public final class SurfaceControl { Method method = getSetDisplayPowerModeMethod(); method.invoke(null, displayToken, mode); return true; - } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { + } catch (ReflectiveOperationException e) { Ln.e("Could not invoke method", e); return false; } diff --git a/server/src/main/java/com/genymobile/scrcpy/wrappers/WindowManager.java b/server/src/main/java/com/genymobile/scrcpy/wrappers/WindowManager.java index a746be5c..7ba5cc06 100644 --- a/server/src/main/java/com/genymobile/scrcpy/wrappers/WindowManager.java +++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/WindowManager.java @@ -1,23 +1,44 @@ package com.genymobile.scrcpy.wrappers; -import com.genymobile.scrcpy.Ln; +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.util.Ln; import android.annotation.TargetApi; +import android.os.Build; import android.os.IInterface; -import android.view.IDisplayFoldListener; -import android.view.IRotationWatcher; +import android.view.IDisplayWindowListener; -import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; public final class WindowManager { + + @SuppressWarnings("checkstyle:LineLength") + // + public static final int DISPLAY_IME_POLICY_LOCAL = 0; + public static final int DISPLAY_IME_POLICY_FALLBACK_DISPLAY = 1; + public static final int DISPLAY_IME_POLICY_HIDE = 2; + private final IInterface manager; private Method getRotationMethod; - private Method freezeRotationMethod; - private Method isRotationFrozenMethod; - private Method thawRotationMethod; - public WindowManager(IInterface manager) { + private Method freezeDisplayRotationMethod; + private int freezeDisplayRotationMethodVersion; + + private Method isDisplayRotationFrozenMethod; + private int isDisplayRotationFrozenMethodVersion; + + private Method thawDisplayRotationMethod; + private int thawDisplayRotationMethodVersion; + + private Method getDisplayImePolicyMethod; + private Method setDisplayImePolicyMethod; + + static WindowManager create() { + IInterface manager = ServiceManager.getService("window", "android.view.IWindowManager"); + return new WindowManager(manager); + } + + private WindowManager(IInterface manager) { this.manager = manager; } @@ -36,88 +57,211 @@ public final class WindowManager { return getRotationMethod; } - private Method getFreezeRotationMethod() throws NoSuchMethodException { - if (freezeRotationMethod == null) { - freezeRotationMethod = manager.getClass().getMethod("freezeRotation", int.class); + private Method getFreezeDisplayRotationMethod() throws NoSuchMethodException { + if (freezeDisplayRotationMethod == null) { + try { + // Android 15 preview and 14 QPR3 Beta added a String caller parameter for debugging: + // + freezeDisplayRotationMethod = manager.getClass().getMethod("freezeDisplayRotation", int.class, int.class, String.class); + freezeDisplayRotationMethodVersion = 0; + } catch (NoSuchMethodException e) { + try { + // New method added by this commit: + // + freezeDisplayRotationMethod = manager.getClass().getMethod("freezeDisplayRotation", int.class, int.class); + freezeDisplayRotationMethodVersion = 1; + } catch (NoSuchMethodException e1) { + freezeDisplayRotationMethod = manager.getClass().getMethod("freezeRotation", int.class); + freezeDisplayRotationMethodVersion = 2; + } + } } - return freezeRotationMethod; + return freezeDisplayRotationMethod; } - private Method getIsRotationFrozenMethod() throws NoSuchMethodException { - if (isRotationFrozenMethod == null) { - isRotationFrozenMethod = manager.getClass().getMethod("isRotationFrozen"); + private Method getIsDisplayRotationFrozenMethod() throws NoSuchMethodException { + if (isDisplayRotationFrozenMethod == null) { + try { + // New method added by this commit: + // + isDisplayRotationFrozenMethod = manager.getClass().getMethod("isDisplayRotationFrozen", int.class); + isDisplayRotationFrozenMethodVersion = 0; + } catch (NoSuchMethodException e) { + isDisplayRotationFrozenMethod = manager.getClass().getMethod("isRotationFrozen"); + isDisplayRotationFrozenMethodVersion = 1; + } } - return isRotationFrozenMethod; + return isDisplayRotationFrozenMethod; } - private Method getThawRotationMethod() throws NoSuchMethodException { - if (thawRotationMethod == null) { - thawRotationMethod = manager.getClass().getMethod("thawRotation"); + private Method getThawDisplayRotationMethod() throws NoSuchMethodException { + if (thawDisplayRotationMethod == null) { + try { + // Android 15 preview and 14 QPR3 Beta added a String caller parameter for debugging: + // + thawDisplayRotationMethod = manager.getClass().getMethod("thawDisplayRotation", int.class, String.class); + thawDisplayRotationMethodVersion = 0; + } catch (NoSuchMethodException e) { + try { + // New method added by this commit: + // + thawDisplayRotationMethod = manager.getClass().getMethod("thawDisplayRotation", int.class); + thawDisplayRotationMethodVersion = 1; + } catch (NoSuchMethodException e1) { + thawDisplayRotationMethod = manager.getClass().getMethod("thawRotation"); + thawDisplayRotationMethodVersion = 2; + } + } } - return thawRotationMethod; + return thawDisplayRotationMethod; } public int getRotation() { try { Method method = getGetRotationMethod(); return (int) method.invoke(manager); - } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { + } catch (ReflectiveOperationException e) { Ln.e("Could not invoke method", e); return 0; } } - public void freezeRotation(int rotation) { + public void freezeRotation(int displayId, int rotation) { try { - Method method = getFreezeRotationMethod(); - method.invoke(manager, rotation); - } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { + Method method = getFreezeDisplayRotationMethod(); + switch (freezeDisplayRotationMethodVersion) { + case 0: + method.invoke(manager, displayId, rotation, "scrcpy#freezeRotation"); + break; + case 1: + method.invoke(manager, displayId, rotation); + break; + default: + if (displayId != 0) { + Ln.e("Secondary display rotation not supported on this device"); + return; + } + method.invoke(manager, rotation); + break; + } + } catch (ReflectiveOperationException e) { Ln.e("Could not invoke method", e); } } - public boolean isRotationFrozen() { + public boolean isRotationFrozen(int displayId) { try { - Method method = getIsRotationFrozenMethod(); - return (boolean) method.invoke(manager); - } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { + Method method = getIsDisplayRotationFrozenMethod(); + switch (isDisplayRotationFrozenMethodVersion) { + case 0: + return (boolean) method.invoke(manager, displayId); + default: + if (displayId != 0) { + Ln.e("Secondary display rotation not supported on this device"); + return false; + } + return (boolean) method.invoke(manager); + } + } catch (ReflectiveOperationException e) { Ln.e("Could not invoke method", e); return false; } } - public void thawRotation() { + public void thawRotation(int displayId) { try { - Method method = getThawRotationMethod(); - method.invoke(manager); - } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { + Method method = getThawDisplayRotationMethod(); + switch (thawDisplayRotationMethodVersion) { + case 0: + method.invoke(manager, displayId, "scrcpy#thawRotation"); + break; + case 1: + method.invoke(manager, displayId); + break; + default: + if (displayId != 0) { + Ln.e("Secondary display rotation not supported on this device"); + return; + } + method.invoke(manager); + break; + } + } catch (ReflectiveOperationException e) { Ln.e("Could not invoke method", e); } } - public void registerRotationWatcher(IRotationWatcher rotationWatcher, int displayId) { + @TargetApi(AndroidVersions.API_30_ANDROID_11) + public int[] registerDisplayWindowListener(IDisplayWindowListener listener) { try { - Class cls = manager.getClass(); - try { - // display parameter added since this commit: - // https://android.googlesource.com/platform/frameworks/base/+/35fa3c26adcb5f6577849fd0df5228b1f67cf2c6%5E%21/#F1 - cls.getMethod("watchRotation", IRotationWatcher.class, int.class).invoke(manager, rotationWatcher, displayId); - } catch (NoSuchMethodException e) { - // old version - cls.getMethod("watchRotation", IRotationWatcher.class).invoke(manager, rotationWatcher); - } + return (int[]) manager.getClass().getMethod("registerDisplayWindowListener", IDisplayWindowListener.class).invoke(manager, listener); } catch (Exception e) { - Ln.e("Could not register rotation watcher", e); + Ln.e("Could not register display window listener", e); + } + return null; + } + + @TargetApi(AndroidVersions.API_30_ANDROID_11) + public void unregisterDisplayWindowListener(IDisplayWindowListener listener) { + try { + manager.getClass().getMethod("unregisterDisplayWindowListener", IDisplayWindowListener.class).invoke(manager, listener); + } catch (Exception e) { + Ln.e("Could not unregister display window listener", e); } } - @TargetApi(29) - public void registerDisplayFoldListener(IDisplayFoldListener foldListener) { + @TargetApi(AndroidVersions.API_29_ANDROID_10) + private Method getGetDisplayImePolicyMethod() throws NoSuchMethodException { + if (getDisplayImePolicyMethod == null) { + if (Build.VERSION.SDK_INT >= AndroidVersions.API_31_ANDROID_12) { + getDisplayImePolicyMethod = manager.getClass().getMethod("getDisplayImePolicy", int.class); + } else { + getDisplayImePolicyMethod = manager.getClass().getMethod("shouldShowIme", int.class); + } + } + return getDisplayImePolicyMethod; + } + + @TargetApi(AndroidVersions.API_29_ANDROID_10) + public int getDisplayImePolicy(int displayId) { try { - Class cls = manager.getClass(); - cls.getMethod("registerDisplayFoldListener", IDisplayFoldListener.class).invoke(manager, foldListener); - } catch (Exception e) { - Ln.e("Could not register display fold listener", e); + Method method = getGetDisplayImePolicyMethod(); + if (Build.VERSION.SDK_INT >= AndroidVersions.API_31_ANDROID_12) { + return (int) method.invoke(manager, displayId); + } + boolean shouldShowIme = (boolean) method.invoke(manager, displayId); + return shouldShowIme ? DISPLAY_IME_POLICY_LOCAL : DISPLAY_IME_POLICY_FALLBACK_DISPLAY; + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + return -1; + } + } + + @TargetApi(AndroidVersions.API_29_ANDROID_10) + private Method getSetDisplayImePolicyMethod() throws NoSuchMethodException { + if (setDisplayImePolicyMethod == null) { + if (Build.VERSION.SDK_INT >= AndroidVersions.API_31_ANDROID_12) { + setDisplayImePolicyMethod = manager.getClass().getMethod("setDisplayImePolicy", int.class, int.class); + } else { + setDisplayImePolicyMethod = manager.getClass().getMethod("setShouldShowIme", int.class, boolean.class); + } + } + return setDisplayImePolicyMethod; + } + + @TargetApi(AndroidVersions.API_29_ANDROID_10) + public void setDisplayImePolicy(int displayId, int displayImePolicy) { + try { + Method method = getSetDisplayImePolicyMethod(); + if (Build.VERSION.SDK_INT >= AndroidVersions.API_31_ANDROID_12) { + method.invoke(manager, displayId, displayImePolicy); + } else if (displayImePolicy != DISPLAY_IME_POLICY_HIDE) { + method.invoke(manager, displayId, displayImePolicy == DISPLAY_IME_POLICY_LOCAL); + } else { + Ln.w("DISPLAY_IME_POLICY_HIDE is not supported before Android 12"); + } + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); } } } diff --git a/server/src/test/java/com/genymobile/scrcpy/ControlMessageReaderTest.java b/server/src/test/java/com/genymobile/scrcpy/control/ControlMessageReaderTest.java similarity index 57% rename from server/src/test/java/com/genymobile/scrcpy/ControlMessageReaderTest.java rename to server/src/test/java/com/genymobile/scrcpy/control/ControlMessageReaderTest.java index 47097c78..0cc0a6b5 100644 --- a/server/src/test/java/com/genymobile/scrcpy/ControlMessageReaderTest.java +++ b/server/src/test/java/com/genymobile/scrcpy/control/ControlMessageReaderTest.java @@ -1,4 +1,4 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.control; import android.view.KeyEvent; import android.view.MotionEvent; @@ -8,6 +8,7 @@ import org.junit.Test; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.DataOutputStream; +import java.io.EOFException; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Arrays; @@ -16,8 +17,6 @@ public class ControlMessageReaderTest { @Test public void testParseKeycodeEvent() throws IOException { - ControlMessageReader reader = new ControlMessageReader(); - ByteArrayOutputStream bos = new ByteArrayOutputStream(); DataOutputStream dos = new DataOutputStream(bos); dos.writeByte(ControlMessage.TYPE_INJECT_KEYCODE); @@ -27,23 +26,21 @@ public class ControlMessageReaderTest { dos.writeInt(KeyEvent.META_CTRL_ON); byte[] packet = bos.toByteArray(); - // The message type (1 byte) does not count - Assert.assertEquals(ControlMessageReader.INJECT_KEYCODE_PAYLOAD_LENGTH, packet.length - 1); - - reader.readFrom(new ByteArrayInputStream(packet)); - ControlMessage event = reader.next(); + ByteArrayInputStream bis = new ByteArrayInputStream(packet); + ControlMessageReader reader = new ControlMessageReader(bis); + ControlMessage event = reader.read(); Assert.assertEquals(ControlMessage.TYPE_INJECT_KEYCODE, event.getType()); Assert.assertEquals(KeyEvent.ACTION_UP, event.getAction()); Assert.assertEquals(KeyEvent.KEYCODE_ENTER, event.getKeycode()); Assert.assertEquals(5, event.getRepeat()); Assert.assertEquals(KeyEvent.META_CTRL_ON, event.getMetaState()); + + Assert.assertEquals(-1, bis.read()); // EOS } @Test public void testParseTextEvent() throws IOException { - ControlMessageReader reader = new ControlMessageReader(); - ByteArrayOutputStream bos = new ByteArrayOutputStream(); DataOutputStream dos = new DataOutputStream(bos); dos.writeByte(ControlMessage.TYPE_INJECT_TEXT); @@ -52,17 +49,18 @@ public class ControlMessageReaderTest { dos.write(text); byte[] packet = bos.toByteArray(); - reader.readFrom(new ByteArrayInputStream(packet)); - ControlMessage event = reader.next(); + ByteArrayInputStream bis = new ByteArrayInputStream(packet); + ControlMessageReader reader = new ControlMessageReader(bis); + ControlMessage event = reader.read(); Assert.assertEquals(ControlMessage.TYPE_INJECT_TEXT, event.getType()); Assert.assertEquals("testé", event.getText()); + + Assert.assertEquals(-1, bis.read()); // EOS } @Test public void testParseLongTextEvent() throws IOException { - ControlMessageReader reader = new ControlMessageReader(); - ByteArrayOutputStream bos = new ByteArrayOutputStream(); DataOutputStream dos = new DataOutputStream(bos); dos.writeByte(ControlMessage.TYPE_INJECT_TEXT); @@ -72,17 +70,18 @@ public class ControlMessageReaderTest { dos.write(text); byte[] packet = bos.toByteArray(); - reader.readFrom(new ByteArrayInputStream(packet)); - ControlMessage event = reader.next(); + ByteArrayInputStream bis = new ByteArrayInputStream(packet); + ControlMessageReader reader = new ControlMessageReader(bis); + ControlMessage event = reader.read(); Assert.assertEquals(ControlMessage.TYPE_INJECT_TEXT, event.getType()); Assert.assertEquals(new String(text, StandardCharsets.US_ASCII), event.getText()); + + Assert.assertEquals(-1, bis.read()); // EOS } @Test public void testParseTouchEvent() throws IOException { - ControlMessageReader reader = new ControlMessageReader(); - ByteArrayOutputStream bos = new ByteArrayOutputStream(); DataOutputStream dos = new DataOutputStream(bos); dos.writeByte(ControlMessage.TYPE_INJECT_TOUCH_EVENT); @@ -98,12 +97,10 @@ public class ControlMessageReaderTest { byte[] packet = bos.toByteArray(); - // The message type (1 byte) does not count - Assert.assertEquals(ControlMessageReader.INJECT_TOUCH_EVENT_PAYLOAD_LENGTH, packet.length - 1); - - reader.readFrom(new ByteArrayInputStream(packet)); - ControlMessage event = reader.next(); + ByteArrayInputStream bis = new ByteArrayInputStream(packet); + ControlMessageReader reader = new ControlMessageReader(bis); + ControlMessage event = reader.read(); Assert.assertEquals(ControlMessage.TYPE_INJECT_TOUCH_EVENT, event.getType()); Assert.assertEquals(MotionEvent.ACTION_DOWN, event.getAction()); Assert.assertEquals(-42, event.getPointerId()); @@ -114,12 +111,12 @@ public class ControlMessageReaderTest { Assert.assertEquals(1f, event.getPressure(), 0f); // must be exact Assert.assertEquals(MotionEvent.BUTTON_PRIMARY, event.getActionButton()); Assert.assertEquals(MotionEvent.BUTTON_PRIMARY, event.getButtons()); + + Assert.assertEquals(-1, bis.read()); // EOS } @Test public void testParseScrollEvent() throws IOException { - ControlMessageReader reader = new ControlMessageReader(); - ByteArrayOutputStream bos = new ByteArrayOutputStream(); DataOutputStream dos = new DataOutputStream(bos); dos.writeByte(ControlMessage.TYPE_INJECT_SCROLL_EVENT); @@ -128,115 +125,112 @@ public class ControlMessageReaderTest { dos.writeShort(1080); dos.writeShort(1920); dos.writeShort(0); // 0.0f encoded as i16 - dos.writeShort(0x8000); // -1.0f encoded as i16 + dos.writeShort(0x8000); // -16.0f encoded as i16 (the range is [-16, 16]) dos.writeInt(1); - byte[] packet = bos.toByteArray(); - // The message type (1 byte) does not count - Assert.assertEquals(ControlMessageReader.INJECT_SCROLL_EVENT_PAYLOAD_LENGTH, packet.length - 1); - - reader.readFrom(new ByteArrayInputStream(packet)); - ControlMessage event = reader.next(); + ByteArrayInputStream bis = new ByteArrayInputStream(packet); + ControlMessageReader reader = new ControlMessageReader(bis); + ControlMessage event = reader.read(); Assert.assertEquals(ControlMessage.TYPE_INJECT_SCROLL_EVENT, event.getType()); Assert.assertEquals(260, event.getPosition().getPoint().getX()); Assert.assertEquals(1026, event.getPosition().getPoint().getY()); Assert.assertEquals(1080, event.getPosition().getScreenSize().getWidth()); Assert.assertEquals(1920, event.getPosition().getScreenSize().getHeight()); Assert.assertEquals(0f, event.getHScroll(), 0f); - Assert.assertEquals(-1f, event.getVScroll(), 0f); + Assert.assertEquals(-16f, event.getVScroll(), 0f); Assert.assertEquals(1, event.getButtons()); + + Assert.assertEquals(-1, bis.read()); // EOS } @Test public void testParseBackOrScreenOnEvent() throws IOException { - ControlMessageReader reader = new ControlMessageReader(); - ByteArrayOutputStream bos = new ByteArrayOutputStream(); DataOutputStream dos = new DataOutputStream(bos); dos.writeByte(ControlMessage.TYPE_BACK_OR_SCREEN_ON); dos.writeByte(KeyEvent.ACTION_UP); - byte[] packet = bos.toByteArray(); - reader.readFrom(new ByteArrayInputStream(packet)); - ControlMessage event = reader.next(); + ByteArrayInputStream bis = new ByteArrayInputStream(packet); + ControlMessageReader reader = new ControlMessageReader(bis); + ControlMessage event = reader.read(); Assert.assertEquals(ControlMessage.TYPE_BACK_OR_SCREEN_ON, event.getType()); Assert.assertEquals(KeyEvent.ACTION_UP, event.getAction()); + + Assert.assertEquals(-1, bis.read()); // EOS } @Test public void testParseExpandNotificationPanelEvent() throws IOException { - ControlMessageReader reader = new ControlMessageReader(); - ByteArrayOutputStream bos = new ByteArrayOutputStream(); DataOutputStream dos = new DataOutputStream(bos); dos.writeByte(ControlMessage.TYPE_EXPAND_NOTIFICATION_PANEL); - byte[] packet = bos.toByteArray(); - reader.readFrom(new ByteArrayInputStream(packet)); - ControlMessage event = reader.next(); + ByteArrayInputStream bis = new ByteArrayInputStream(packet); + ControlMessageReader reader = new ControlMessageReader(bis); + ControlMessage event = reader.read(); Assert.assertEquals(ControlMessage.TYPE_EXPAND_NOTIFICATION_PANEL, event.getType()); + + Assert.assertEquals(-1, bis.read()); // EOS } @Test public void testParseExpandSettingsPanelEvent() throws IOException { - ControlMessageReader reader = new ControlMessageReader(); - ByteArrayOutputStream bos = new ByteArrayOutputStream(); DataOutputStream dos = new DataOutputStream(bos); dos.writeByte(ControlMessage.TYPE_EXPAND_SETTINGS_PANEL); - byte[] packet = bos.toByteArray(); - reader.readFrom(new ByteArrayInputStream(packet)); - ControlMessage event = reader.next(); + ByteArrayInputStream bis = new ByteArrayInputStream(packet); + ControlMessageReader reader = new ControlMessageReader(bis); + ControlMessage event = reader.read(); Assert.assertEquals(ControlMessage.TYPE_EXPAND_SETTINGS_PANEL, event.getType()); + + Assert.assertEquals(-1, bis.read()); // EOS } @Test public void testParseCollapsePanelsEvent() throws IOException { - ControlMessageReader reader = new ControlMessageReader(); - ByteArrayOutputStream bos = new ByteArrayOutputStream(); DataOutputStream dos = new DataOutputStream(bos); dos.writeByte(ControlMessage.TYPE_COLLAPSE_PANELS); - byte[] packet = bos.toByteArray(); - reader.readFrom(new ByteArrayInputStream(packet)); - ControlMessage event = reader.next(); + ByteArrayInputStream bis = new ByteArrayInputStream(packet); + ControlMessageReader reader = new ControlMessageReader(bis); + ControlMessage event = reader.read(); Assert.assertEquals(ControlMessage.TYPE_COLLAPSE_PANELS, event.getType()); + + Assert.assertEquals(-1, bis.read()); // EOS } @Test public void testParseGetClipboardEvent() throws IOException { - ControlMessageReader reader = new ControlMessageReader(); - ByteArrayOutputStream bos = new ByteArrayOutputStream(); DataOutputStream dos = new DataOutputStream(bos); dos.writeByte(ControlMessage.TYPE_GET_CLIPBOARD); dos.writeByte(ControlMessage.COPY_KEY_COPY); - byte[] packet = bos.toByteArray(); - reader.readFrom(new ByteArrayInputStream(packet)); - ControlMessage event = reader.next(); + ByteArrayInputStream bis = new ByteArrayInputStream(packet); + ControlMessageReader reader = new ControlMessageReader(bis); + ControlMessage event = reader.read(); Assert.assertEquals(ControlMessage.TYPE_GET_CLIPBOARD, event.getType()); Assert.assertEquals(ControlMessage.COPY_KEY_COPY, event.getCopyKey()); + + Assert.assertEquals(-1, bis.read()); // EOS } @Test public void testParseSetClipboardEvent() throws IOException { - ControlMessageReader reader = new ControlMessageReader(); - ByteArrayOutputStream bos = new ByteArrayOutputStream(); DataOutputStream dos = new DataOutputStream(bos); dos.writeByte(ControlMessage.TYPE_SET_CLIPBOARD); @@ -245,22 +239,22 @@ public class ControlMessageReaderTest { byte[] text = "testé".getBytes(StandardCharsets.UTF_8); dos.writeInt(text.length); dos.write(text); - byte[] packet = bos.toByteArray(); - reader.readFrom(new ByteArrayInputStream(packet)); - ControlMessage event = reader.next(); + ByteArrayInputStream bis = new ByteArrayInputStream(packet); + ControlMessageReader reader = new ControlMessageReader(bis); + ControlMessage event = reader.read(); Assert.assertEquals(ControlMessage.TYPE_SET_CLIPBOARD, event.getType()); Assert.assertEquals(0x0102030405060708L, event.getSequence()); Assert.assertEquals("testé", event.getText()); Assert.assertTrue(event.getPaste()); + + Assert.assertEquals(-1, bis.read()); // EOS } @Test public void testParseBigSetClipboardEvent() throws IOException { - ControlMessageReader reader = new ControlMessageReader(); - ByteArrayOutputStream bos = new ByteArrayOutputStream(); DataOutputStream dos = new DataOutputStream(bos); dos.writeByte(ControlMessage.TYPE_SET_CLIPBOARD); @@ -276,56 +270,160 @@ public class ControlMessageReaderTest { byte[] packet = bos.toByteArray(); - reader.readFrom(new ByteArrayInputStream(packet)); - ControlMessage event = reader.next(); + ByteArrayInputStream bis = new ByteArrayInputStream(packet); + ControlMessageReader reader = new ControlMessageReader(bis); + ControlMessage event = reader.read(); Assert.assertEquals(ControlMessage.TYPE_SET_CLIPBOARD, event.getType()); Assert.assertEquals(0x0807060504030201L, event.getSequence()); Assert.assertEquals(text, event.getText()); Assert.assertTrue(event.getPaste()); + + Assert.assertEquals(-1, bis.read()); // EOS } @Test - public void testParseSetScreenPowerMode() throws IOException { - ControlMessageReader reader = new ControlMessageReader(); - + 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(); - // The message type (1 byte) does not count - Assert.assertEquals(ControlMessageReader.SET_SCREEN_POWER_MODE_PAYLOAD_LENGTH, packet.length - 1); + ByteArrayInputStream bis = new ByteArrayInputStream(packet); + ControlMessageReader reader = new ControlMessageReader(bis); - reader.readFrom(new ByteArrayInputStream(packet)); - ControlMessage event = reader.next(); + ControlMessage event = reader.read(); + Assert.assertEquals(ControlMessage.TYPE_SET_DISPLAY_POWER, event.getType()); + Assert.assertTrue(event.getOn()); - Assert.assertEquals(ControlMessage.TYPE_SET_SCREEN_POWER_MODE, event.getType()); - Assert.assertEquals(Device.POWER_MODE_NORMAL, event.getAction()); + Assert.assertEquals(-1, bis.read()); // EOS } @Test public void testParseRotateDevice() throws IOException { - ControlMessageReader reader = new ControlMessageReader(); - ByteArrayOutputStream bos = new ByteArrayOutputStream(); DataOutputStream dos = new DataOutputStream(bos); dos.writeByte(ControlMessage.TYPE_ROTATE_DEVICE); - byte[] packet = bos.toByteArray(); - reader.readFrom(new ByteArrayInputStream(packet)); - ControlMessage event = reader.next(); + ByteArrayInputStream bis = new ByteArrayInputStream(packet); + ControlMessageReader reader = new ControlMessageReader(bis); + ControlMessage event = reader.read(); Assert.assertEquals(ControlMessage.TYPE_ROTATE_DEVICE, event.getType()); + + Assert.assertEquals(-1, bis.read()); // EOS + } + + @Test + public void testParseUhidCreate() throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + DataOutputStream dos = new DataOutputStream(bos); + dos.writeByte(ControlMessage.TYPE_UHID_CREATE); + dos.writeShort(42); // id + dos.writeShort(0x1234); // vendorId + dos.writeShort(0x5678); // productId + dos.writeByte(3); // name size + dos.write("ABC".getBytes(StandardCharsets.US_ASCII)); + byte[] data = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}; + dos.writeShort(data.length); // report desc size + dos.write(data); + byte[] packet = bos.toByteArray(); + + ByteArrayInputStream bis = new ByteArrayInputStream(packet); + ControlMessageReader reader = new ControlMessageReader(bis); + + ControlMessage event = reader.read(); + Assert.assertEquals(ControlMessage.TYPE_UHID_CREATE, event.getType()); + Assert.assertEquals(42, event.getId()); + Assert.assertEquals(0x1234, event.getVendorId()); + Assert.assertEquals(0x5678, event.getProductId()); + Assert.assertEquals("ABC", event.getText()); + Assert.assertArrayEquals(data, event.getData()); + + Assert.assertEquals(-1, bis.read()); // EOS + } + + @Test + public void testParseUhidInput() throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + DataOutputStream dos = new DataOutputStream(bos); + dos.writeByte(ControlMessage.TYPE_UHID_INPUT); + dos.writeShort(42); // id + byte[] data = {1, 2, 3, 4, 5}; + dos.writeShort(data.length); // size + dos.write(data); + byte[] packet = bos.toByteArray(); + + ByteArrayInputStream bis = new ByteArrayInputStream(packet); + ControlMessageReader reader = new ControlMessageReader(bis); + + ControlMessage event = reader.read(); + Assert.assertEquals(ControlMessage.TYPE_UHID_INPUT, event.getType()); + Assert.assertEquals(42, event.getId()); + Assert.assertArrayEquals(data, event.getData()); + + Assert.assertEquals(-1, bis.read()); // EOS + } + + @Test + public void testParseUhidDestroy() throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + DataOutputStream dos = new DataOutputStream(bos); + dos.writeByte(ControlMessage.TYPE_UHID_DESTROY); + dos.writeShort(42); // id + byte[] packet = bos.toByteArray(); + + ByteArrayInputStream bis = new ByteArrayInputStream(packet); + ControlMessageReader reader = new ControlMessageReader(bis); + + ControlMessage event = reader.read(); + Assert.assertEquals(ControlMessage.TYPE_UHID_DESTROY, event.getType()); + Assert.assertEquals(42, event.getId()); + + Assert.assertEquals(-1, bis.read()); // EOS + } + + @Test + public void testParseOpenHardKeyboardSettings() throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + DataOutputStream dos = new DataOutputStream(bos); + dos.writeByte(ControlMessage.TYPE_OPEN_HARD_KEYBOARD_SETTINGS); + byte[] packet = bos.toByteArray(); + + ByteArrayInputStream bis = new ByteArrayInputStream(packet); + ControlMessageReader reader = new ControlMessageReader(bis); + + ControlMessage event = reader.read(); + Assert.assertEquals(ControlMessage.TYPE_OPEN_HARD_KEYBOARD_SETTINGS, event.getType()); + + 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 { - ControlMessageReader reader = new ControlMessageReader(); - ByteArrayOutputStream bos = new ByteArrayOutputStream(); DataOutputStream dos = new DataOutputStream(bos); @@ -342,27 +440,29 @@ public class ControlMessageReaderTest { dos.writeInt(KeyEvent.META_CTRL_ON); byte[] packet = bos.toByteArray(); - reader.readFrom(new ByteArrayInputStream(packet)); - ControlMessage event = reader.next(); + ByteArrayInputStream bis = new ByteArrayInputStream(packet); + ControlMessageReader reader = new ControlMessageReader(bis); + + ControlMessage event = reader.read(); Assert.assertEquals(ControlMessage.TYPE_INJECT_KEYCODE, event.getType()); Assert.assertEquals(KeyEvent.ACTION_UP, event.getAction()); Assert.assertEquals(KeyEvent.KEYCODE_ENTER, event.getKeycode()); Assert.assertEquals(0, event.getRepeat()); Assert.assertEquals(KeyEvent.META_CTRL_ON, event.getMetaState()); - event = reader.next(); + event = reader.read(); Assert.assertEquals(ControlMessage.TYPE_INJECT_KEYCODE, event.getType()); Assert.assertEquals(MotionEvent.ACTION_DOWN, event.getAction()); Assert.assertEquals(MotionEvent.BUTTON_PRIMARY, event.getKeycode()); Assert.assertEquals(1, event.getRepeat()); Assert.assertEquals(KeyEvent.META_CTRL_ON, event.getMetaState()); + + Assert.assertEquals(-1, bis.read()); // EOS } @Test public void testPartialEvents() throws IOException { - ControlMessageReader reader = new ControlMessageReader(); - ByteArrayOutputStream bos = new ByteArrayOutputStream(); DataOutputStream dos = new DataOutputStream(bos); @@ -376,31 +476,21 @@ public class ControlMessageReaderTest { dos.writeByte(MotionEvent.ACTION_DOWN); byte[] packet = bos.toByteArray(); - reader.readFrom(new ByteArrayInputStream(packet)); + ByteArrayInputStream bis = new ByteArrayInputStream(packet); + ControlMessageReader reader = new ControlMessageReader(bis); - ControlMessage event = reader.next(); + ControlMessage event = reader.read(); Assert.assertEquals(ControlMessage.TYPE_INJECT_KEYCODE, event.getType()); Assert.assertEquals(KeyEvent.ACTION_UP, event.getAction()); Assert.assertEquals(KeyEvent.KEYCODE_ENTER, event.getKeycode()); Assert.assertEquals(4, event.getRepeat()); Assert.assertEquals(KeyEvent.META_CTRL_ON, event.getMetaState()); - event = reader.next(); - Assert.assertNull(event); // the event is not complete - - bos.reset(); - dos.writeInt(MotionEvent.BUTTON_PRIMARY); - dos.writeInt(5); // repeat - dos.writeInt(KeyEvent.META_CTRL_ON); - packet = bos.toByteArray(); - reader.readFrom(new ByteArrayInputStream(packet)); - - // the event is now complete - event = reader.next(); - Assert.assertEquals(ControlMessage.TYPE_INJECT_KEYCODE, event.getType()); - Assert.assertEquals(MotionEvent.ACTION_DOWN, event.getAction()); - Assert.assertEquals(MotionEvent.BUTTON_PRIMARY, event.getKeycode()); - Assert.assertEquals(5, event.getRepeat()); - Assert.assertEquals(KeyEvent.META_CTRL_ON, event.getMetaState()); + try { + event = reader.read(); + Assert.fail("Reader did not reach EOF"); + } catch (EOFException e) { + // expected + } } } diff --git a/server/src/test/java/com/genymobile/scrcpy/DeviceMessageWriterTest.java b/server/src/test/java/com/genymobile/scrcpy/control/DeviceMessageWriterTest.java similarity index 60% rename from server/src/test/java/com/genymobile/scrcpy/DeviceMessageWriterTest.java rename to server/src/test/java/com/genymobile/scrcpy/control/DeviceMessageWriterTest.java index 7b917d33..4e4717fd 100644 --- a/server/src/test/java/com/genymobile/scrcpy/DeviceMessageWriterTest.java +++ b/server/src/test/java/com/genymobile/scrcpy/control/DeviceMessageWriterTest.java @@ -1,4 +1,4 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.control; import org.junit.Assert; import org.junit.Test; @@ -12,8 +12,6 @@ public class DeviceMessageWriterTest { @Test public void testSerializeClipboard() throws IOException { - DeviceMessageWriter writer = new DeviceMessageWriter(); - String text = "aéûoç"; byte[] data = text.getBytes(StandardCharsets.UTF_8); ByteArrayOutputStream bos = new ByteArrayOutputStream(); @@ -21,12 +19,13 @@ public class DeviceMessageWriterTest { dos.writeByte(DeviceMessage.TYPE_CLIPBOARD); dos.writeInt(data.length); dos.write(data); - byte[] expected = bos.toByteArray(); - DeviceMessage msg = DeviceMessage.createClipboard(text); bos = new ByteArrayOutputStream(); - writer.writeTo(msg, bos); + DeviceMessageWriter writer = new DeviceMessageWriter(bos); + + DeviceMessage msg = DeviceMessage.createClipboard(text); + writer.write(msg); byte[] actual = bos.toByteArray(); @@ -35,18 +34,39 @@ public class DeviceMessageWriterTest { @Test public void testSerializeAckSetClipboard() throws IOException { - DeviceMessageWriter writer = new DeviceMessageWriter(); - ByteArrayOutputStream bos = new ByteArrayOutputStream(); DataOutputStream dos = new DataOutputStream(bos); dos.writeByte(DeviceMessage.TYPE_ACK_CLIPBOARD); dos.writeLong(0x0102030405060708L); - byte[] expected = bos.toByteArray(); - DeviceMessage msg = DeviceMessage.createAckClipboard(0x0102030405060708L); bos = new ByteArrayOutputStream(); - writer.writeTo(msg, bos); + DeviceMessageWriter writer = new DeviceMessageWriter(bos); + + DeviceMessage msg = DeviceMessage.createAckClipboard(0x0102030405060708L); + writer.write(msg); + + byte[] actual = bos.toByteArray(); + + Assert.assertArrayEquals(expected, actual); + } + + @Test + public void testSerializeUhidOutput() throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + DataOutputStream dos = new DataOutputStream(bos); + dos.writeByte(DeviceMessage.TYPE_UHID_OUTPUT); + dos.writeShort(42); // id + byte[] data = {1, 2, 3, 4, 5}; + dos.writeShort(data.length); + dos.write(data); + byte[] expected = bos.toByteArray(); + + bos = new ByteArrayOutputStream(); + DeviceMessageWriter writer = new DeviceMessageWriter(bos); + + DeviceMessage msg = DeviceMessage.createUhidOutput(42, data); + writer.write(msg); byte[] actual = bos.toByteArray(); diff --git a/server/src/test/java/com/genymobile/scrcpy/BinaryTest.java b/server/src/test/java/com/genymobile/scrcpy/util/BinaryTest.java similarity index 98% rename from server/src/test/java/com/genymobile/scrcpy/BinaryTest.java rename to server/src/test/java/com/genymobile/scrcpy/util/BinaryTest.java index 569a2f2c..7ee95ac5 100644 --- a/server/src/test/java/com/genymobile/scrcpy/BinaryTest.java +++ b/server/src/test/java/com/genymobile/scrcpy/util/BinaryTest.java @@ -1,4 +1,4 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.util; import org.junit.Assert; import org.junit.Test; diff --git a/server/src/test/java/com/genymobile/scrcpy/CodecOptionsTest.java b/server/src/test/java/com/genymobile/scrcpy/util/CodecOptionsTest.java similarity index 99% rename from server/src/test/java/com/genymobile/scrcpy/CodecOptionsTest.java rename to server/src/test/java/com/genymobile/scrcpy/util/CodecOptionsTest.java index ad802258..ffd8e32e 100644 --- a/server/src/test/java/com/genymobile/scrcpy/CodecOptionsTest.java +++ b/server/src/test/java/com/genymobile/scrcpy/util/CodecOptionsTest.java @@ -1,4 +1,4 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.util; import org.junit.Assert; import org.junit.Test; diff --git a/server/src/test/java/com/genymobile/scrcpy/CommandParserTest.java b/server/src/test/java/com/genymobile/scrcpy/util/CommandParserTest.java similarity index 99% rename from server/src/test/java/com/genymobile/scrcpy/CommandParserTest.java rename to server/src/test/java/com/genymobile/scrcpy/util/CommandParserTest.java index de996a07..7e1d55b5 100644 --- a/server/src/test/java/com/genymobile/scrcpy/CommandParserTest.java +++ b/server/src/test/java/com/genymobile/scrcpy/util/CommandParserTest.java @@ -1,5 +1,6 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.util; +import com.genymobile.scrcpy.device.DisplayInfo; import com.genymobile.scrcpy.wrappers.DisplayManager; import android.view.Display; diff --git a/server/src/test/java/com/genymobile/scrcpy/StringUtilsTest.java b/server/src/test/java/com/genymobile/scrcpy/util/StringUtilsTest.java similarity index 97% rename from server/src/test/java/com/genymobile/scrcpy/StringUtilsTest.java rename to server/src/test/java/com/genymobile/scrcpy/util/StringUtilsTest.java index 89799c5e..c72b112a 100644 --- a/server/src/test/java/com/genymobile/scrcpy/StringUtilsTest.java +++ b/server/src/test/java/com/genymobile/scrcpy/util/StringUtilsTest.java @@ -1,4 +1,4 @@ -package com.genymobile.scrcpy; +package com.genymobile.scrcpy.util; import org.junit.Assert; import org.junit.Test;