#include "util/str_util.h"
@@ -136,7 +137,7 @@ static void test_strquote(void) {
// add '"' at the beginning and the end
assert(!strcmp("\"abcde\"", out));
- SDL_free(out);
+ free(out);
}
static void test_utf8_truncate(void) {
@@ -187,6 +188,55 @@ static void test_parse_integer(void) {
assert(!ok); // out-of-range
}
+static void test_parse_integers(void) {
+ long values[5];
+
+ size_t count = parse_integers("1234", ':', 5, values);
+ assert(count == 1);
+ assert(values[0] == 1234);
+
+ count = parse_integers("1234:5678", ':', 5, values);
+ assert(count == 2);
+ assert(values[0] == 1234);
+ assert(values[1] == 5678);
+
+ count = parse_integers("1234:5678", ':', 2, values);
+ assert(count == 2);
+ assert(values[0] == 1234);
+ assert(values[1] == 5678);
+
+ count = parse_integers("1234:-5678", ':', 2, values);
+ assert(count == 2);
+ assert(values[0] == 1234);
+ assert(values[1] == -5678);
+
+ count = parse_integers("1:2:3:4:5", ':', 5, values);
+ assert(count == 5);
+ assert(values[0] == 1);
+ assert(values[1] == 2);
+ assert(values[2] == 3);
+ assert(values[3] == 4);
+ assert(values[4] == 5);
+
+ count = parse_integers("1234:5678", ':', 1, values);
+ assert(count == 0); // max_items == 1
+
+ count = parse_integers("1:2:3:4:5", ':', 3, values);
+ assert(count == 0); // max_items == 3
+
+ count = parse_integers(":1234", ':', 5, values);
+ assert(count == 0); // invalid
+
+ count = parse_integers("1234:", ':', 5, values);
+ assert(count == 0); // invalid
+
+ count = parse_integers("1234:", ':', 1, values);
+ assert(count == 0); // invalid, even when max_items == 1
+
+ count = parse_integers("1234::5678", ':', 5, values);
+ assert(count == 0); // invalid
+}
+
static void test_parse_integer_with_suffix(void) {
long value;
bool ok = parse_integer_with_suffix("1234", &value);
@@ -237,7 +287,22 @@ static void test_parse_integer_with_suffix(void) {
assert(!ok);
}
-int main(void) {
+static void test_strlist_contains(void) {
+ assert(strlist_contains("a,bc,def", ',', "bc"));
+ assert(!strlist_contains("a,bc,def", ',', "b"));
+ assert(strlist_contains("", ',', ""));
+ assert(strlist_contains("abc,", ',', ""));
+ assert(strlist_contains(",abc", ',', ""));
+ assert(strlist_contains("abc,,def", ',', ""));
+ assert(!strlist_contains("abc", ',', ""));
+ assert(strlist_contains(",,|x", '|', ",,"));
+ assert(strlist_contains("xyz", '\0', "xyz"));
+}
+
+int main(int argc, char *argv[]) {
+ (void) argc;
+ (void) argv;
+
test_xstrncpy_simple();
test_xstrncpy_just_fit();
test_xstrncpy_truncated();
@@ -249,6 +314,8 @@ int main(void) {
test_strquote();
test_utf8_truncate();
test_parse_integer();
+ test_parse_integers();
test_parse_integer_with_suffix();
+ test_strlist_contains();
return 0;
}
diff --git a/build.gradle b/build.gradle
index b6ec625d..c977c398 100644
--- a/build.gradle
+++ b/build.gradle
@@ -7,7 +7,7 @@ buildscript {
jcenter()
}
dependencies {
- classpath 'com.android.tools.build:gradle:3.4.2'
+ classpath 'com.android.tools.build:gradle:4.0.1'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
@@ -19,6 +19,9 @@ allprojects {
google()
jcenter()
}
+ tasks.withType(JavaCompile) {
+ options.compilerArgs << "-Xlint:deprecation"
+ }
}
task clean(type: Delete) {
diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml
index 798814d9..812d060b 100644
--- a/config/checkstyle/checkstyle.xml
+++ b/config/checkstyle/checkstyle.xml
@@ -129,11 +129,6 @@ page at http://checkstyle.sourceforge.net/config.html -->
-
-
-
-
-
diff --git a/cross_win32.txt b/cross_win32.txt
index d13af0e2..0d8a843a 100644
--- a/cross_win32.txt
+++ b/cross_win32.txt
@@ -2,11 +2,11 @@
[binaries]
name = 'mingw'
-c = '/usr/bin/i686-w64-mingw32-gcc'
-cpp = '/usr/bin/i686-w64-mingw32-g++'
-ar = '/usr/bin/i686-w64-mingw32-ar'
-strip = '/usr/bin/i686-w64-mingw32-strip'
-pkgconfig = '/usr/bin/i686-w64-mingw32-pkg-config'
+c = 'i686-w64-mingw32-gcc'
+cpp = 'i686-w64-mingw32-g++'
+ar = 'i686-w64-mingw32-ar'
+strip = 'i686-w64-mingw32-strip'
+pkgconfig = 'i686-w64-mingw32-pkg-config'
[host_machine]
system = 'windows'
@@ -15,6 +15,6 @@ cpu = 'i686'
endian = 'little'
[properties]
-prebuilt_ffmpeg_shared = 'ffmpeg-4.2.1-win32-shared'
-prebuilt_ffmpeg_dev = 'ffmpeg-4.2.1-win32-dev'
-prebuilt_sdl2 = 'SDL2-2.0.10/i686-w64-mingw32'
+prebuilt_ffmpeg_shared = 'ffmpeg-4.3.1-win32-shared'
+prebuilt_ffmpeg_dev = 'ffmpeg-4.3.1-win32-dev'
+prebuilt_sdl2 = 'SDL2-2.0.14/i686-w64-mingw32'
diff --git a/cross_win64.txt b/cross_win64.txt
index 09f387e1..6a39c391 100644
--- a/cross_win64.txt
+++ b/cross_win64.txt
@@ -2,11 +2,11 @@
[binaries]
name = 'mingw'
-c = '/usr/bin/x86_64-w64-mingw32-gcc'
-cpp = '/usr/bin/x86_64-w64-mingw32-g++'
-ar = '/usr/bin/x86_64-w64-mingw32-ar'
-strip = '/usr/bin/x86_64-w64-mingw32-strip'
-pkgconfig = '/usr/bin/x86_64-w64-mingw32-pkg-config'
+c = 'x86_64-w64-mingw32-gcc'
+cpp = 'x86_64-w64-mingw32-g++'
+ar = 'x86_64-w64-mingw32-ar'
+strip = 'x86_64-w64-mingw32-strip'
+pkgconfig = 'x86_64-w64-mingw32-pkg-config'
[host_machine]
system = 'windows'
@@ -15,6 +15,6 @@ cpu = 'x86_64'
endian = 'little'
[properties]
-prebuilt_ffmpeg_shared = 'ffmpeg-4.2.1-win64-shared'
-prebuilt_ffmpeg_dev = 'ffmpeg-4.2.1-win64-dev'
-prebuilt_sdl2 = 'SDL2-2.0.10/x86_64-w64-mingw32'
+prebuilt_ffmpeg_shared = 'ffmpeg-4.3.1-win64-shared'
+prebuilt_ffmpeg_dev = 'ffmpeg-4.3.1-win64-dev'
+prebuilt_sdl2 = 'SDL2-2.0.14/x86_64-w64-mingw32'
diff --git a/data/scrcpy-console.bat b/data/scrcpy-console.bat
new file mode 100644
index 00000000..b90be29a
--- /dev/null
+++ b/data/scrcpy-console.bat
@@ -0,0 +1,4 @@
+@echo off
+scrcpy.exe %*
+:: if the exit code is >= 1, then pause
+if errorlevel 1 pause
diff --git a/data/scrcpy-noconsole.vbs b/data/scrcpy-noconsole.vbs
new file mode 100644
index 00000000..d509ad7f
--- /dev/null
+++ b/data/scrcpy-noconsole.vbs
@@ -0,0 +1,7 @@
+strCommand = "cmd /c scrcpy.exe"
+
+For Each Arg In WScript.Arguments
+ strCommand = strCommand & " """ & replace(Arg, """", """""""""") & """"
+Next
+
+CreateObject("Wscript.Shell").Run strCommand, 0, false
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
index 5c2d1cf0..490fda85 100644
Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 430dfabc..a4b44297 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-5.5.1-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-6.3-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
index 8e25e6c1..2fe81a7d 100755
--- a/gradlew
+++ b/gradlew
@@ -125,8 +125,8 @@ if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
-# For Cygwin, switch paths to Windows format before running java
-if $cygwin ; then
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
@@ -154,19 +154,19 @@ if $cygwin ; then
else
eval `echo args$i`="\"$arg\""
fi
- i=$((i+1))
+ i=`expr $i + 1`
done
case $i in
- (0) set -- ;;
- (1) set -- "$args0" ;;
- (2) set -- "$args0" "$args1" ;;
- (3) set -- "$args0" "$args1" "$args2" ;;
- (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
- (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
- (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
- (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
- (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
- (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
@@ -175,14 +175,9 @@ save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
-APP_ARGS=$(save "$@")
+APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
-# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
-if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
- cd "$(dirname "$0")"
-fi
-
exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
index 24467a14..9109989e 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -29,6 +29,9 @@ if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
diff --git a/install_release.sh b/install_release.sh
new file mode 100755
index 00000000..9158bdd4
--- /dev/null
+++ b/install_release.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+set -e
+
+BUILDDIR=build-auto
+PREBUILT_SERVER_URL=https://github.com/Genymobile/scrcpy/releases/download/v1.18/scrcpy-server-v1.18
+PREBUILT_SERVER_SHA256=641c5c6beda9399dfae72d116f5ff43b5ed1059d871c9ebc3f47610fd33c51a3
+
+echo "[scrcpy] Downloading prebuilt server..."
+wget "$PREBUILT_SERVER_URL" -O scrcpy-server
+echo "[scrcpy] Verifying prebuilt server..."
+echo "$PREBUILT_SERVER_SHA256 scrcpy-server" | sha256sum --check
+
+echo "[scrcpy] Building client..."
+rm -rf "$BUILDDIR"
+meson "$BUILDDIR" --buildtype release --strip -Db_lto=true \
+ -Dprebuilt_server=scrcpy-server
+cd "$BUILDDIR"
+ninja
+
+echo "[scrcpy] Installing (sudo)..."
+sudo ninja install
diff --git a/meson.build b/meson.build
index 412c9c51..2d76f1e9 100644
--- a/meson.build
+++ b/meson.build
@@ -1,9 +1,10 @@
project('scrcpy', 'c',
- version: '1.12.1',
- meson_version: '>= 0.37',
+ version: '1.18',
+ meson_version: '>= 0.48',
default_options: [
'c_std=c11',
'warning_level=2',
+ 'b_ndebug=if-release',
])
if get_option('compile_app')
diff --git a/meson_options.txt b/meson_options.txt
index 4cf4a8bf..66ad5b25 100644
--- a/meson_options.txt
+++ b/meson_options.txt
@@ -1,8 +1,7 @@
option('compile_app', type: 'boolean', value: true, description: 'Build the client')
option('compile_server', type: 'boolean', value: true, description: 'Build the server')
option('crossbuild_windows', type: 'boolean', value: false, description: 'Build for Windows from Linux')
-option('windows_noconsole', type: 'boolean', value: false, description: 'Disable console on Windows (pass -mwindows flag)')
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('hidpi_support', type: 'boolean', value: true, description: 'Enable High DPI support')
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")')
diff --git a/prebuilt-deps/Makefile b/prebuilt-deps/Makefile
index 892af6c7..d75d0a5c 100644
--- a/prebuilt-deps/Makefile
+++ b/prebuilt-deps/Makefile
@@ -10,31 +10,31 @@ prepare-win32: prepare-sdl2 prepare-ffmpeg-shared-win32 prepare-ffmpeg-dev-win32
prepare-win64: prepare-sdl2 prepare-ffmpeg-shared-win64 prepare-ffmpeg-dev-win64 prepare-adb
prepare-ffmpeg-shared-win32:
- @./prepare-dep https://ffmpeg.zeranoe.com/builds/win32/shared/ffmpeg-4.2.1-win32-shared.zip \
- 9208255f409410d95147151d7e829b5699bf8d91bfe1e81c3f470f47c2fa66d2 \
- ffmpeg-4.2.1-win32-shared
+ @./prepare-dep https://github.com/Genymobile/scrcpy/releases/download/v1.16/ffmpeg-4.3.1-win32-shared.zip \
+ 357af9901a456f4dcbacd107e83a934d344c9cb07ddad8aaf80612eeab7d26d2 \
+ ffmpeg-4.3.1-win32-shared
prepare-ffmpeg-dev-win32:
- @./prepare-dep https://ffmpeg.zeranoe.com/builds/win32/dev/ffmpeg-4.2.1-win32-dev.zip \
- c3469e6c5f031cbcc8cba88dee92d6548c5c6b6ff14f4097f18f72a92d0d70c4 \
- ffmpeg-4.2.1-win32-dev
+ @./prepare-dep https://github.com/Genymobile/scrcpy/releases/download/v1.16/ffmpeg-4.3.1-win32-dev.zip \
+ 230efb08e9bcf225bd474da29676c70e591fc94d8790a740ca801408fddcb78b \
+ ffmpeg-4.3.1-win32-dev
prepare-ffmpeg-shared-win64:
- @./prepare-dep https://ffmpeg.zeranoe.com/builds/win64/shared/ffmpeg-4.2.1-win64-shared.zip \
- 55063d3cf750a75485c7bf196031773d81a1b25d0980c7db48ecfc7701a42331 \
- ffmpeg-4.2.1-win64-shared
+ @./prepare-dep https://github.com/Genymobile/scrcpy/releases/download/v1.16/ffmpeg-4.3.1-win64-shared.zip \
+ dd29b7f92f48dead4dd940492c7509138c0f99db445076d0a597007298a79940 \
+ ffmpeg-4.3.1-win64-shared
prepare-ffmpeg-dev-win64:
- @./prepare-dep https://ffmpeg.zeranoe.com/builds/win64/dev/ffmpeg-4.2.1-win64-dev.zip \
- 5af393be5f25c0a71aa29efce768e477c35347f7f8e0d9696767d5b9d405b74e \
- ffmpeg-4.2.1-win64-dev
+ @./prepare-dep https://github.com/Genymobile/scrcpy/releases/download/v1.16/ffmpeg-4.3.1-win64-dev.zip \
+ 2e8038242cf8e1bd095c2978f196ff0462b122cc6ef7e74626a6af15459d8b81 \
+ ffmpeg-4.3.1-win64-dev
prepare-sdl2:
- @./prepare-dep https://libsdl.org/release/SDL2-devel-2.0.10-mingw.tar.gz \
- a90a7cddaec4996f4d7be6d80c57ec69b062e132bffc513965f99217f603274a \
- SDL2-2.0.10
+ @./prepare-dep https://libsdl.org/release/SDL2-devel-2.0.14-mingw.tar.gz \
+ 405eaff3eb18f2e08fe669ef9e63bc9a8710b7d343756f238619761e9b60407d \
+ SDL2-2.0.14
prepare-adb:
- @./prepare-dep https://dl.google.com/android/repository/platform-tools_r29.0.5-windows.zip \
- 2df06160056ec9a84c7334af2a1e42740befbb1a2e34370e7af544a2cc78152c \
+ @./prepare-dep https://dl.google.com/android/repository/platform-tools_r31.0.2-windows.zip \
+ d560cb8ded83ae04763b94632673481f14843a5969256569623cfeac82db4ba5 \
platform-tools
diff --git a/prebuilt-deps/prepare-dep b/prebuilt-deps/prepare-dep
index 34ddcbf5..f152e6cf 100755
--- a/prebuilt-deps/prepare-dep
+++ b/prebuilt-deps/prepare-dep
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
set -e
url="$1"
sum="$2"
diff --git a/Makefile.CrossWindows b/release.mk
similarity index 56%
rename from Makefile.CrossWindows
rename to release.mk
index 2b30dcb5..2a026135 100644
--- a/Makefile.CrossWindows
+++ b/release.mk
@@ -9,21 +9,20 @@
# the server to the device.
.PHONY: default clean \
+ test \
build-server \
prepare-deps-win32 prepare-deps-win64 \
- build-win32 build-win32-noconsole \
- build-win64 build-win64-noconsole \
+ build-win32 build-win64 \
dist-win32 dist-win64 \
zip-win32 zip-win64 \
- sums release
+ release
GRADLE ?= ./gradlew
+TEST_BUILD_DIR := build-test
SERVER_BUILD_DIR := build-server
WIN32_BUILD_DIR := build-win32
-WIN32_NOCONSOLE_BUILD_DIR := build-win32-noconsole
WIN64_BUILD_DIR := build-win64
-WIN64_NOCONSOLE_BUILD_DIR := build-win64-noconsole
DIST := dist
WIN32_TARGET_DIR := scrcpy-win32
@@ -33,19 +32,35 @@ VERSION := $(shell git describe --tags --always)
WIN32_TARGET := $(WIN32_TARGET_DIR)-$(VERSION).zip
WIN64_TARGET := $(WIN64_TARGET_DIR)-$(VERSION).zip
-release: clean zip-win32 zip-win64 sums
- @echo "Windows archives generated in $(DIST)/"
+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 "$(SERVER_BUILD_DIR)" "$(WIN32_BUILD_DIR)" "$(WIN64_BUILD_DIR)" \
- "$(WIN32_NOCONSOLE_BUILD_DIR)" "$(WIN64_NOCONSOLE_BUILD_DIR)" "$(DIST)"
+ 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 "$(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 "$(SERVER_BUILD_DIR)" \
- --buildtype release -Dcompile_app=false )
- ninja -C "$(SERVER_BUILD_DIR)"
+ meson "$(SERVER_BUILD_DIR)" --buildtype release -Dcompile_app=false )
+ ninja -C "$(SERVER_BUILD_DIR)"
prepare-deps-win32:
-$(MAKE) -C prebuilt-deps prepare-win32
@@ -60,17 +75,6 @@ build-win32: prepare-deps-win32
-Dportable=true )
ninja -C "$(WIN32_BUILD_DIR)"
-build-win32-noconsole: prepare-deps-win32
- [ -d "$(WIN32_NOCONSOLE_BUILD_DIR)" ] || ( mkdir "$(WIN32_NOCONSOLE_BUILD_DIR)" && \
- meson "$(WIN32_NOCONSOLE_BUILD_DIR)" \
- --cross-file cross_win32.txt \
- --buildtype release --strip -Db_lto=true \
- -Dcrossbuild_windows=true \
- -Dcompile_server=false \
- -Dwindows_noconsole=true \
- -Dportable=true )
- ninja -C "$(WIN32_NOCONSOLE_BUILD_DIR)"
-
prepare-deps-win64:
-$(MAKE) -C prebuilt-deps prepare-win64
@@ -84,46 +88,37 @@ build-win64: prepare-deps-win64
-Dportable=true )
ninja -C "$(WIN64_BUILD_DIR)"
-build-win64-noconsole: prepare-deps-win64
- [ -d "$(WIN64_NOCONSOLE_BUILD_DIR)" ] || ( mkdir "$(WIN64_NOCONSOLE_BUILD_DIR)" && \
- meson "$(WIN64_NOCONSOLE_BUILD_DIR)" \
- --cross-file cross_win64.txt \
- --buildtype release --strip -Db_lto=true \
- -Dcrossbuild_windows=true \
- -Dcompile_server=false \
- -Dwindows_noconsole=true \
- -Dportable=true )
- ninja -C "$(WIN64_NOCONSOLE_BUILD_DIR)"
-
-dist-win32: build-server build-win32 build-win32-noconsole
+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 "$(WIN32_NOCONSOLE_BUILD_DIR)"/app/scrcpy.exe "$(DIST)/$(WIN32_TARGET_DIR)/scrcpy-noconsole.exe"
- cp prebuilt-deps/ffmpeg-4.2.1-win32-shared/bin/avutil-56.dll "$(DIST)/$(WIN32_TARGET_DIR)/"
- cp prebuilt-deps/ffmpeg-4.2.1-win32-shared/bin/avcodec-58.dll "$(DIST)/$(WIN32_TARGET_DIR)/"
- cp prebuilt-deps/ffmpeg-4.2.1-win32-shared/bin/avformat-58.dll "$(DIST)/$(WIN32_TARGET_DIR)/"
- cp prebuilt-deps/ffmpeg-4.2.1-win32-shared/bin/swresample-3.dll "$(DIST)/$(WIN32_TARGET_DIR)/"
- cp prebuilt-deps/ffmpeg-4.2.1-win32-shared/bin/swscale-5.dll "$(DIST)/$(WIN32_TARGET_DIR)/"
+ cp data/scrcpy-console.bat "$(DIST)/$(WIN32_TARGET_DIR)"
+ cp data/scrcpy-noconsole.vbs "$(DIST)/$(WIN32_TARGET_DIR)"
+ cp prebuilt-deps/ffmpeg-4.3.1-win32-shared/bin/avutil-56.dll "$(DIST)/$(WIN32_TARGET_DIR)/"
+ cp prebuilt-deps/ffmpeg-4.3.1-win32-shared/bin/avcodec-58.dll "$(DIST)/$(WIN32_TARGET_DIR)/"
+ cp prebuilt-deps/ffmpeg-4.3.1-win32-shared/bin/avformat-58.dll "$(DIST)/$(WIN32_TARGET_DIR)/"
+ cp prebuilt-deps/ffmpeg-4.3.1-win32-shared/bin/swresample-3.dll "$(DIST)/$(WIN32_TARGET_DIR)/"
+ cp prebuilt-deps/ffmpeg-4.3.1-win32-shared/bin/swscale-5.dll "$(DIST)/$(WIN32_TARGET_DIR)/"
cp prebuilt-deps/platform-tools/adb.exe "$(DIST)/$(WIN32_TARGET_DIR)/"
cp prebuilt-deps/platform-tools/AdbWinApi.dll "$(DIST)/$(WIN32_TARGET_DIR)/"
cp prebuilt-deps/platform-tools/AdbWinUsbApi.dll "$(DIST)/$(WIN32_TARGET_DIR)/"
- cp prebuilt-deps/SDL2-2.0.10/i686-w64-mingw32/bin/SDL2.dll "$(DIST)/$(WIN32_TARGET_DIR)/"
+ cp prebuilt-deps/SDL2-2.0.14/i686-w64-mingw32/bin/SDL2.dll "$(DIST)/$(WIN32_TARGET_DIR)/"
-dist-win64: build-server build-win64 build-win64-noconsole
+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 "$(WIN64_NOCONSOLE_BUILD_DIR)"/app/scrcpy.exe "$(DIST)/$(WIN64_TARGET_DIR)/scrcpy-noconsole.exe"
- cp prebuilt-deps/ffmpeg-4.2.1-win64-shared/bin/avutil-56.dll "$(DIST)/$(WIN64_TARGET_DIR)/"
- cp prebuilt-deps/ffmpeg-4.2.1-win64-shared/bin/avcodec-58.dll "$(DIST)/$(WIN64_TARGET_DIR)/"
- cp prebuilt-deps/ffmpeg-4.2.1-win64-shared/bin/avformat-58.dll "$(DIST)/$(WIN64_TARGET_DIR)/"
- cp prebuilt-deps/ffmpeg-4.2.1-win64-shared/bin/swresample-3.dll "$(DIST)/$(WIN64_TARGET_DIR)/"
- cp prebuilt-deps/ffmpeg-4.2.1-win64-shared/bin/swscale-5.dll "$(DIST)/$(WIN64_TARGET_DIR)/"
+ cp data/scrcpy-console.bat "$(DIST)/$(WIN64_TARGET_DIR)"
+ cp data/scrcpy-noconsole.vbs "$(DIST)/$(WIN64_TARGET_DIR)"
+ cp prebuilt-deps/ffmpeg-4.3.1-win64-shared/bin/avutil-56.dll "$(DIST)/$(WIN64_TARGET_DIR)/"
+ cp prebuilt-deps/ffmpeg-4.3.1-win64-shared/bin/avcodec-58.dll "$(DIST)/$(WIN64_TARGET_DIR)/"
+ cp prebuilt-deps/ffmpeg-4.3.1-win64-shared/bin/avformat-58.dll "$(DIST)/$(WIN64_TARGET_DIR)/"
+ cp prebuilt-deps/ffmpeg-4.3.1-win64-shared/bin/swresample-3.dll "$(DIST)/$(WIN64_TARGET_DIR)/"
+ cp prebuilt-deps/ffmpeg-4.3.1-win64-shared/bin/swscale-5.dll "$(DIST)/$(WIN64_TARGET_DIR)/"
cp prebuilt-deps/platform-tools/adb.exe "$(DIST)/$(WIN64_TARGET_DIR)/"
cp prebuilt-deps/platform-tools/AdbWinApi.dll "$(DIST)/$(WIN64_TARGET_DIR)/"
cp prebuilt-deps/platform-tools/AdbWinUsbApi.dll "$(DIST)/$(WIN64_TARGET_DIR)/"
- cp prebuilt-deps/SDL2-2.0.10/x86_64-w64-mingw32/bin/SDL2.dll "$(DIST)/$(WIN64_TARGET_DIR)/"
+ cp prebuilt-deps/SDL2-2.0.14/x86_64-w64-mingw32/bin/SDL2.dll "$(DIST)/$(WIN64_TARGET_DIR)/"
zip-win32: dist-win32
cd "$(DIST)/$(WIN32_TARGET_DIR)"; \
@@ -132,7 +127,3 @@ zip-win32: dist-win32
zip-win64: dist-win64
cd "$(DIST)/$(WIN64_TARGET_DIR)"; \
zip -r "../$(WIN64_TARGET)" .
-
-sums:
- cd "$(DIST)"; \
- sha256sum *.zip > SHA256SUMS.txt
diff --git a/release.sh b/release.sh
index 4c5afbf1..51ce2e38 100755
--- a/release.sh
+++ b/release.sh
@@ -1,44 +1,2 @@
#!/bin/bash
-set -e
-
-# test locally
-TESTDIR=build_test
-rm -rf "$TESTDIR"
-# run client tests with ASAN enabled
-meson "$TESTDIR" -Db_sanitize=address
-ninja -C"$TESTDIR" test
-
-# test server
-GRADLE=${GRADLE:-./gradlew}
-$GRADLE -p server check
-
-BUILDDIR=build_release
-rm -rf "$BUILDDIR"
-meson "$BUILDDIR" --buildtype release --strip -Db_lto=true
-cd "$BUILDDIR"
-ninja
-cd -
-
-# build Windows releases
-make -f Makefile.CrossWindows
-
-# the generated server must be the same everywhere
-cmp "$BUILDDIR/server/scrcpy-server" dist/scrcpy-win32/scrcpy-server
-cmp "$BUILDDIR/server/scrcpy-server" dist/scrcpy-win64/scrcpy-server
-
-# get version name
-TAG=$(git describe --tags --always)
-
-# create release directory
-mkdir -p "release-$TAG"
-cp "$BUILDDIR/server/scrcpy-server" "release-$TAG/scrcpy-server-$TAG"
-cp "dist/scrcpy-win32-$TAG.zip" "release-$TAG/"
-cp "dist/scrcpy-win64-$TAG.zip" "release-$TAG/"
-
-# generate checksums
-cd "release-$TAG"
-sha256sum "scrcpy-server-$TAG" \
- "scrcpy-win32-$TAG.zip" \
- "scrcpy-win64-$TAG.zip" > SHA256SUMS.txt
-
-echo "Release generated in release-$TAG/"
+make -f release.mk
diff --git a/run b/run
index bfb499ae..628c5c7e 100755
--- a/run
+++ b/run
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
# Run scrcpy generated in the specified BUILDDIR.
#
# This provides the same feature as "ninja run", except that it is possible to
diff --git a/scripts/run-scrcpy.sh b/scripts/run-scrcpy.sh
index f3130ee9..e93b639f 100755
--- a/scripts/run-scrcpy.sh
+++ b/scripts/run-scrcpy.sh
@@ -1,2 +1,2 @@
-#!/bin/bash
+#!/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 539a97b8..f088ba9d 100644
--- a/server/build.gradle
+++ b/server/build.gradle
@@ -1,13 +1,13 @@
apply plugin: 'com.android.application'
android {
- compileSdkVersion 29
+ compileSdkVersion 30
defaultConfig {
applicationId "com.genymobile.scrcpy"
minSdkVersion 21
- targetSdkVersion 29
- versionCode 14
- versionName "1.12.1"
+ targetSdkVersion 30
+ versionCode 11800
+ versionName "1.18"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
buildTypes {
@@ -20,7 +20,7 @@ android {
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
- testImplementation 'junit:junit:4.12'
+ testImplementation 'junit:junit:4.13'
}
apply from: "$project.rootDir/config/android-checkstyle.gradle"
diff --git a/server/build_without_gradle.sh b/server/build_without_gradle.sh
index c117d89c..302d3aaa 100755
--- a/server/build_without_gradle.sh
+++ b/server/build_without_gradle.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
#
# This script generates the scrcpy binary "manually" (without gradle).
#
@@ -12,10 +12,10 @@
set -e
SCRCPY_DEBUG=false
-SCRCPY_VERSION_NAME=1.12.1
+SCRCPY_VERSION_NAME=1.18
-PLATFORM=${ANDROID_PLATFORM:-29}
-BUILD_TOOLS=${ANDROID_BUILD_TOOLS:-29.0.2}
+PLATFORM=${ANDROID_PLATFORM:-30}
+BUILD_TOOLS=${ANDROID_BUILD_TOOLS:-30.0.0}
BUILD_DIR="$(realpath ${BUILD_DIR:-build_manual})"
CLASSES_DIR="$BUILD_DIR/classes"
@@ -42,6 +42,8 @@ echo "Generating java from aidl..."
cd "$SERVER_DIR/src/main/aidl"
"$ANDROID_HOME/build-tools/$BUILD_TOOLS/aidl" -o"$CLASSES_DIR" \
android/view/IRotationWatcher.aidl
+"$ANDROID_HOME/build-tools/$BUILD_TOOLS/aidl" -o"$CLASSES_DIR" \
+ android/content/IOnPrimaryClipChangedListener.aidl
echo "Compiling java sources..."
cd ../java
@@ -55,6 +57,7 @@ cd "$CLASSES_DIR"
"$ANDROID_HOME/build-tools/$BUILD_TOOLS/dx" --dex \
--output "$BUILD_DIR/classes.dex" \
android/view/*.class \
+ android/content/*.class \
com/genymobile/scrcpy/*.class \
com/genymobile/scrcpy/wrappers/*.class
diff --git a/server/meson.build b/server/meson.build
index 4ba481d5..984daf3b 100644
--- a/server/meson.build
+++ b/server/meson.build
@@ -3,7 +3,9 @@
prebuilt_server = get_option('prebuilt_server')
if prebuilt_server == ''
custom_target('scrcpy-server',
- build_always: true, # gradle is responsible for tracking source changes
+ # gradle is responsible for tracking source changes
+ build_by_default: true,
+ build_always_stale: true,
output: 'scrcpy-server',
command: [find_program('./scripts/build-wrapper.sh'), meson.current_source_dir(), '@OUTPUT@', get_option('buildtype')],
console: true,
diff --git a/server/scripts/build-wrapper.sh b/server/scripts/build-wrapper.sh
index f55e1ea4..7e16dc94 100755
--- a/server/scripts/build-wrapper.sh
+++ b/server/scripts/build-wrapper.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
# Wrapper script to invoke gradle from meson
set -e
diff --git a/server/src/main/aidl/android/content/IOnPrimaryClipChangedListener.aidl b/server/src/main/aidl/android/content/IOnPrimaryClipChangedListener.aidl
new file mode 100644
index 00000000..46d7f7ca
--- /dev/null
+++ b/server/src/main/aidl/android/content/IOnPrimaryClipChangedListener.aidl
@@ -0,0 +1,24 @@
+/**
+ * Copyright (c) 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.content;
+
+/**
+ * {@hide}
+ */
+oneway interface IOnPrimaryClipChangedListener {
+ void dispatchPrimaryClipChanged();
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/CleanUp.java b/server/src/main/java/com/genymobile/scrcpy/CleanUp.java
new file mode 100644
index 00000000..ec61a1c0
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/CleanUp.java
@@ -0,0 +1,191 @@
+package com.genymobile.scrcpy;
+
+import com.genymobile.scrcpy.wrappers.ContentProvider;
+import com.genymobile.scrcpy.wrappers.ServiceManager;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.util.Base64;
+
+import java.io.File;
+import java.io.IOException;
+
+/**
+ * Handle the cleanup of scrcpy, even if the main process is killed.
+ *
+ * This is useful to restore some state when scrcpy is closed, even on device disconnection (which kills the scrcpy process).
+ */
+public final class CleanUp {
+
+ public static final String SERVER_PATH = "/data/local/tmp/scrcpy-server.jar";
+
+ // A simple struct to be passed from the main process to the cleanup process
+ public static class Config implements Parcelable {
+
+ public static final Creator CREATOR = new Creator() {
+ @Override
+ public Config createFromParcel(Parcel in) {
+ return new Config(in);
+ }
+
+ @Override
+ public Config[] newArray(int size) {
+ return new Config[size];
+ }
+ };
+
+ private static final int FLAG_DISABLE_SHOW_TOUCHES = 1;
+ private static final int FLAG_RESTORE_NORMAL_POWER_MODE = 2;
+ private static final int FLAG_POWER_OFF_SCREEN = 4;
+
+ private 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_PATH);
+ builder.start();
+ }
+
+ private static void unlinkSelf() {
+ try {
+ new File(SERVER_PATH).delete();
+ } catch (Exception e) {
+ Ln.e("Could not unlink server", e);
+ }
+ }
+
+ public static void main(String... args) {
+ unlinkSelf();
+
+ try {
+ // Wait for the server to die
+ System.in.read();
+ } 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) {
+ ServiceManager serviceManager = new ServiceManager();
+ try (ContentProvider settings = serviceManager.getActivityManager().createSettingsProvider()) {
+ if (config.disableShowTouches) {
+ Ln.i("Disabling \"show touches\"");
+ settings.putValue(ContentProvider.TABLE_SYSTEM, "show_touches", "0");
+ }
+ if (config.restoreStayOn != -1) {
+ Ln.i("Restoring \"stay awake\"");
+ settings.putValue(ContentProvider.TABLE_GLOBAL, "stay_on_while_plugged_in", String.valueOf(config.restoreStayOn));
+ }
+ }
+ }
+
+ if (Device.isScreenOn()) {
+ if (config.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);
+ }
+ }
+ }
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/CodecOption.java b/server/src/main/java/com/genymobile/scrcpy/CodecOption.java
new file mode 100644
index 00000000..1897bda3
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/CodecOption.java
@@ -0,0 +1,112 @@
+package com.genymobile.scrcpy;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class CodecOption {
+ private String key;
+ private Object value;
+
+ public CodecOption(String key, Object value) {
+ this.key = key;
+ this.value = value;
+ }
+
+ public String getKey() {
+ return key;
+ }
+
+ public Object getValue() {
+ return value;
+ }
+
+ public static List parse(String codecOptions) {
+ if ("-".equals(codecOptions)) {
+ return null;
+ }
+
+ List result = new ArrayList<>();
+
+ boolean escape = false;
+ StringBuilder buf = new StringBuilder();
+
+ for (char c : codecOptions.toCharArray()) {
+ switch (c) {
+ case '\\':
+ if (escape) {
+ buf.append('\\');
+ escape = false;
+ } else {
+ escape = true;
+ }
+ break;
+ case ',':
+ if (escape) {
+ buf.append(',');
+ escape = false;
+ } else {
+ // This comma is a separator between codec options
+ String codecOption = buf.toString();
+ result.add(parseOption(codecOption));
+ // Clear buf
+ buf.setLength(0);
+ }
+ break;
+ default:
+ buf.append(c);
+ break;
+ }
+ }
+
+ if (buf.length() > 0) {
+ String codecOption = buf.toString();
+ result.add(parseOption(codecOption));
+ }
+
+ return result;
+ }
+
+ private static CodecOption parseOption(String option) {
+ int equalSignIndex = option.indexOf('=');
+ if (equalSignIndex == -1) {
+ throw new IllegalArgumentException("'=' expected");
+ }
+ String keyAndType = option.substring(0, equalSignIndex);
+ if (keyAndType.length() == 0) {
+ throw new IllegalArgumentException("Key may not be null");
+ }
+
+ String key;
+ String type;
+
+ int colonIndex = keyAndType.indexOf(':');
+ if (colonIndex != -1) {
+ key = keyAndType.substring(0, colonIndex);
+ type = keyAndType.substring(colonIndex + 1);
+ } else {
+ key = keyAndType;
+ type = "int"; // assume int by default
+ }
+
+ Object value;
+ String valueString = option.substring(equalSignIndex + 1);
+ switch (type) {
+ case "int":
+ value = Integer.parseInt(valueString);
+ break;
+ case "long":
+ value = Long.parseLong(valueString);
+ break;
+ case "float":
+ value = Float.parseFloat(valueString);
+ break;
+ case "string":
+ value = valueString;
+ break;
+ default:
+ throw new IllegalArgumentException("Invalid codec option type (int, long, float, str): " + type);
+ }
+
+ return new CodecOption(key, value);
+ }
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/ControlMessage.java b/server/src/main/java/com/genymobile/scrcpy/ControlMessage.java
index 195b04bf..f8edd53c 100644
--- a/server/src/main/java/com/genymobile/scrcpy/ControlMessage.java
+++ b/server/src/main/java/com/genymobile/scrcpy/ControlMessage.java
@@ -11,11 +11,12 @@ public final class ControlMessage {
public static final int TYPE_INJECT_SCROLL_EVENT = 3;
public static final int TYPE_BACK_OR_SCREEN_ON = 4;
public static final int TYPE_EXPAND_NOTIFICATION_PANEL = 5;
- public static final int TYPE_COLLAPSE_NOTIFICATION_PANEL = 6;
- public static final int TYPE_GET_CLIPBOARD = 7;
- public static final int TYPE_SET_CLIPBOARD = 8;
- public static final int TYPE_SET_SCREEN_POWER_MODE = 9;
- public static final int TYPE_ROTATE_DEVICE = 10;
+ public static final int TYPE_EXPAND_SETTINGS_PANEL = 6;
+ 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_ROTATE_DEVICE = 11;
private int type;
private String text;
@@ -28,15 +29,18 @@ public final class ControlMessage {
private Position position;
private int hScroll;
private int vScroll;
+ private boolean paste;
+ private int repeat;
private ControlMessage() {
}
- public static ControlMessage createInjectKeycode(int action, int keycode, int metaState) {
+ public static ControlMessage createInjectKeycode(int action, int keycode, int repeat, int metaState) {
ControlMessage msg = new ControlMessage();
msg.type = TYPE_INJECT_KEYCODE;
msg.action = action;
msg.keycode = keycode;
+ msg.repeat = repeat;
msg.metaState = metaState;
return msg;
}
@@ -68,10 +72,18 @@ public final class ControlMessage {
return msg;
}
- public static ControlMessage createSetClipboard(String text) {
+ public static ControlMessage createBackOrScreenOn(int action) {
+ ControlMessage msg = new ControlMessage();
+ msg.type = TYPE_BACK_OR_SCREEN_ON;
+ msg.action = action;
+ return msg;
+ }
+
+ public static ControlMessage createSetClipboard(String text, boolean paste) {
ControlMessage msg = new ControlMessage();
msg.type = TYPE_SET_CLIPBOARD;
msg.text = text;
+ msg.paste = paste;
return msg;
}
@@ -134,4 +146,12 @@ public final class ControlMessage {
public int getVScroll() {
return vScroll;
}
+
+ public boolean getPaste() {
+ return paste;
+ }
+
+ public int getRepeat() {
+ return repeat;
+ }
}
diff --git a/server/src/main/java/com/genymobile/scrcpy/ControlMessageReader.java b/server/src/main/java/com/genymobile/scrcpy/ControlMessageReader.java
index 726b5659..e4ab8402 100644
--- a/server/src/main/java/com/genymobile/scrcpy/ControlMessageReader.java
+++ b/server/src/main/java/com/genymobile/scrcpy/ControlMessageReader.java
@@ -8,19 +8,20 @@ import java.nio.charset.StandardCharsets;
public class ControlMessageReader {
- private static final int INJECT_KEYCODE_PAYLOAD_LENGTH = 9;
- private static final int INJECT_MOUSE_EVENT_PAYLOAD_LENGTH = 17;
- private static final int INJECT_TOUCH_EVENT_PAYLOAD_LENGTH = 21;
- private static final int INJECT_SCROLL_EVENT_PAYLOAD_LENGTH = 20;
- private static final int SET_SCREEN_POWER_MODE_PAYLOAD_LENGTH = 1;
+ static final int INJECT_KEYCODE_PAYLOAD_LENGTH = 13;
+ static final int INJECT_TOUCH_EVENT_PAYLOAD_LENGTH = 27;
+ 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 SET_CLIPBOARD_FIXED_PAYLOAD_LENGTH = 1;
- public static final int TEXT_MAX_LENGTH = 300;
- public static final int CLIPBOARD_TEXT_MAX_LENGTH = 4093;
- private static final int RAW_BUFFER_SIZE = 1024;
+ private static final int MESSAGE_MAX_SIZE = 1 << 18; // 256k
- private final byte[] rawBuffer = new byte[RAW_BUFFER_SIZE];
+ public static final int CLIPBOARD_TEXT_MAX_LENGTH = MESSAGE_MAX_SIZE - 6; // type: 1 byte; 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);
- private final byte[] textBuffer = new byte[CLIPBOARD_TEXT_MAX_LENGTH];
public ControlMessageReader() {
// invariant: the buffer is always in "get" mode
@@ -66,15 +67,18 @@ public class ControlMessageReader {
case ControlMessage.TYPE_INJECT_SCROLL_EVENT:
msg = parseInjectScrollEvent();
break;
+ case ControlMessage.TYPE_BACK_OR_SCREEN_ON:
+ msg = parseBackOrScreenOnEvent();
+ break;
case ControlMessage.TYPE_SET_CLIPBOARD:
msg = parseSetClipboard();
break;
case ControlMessage.TYPE_SET_SCREEN_POWER_MODE:
msg = parseSetScreenPowerMode();
break;
- case ControlMessage.TYPE_BACK_OR_SCREEN_ON:
case ControlMessage.TYPE_EXPAND_NOTIFICATION_PANEL:
- case ControlMessage.TYPE_COLLAPSE_NOTIFICATION_PANEL:
+ case ControlMessage.TYPE_EXPAND_SETTINGS_PANEL:
+ case ControlMessage.TYPE_COLLAPSE_PANELS:
case ControlMessage.TYPE_GET_CLIPBOARD:
case ControlMessage.TYPE_ROTATE_DEVICE:
msg = ControlMessage.createEmpty(type);
@@ -98,20 +102,23 @@ public class ControlMessageReader {
}
int action = toUnsigned(buffer.get());
int keycode = buffer.getInt();
+ int repeat = buffer.getInt();
int metaState = buffer.getInt();
- return ControlMessage.createInjectKeycode(action, keycode, metaState);
+ return ControlMessage.createInjectKeycode(action, keycode, repeat, metaState);
}
private String parseString() {
- if (buffer.remaining() < 2) {
+ if (buffer.remaining() < 4) {
return null;
}
- int len = toUnsigned(buffer.getShort());
+ int len = buffer.getInt();
if (buffer.remaining() < len) {
return null;
}
- buffer.get(textBuffer, 0, len);
- return new String(textBuffer, 0, len, StandardCharsets.UTF_8);
+ 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() {
@@ -122,7 +129,6 @@ public class ControlMessageReader {
return ControlMessage.createInjectText(text);
}
- @SuppressWarnings("checkstyle:MagicNumber")
private ControlMessage parseInjectTouchEvent() {
if (buffer.remaining() < INJECT_TOUCH_EVENT_PAYLOAD_LENGTH) {
return null;
@@ -148,12 +154,24 @@ public class ControlMessageReader {
return ControlMessage.createInjectScrollEvent(position, hScroll, vScroll);
}
+ private ControlMessage parseBackOrScreenOnEvent() {
+ if (buffer.remaining() < BACK_OR_SCREEN_ON_LENGTH) {
+ return null;
+ }
+ int action = toUnsigned(buffer.get());
+ return ControlMessage.createBackOrScreenOn(action);
+ }
+
private ControlMessage parseSetClipboard() {
+ if (buffer.remaining() < SET_CLIPBOARD_FIXED_PAYLOAD_LENGTH) {
+ return null;
+ }
+ boolean paste = buffer.get() != 0;
String text = parseString();
if (text == null) {
return null;
}
- return ControlMessage.createSetClipboard(text);
+ return ControlMessage.createSetClipboard(text, paste);
}
private ControlMessage parseSetScreenPowerMode() {
@@ -172,12 +190,10 @@ public class ControlMessageReader {
return new Position(x, y, screenWidth, screenHeight);
}
- @SuppressWarnings("checkstyle:MagicNumber")
private static int toUnsigned(short value) {
return value & 0xffff;
}
- @SuppressWarnings("checkstyle:MagicNumber")
private static int toUnsigned(byte value) {
return value & 0xff;
}
diff --git a/server/src/main/java/com/genymobile/scrcpy/Controller.java b/server/src/main/java/com/genymobile/scrcpy/Controller.java
index dc0fa67b..92986241 100644
--- a/server/src/main/java/com/genymobile/scrcpy/Controller.java
+++ b/server/src/main/java/com/genymobile/scrcpy/Controller.java
@@ -1,19 +1,22 @@
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.InputEvent;
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 {
- private static final int DEVICE_ID_VIRTUAL = -1;
+ private static final int DEFAULT_DEVICE_ID = 0;
+
+ private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor();
private final Device device;
private final DesktopConnection connection;
@@ -26,6 +29,8 @@ public class Controller {
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) {
this.device = device;
this.connection = connection;
@@ -40,18 +45,17 @@ public class Controller {
MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords();
coords.orientation = 0;
- coords.size = 1;
+ coords.size = 0;
pointerProperties[i] = props;
pointerCoords[i] = coords;
}
}
- @SuppressWarnings("checkstyle:MagicNumber")
public void control() throws IOException {
// on start, power on the device
- if (!device.isScreenOn()) {
- injectKeycode(KeyEvent.KEYCODE_POWER);
+ if (!Device.isScreenOn()) {
+ device.pressReleaseKeycode(KeyEvent.KEYCODE_POWER);
// dirty hack
// After POWER is injected, the device is powered on asynchronously.
@@ -76,46 +80,71 @@ public class Controller {
ControlMessage msg = connection.receiveControlMessage();
switch (msg.getType()) {
case ControlMessage.TYPE_INJECT_KEYCODE:
- injectKeycode(msg.getAction(), msg.getKeycode(), msg.getMetaState());
+ if (device.supportsInputEvents()) {
+ injectKeycode(msg.getAction(), msg.getKeycode(), msg.getRepeat(), msg.getMetaState());
+ }
break;
case ControlMessage.TYPE_INJECT_TEXT:
- injectText(msg.getText());
+ if (device.supportsInputEvents()) {
+ injectText(msg.getText());
+ }
break;
case ControlMessage.TYPE_INJECT_TOUCH_EVENT:
- injectTouch(msg.getAction(), msg.getPointerId(), msg.getPosition(), msg.getPressure(), msg.getButtons());
+ if (device.supportsInputEvents()) {
+ injectTouch(msg.getAction(), msg.getPointerId(), msg.getPosition(), msg.getPressure(), msg.getButtons());
+ }
break;
case ControlMessage.TYPE_INJECT_SCROLL_EVENT:
- injectScroll(msg.getPosition(), msg.getHScroll(), msg.getVScroll());
+ if (device.supportsInputEvents()) {
+ injectScroll(msg.getPosition(), msg.getHScroll(), msg.getVScroll());
+ }
break;
case ControlMessage.TYPE_BACK_OR_SCREEN_ON:
- pressBackOrTurnScreenOn();
+ if (device.supportsInputEvents()) {
+ pressBackOrTurnScreenOn(msg.getAction());
+ }
break;
case ControlMessage.TYPE_EXPAND_NOTIFICATION_PANEL:
- device.expandNotificationPanel();
+ Device.expandNotificationPanel();
break;
- case ControlMessage.TYPE_COLLAPSE_NOTIFICATION_PANEL:
- device.collapsePanels();
+ case ControlMessage.TYPE_EXPAND_SETTINGS_PANEL:
+ Device.expandSettingsPanel();
+ break;
+ case ControlMessage.TYPE_COLLAPSE_PANELS:
+ Device.collapsePanels();
break;
case ControlMessage.TYPE_GET_CLIPBOARD:
- String clipboardText = device.getClipboardText();
- sender.pushClipboardText(clipboardText);
+ String clipboardText = Device.getClipboardText();
+ if (clipboardText != null) {
+ sender.pushClipboardText(clipboardText);
+ }
break;
case ControlMessage.TYPE_SET_CLIPBOARD:
- device.setClipboardText(msg.getText());
+ setClipboard(msg.getText(), msg.getPaste());
break;
case ControlMessage.TYPE_SET_SCREEN_POWER_MODE:
- device.setScreenPowerMode(msg.getAction());
+ 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();
+ Device.rotateDevice();
break;
default:
// do nothing
}
}
- private boolean injectKeycode(int action, int keycode, int metaState) {
- return injectKeyEvent(action, keycode, 0, metaState);
+ 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);
}
private boolean injectChar(char c) {
@@ -126,7 +155,7 @@ public class Controller {
return false;
}
for (KeyEvent event : events) {
- if (!injectEvent(event)) {
+ if (!device.injectEvent(event)) {
return false;
}
}
@@ -150,7 +179,7 @@ public class Controller {
Point point = device.getPhysicalPoint(position);
if (point == null) {
- // ignore event
+ Ln.w("Ignore touch event, it was generated for a different device size");
return false;
}
@@ -179,10 +208,18 @@ public class Controller {
}
}
+ // Right-click and middle-click only work if the source is a mouse
+ boolean nonPrimaryButtonPressed = (buttons & ~MotionEvent.BUTTON_PRIMARY) != 0;
+ int source = nonPrimaryButtonPressed ? InputDevice.SOURCE_MOUSE : InputDevice.SOURCE_TOUCHSCREEN;
+ if (source != InputDevice.SOURCE_MOUSE) {
+ // Buttons must not be set for touch events
+ buttons = 0;
+ }
+
MotionEvent event = MotionEvent
- .obtain(lastTouchDown, now, action, pointerCount, pointerProperties, pointerCoords, 0, buttons, 1f, 1f, DEVICE_ID_VIRTUAL, 0,
- InputDevice.SOURCE_TOUCHSCREEN, 0);
- return injectEvent(event);
+ .obtain(lastTouchDown, now, action, pointerCount, pointerProperties, pointerCoords, 0, buttons, 1f, 1f, DEFAULT_DEVICE_ID, 0, source,
+ 0);
+ return device.injectEvent(event);
}
private boolean injectScroll(Position position, int hScroll, int vScroll) {
@@ -203,28 +240,53 @@ public class Controller {
coords.setAxisValue(MotionEvent.AXIS_VSCROLL, vScroll);
MotionEvent event = MotionEvent
- .obtain(lastTouchDown, now, MotionEvent.ACTION_SCROLL, 1, pointerProperties, pointerCoords, 0, 0, 1f, 1f, DEVICE_ID_VIRTUAL, 0,
- InputDevice.SOURCE_MOUSE, 0);
- return injectEvent(event);
+ .obtain(lastTouchDown, now, MotionEvent.ACTION_SCROLL, 1, pointerProperties, pointerCoords, 0, 0, 1f, 1f, DEFAULT_DEVICE_ID, 0,
+ InputDevice.SOURCE_TOUCHSCREEN, 0);
+ return device.injectEvent(event);
}
- private boolean injectKeyEvent(int action, int keyCode, int repeat, int metaState) {
- 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);
+ /**
+ * Schedule a call to set power mode to off after a small delay.
+ */
+ private static void schedulePowerModeOff() {
+ EXECUTOR.schedule(new Runnable() {
+ @Override
+ public void run() {
+ Ln.i("Forcing screen off");
+ Device.setScreenPowerMode(Device.POWER_MODE_OFF);
+ }
+ }, 200, TimeUnit.MILLISECONDS);
}
- private boolean injectKeycode(int keyCode) {
- return injectKeyEvent(KeyEvent.ACTION_DOWN, keyCode, 0, 0) && injectKeyEvent(KeyEvent.ACTION_UP, keyCode, 0, 0);
+ private boolean pressBackOrTurnScreenOn(int action) {
+ if (Device.isScreenOn()) {
+ return device.injectKeyEvent(action, KeyEvent.KEYCODE_BACK, 0, 0);
+ }
+
+ // 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);
}
- private boolean injectEvent(InputEvent event) {
- return device.injectInputEvent(event, InputManager.INJECT_INPUT_EVENT_MODE_ASYNC);
- }
+ private boolean setClipboard(String text, boolean paste) {
+ boolean ok = device.setClipboardText(text);
+ if (ok) {
+ Ln.i("Device clipboard set");
+ }
- private boolean pressBackOrTurnScreenOn() {
- int keycode = device.isScreenOn() ? KeyEvent.KEYCODE_BACK : KeyEvent.KEYCODE_POWER;
- return injectKeycode(keycode);
+ // 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);
+ }
+
+ return ok;
}
}
diff --git a/server/src/main/java/com/genymobile/scrcpy/DesktopConnection.java b/server/src/main/java/com/genymobile/scrcpy/DesktopConnection.java
index a725d83d..0ec43040 100644
--- a/server/src/main/java/com/genymobile/scrcpy/DesktopConnection.java
+++ b/server/src/main/java/com/genymobile/scrcpy/DesktopConnection.java
@@ -84,7 +84,6 @@ public final class DesktopConnection implements Closeable {
controlSocket.close();
}
- @SuppressWarnings("checkstyle:MagicNumber")
private void send(String deviceName, int width, int height) throws IOException {
byte[] buffer = new byte[DEVICE_NAME_FIELD_LENGTH + 4];
diff --git a/server/src/main/java/com/genymobile/scrcpy/Device.java b/server/src/main/java/com/genymobile/scrcpy/Device.java
index 9448098a..3e71fe9c 100644
--- a/server/src/main/java/com/genymobile/scrcpy/Device.java
+++ b/server/src/main/java/com/genymobile/scrcpy/Device.java
@@ -1,37 +1,78 @@
package com.genymobile.scrcpy;
+import com.genymobile.scrcpy.wrappers.ClipboardManager;
+import com.genymobile.scrcpy.wrappers.ContentProvider;
+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.RemoteException;
+import android.os.SystemClock;
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 LOCK_VIDEO_ORIENTATION_UNLOCKED = -1;
+ public static final int LOCK_VIDEO_ORIENTATION_INITIAL = -2;
+
+ private static final ServiceManager SERVICE_MANAGER = new ServiceManager();
+
public interface RotationListener {
void onRotationChanged(int rotation);
}
- private final ServiceManager serviceManager = new ServiceManager();
+ public interface ClipboardListener {
+ void onClipboardTextChanged(String text);
+ }
private ScreenInfo screenInfo;
private RotationListener rotationListener;
+ 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) {
- screenInfo = computeScreenInfo(options.getCrop(), options.getMaxSize());
- registerRotationWatcher(new IRotationWatcher.Stub() {
+ displayId = options.getDisplayId();
+ DisplayInfo displayInfo = SERVICE_MANAGER.getDisplayManager().getDisplayInfo(displayId);
+ if (displayInfo == null) {
+ int[] displayIds = SERVICE_MANAGER.getDisplayManager().getDisplayIds();
+ throw new InvalidDisplayIdException(displayId, displayIds);
+ }
+
+ int displayInfoFlags = displayInfo.getFlags();
+
+ screenInfo = ScreenInfo.computeScreenInfo(displayInfo, options.getCrop(), options.getMaxSize(), options.getLockedVideoOrientation());
+ layerStack = displayInfo.getLayerStack();
+
+ SERVICE_MANAGER.getWindowManager().registerRotationWatcher(new IRotationWatcher.Stub() {
@Override
- public void onRotationChanged(int rotation) throws RemoteException {
+ public void onRotationChanged(int rotation) {
synchronized (Device.this) {
- screenInfo = screenInfo.withRotation(rotation);
+ screenInfo = screenInfo.withDeviceRotation(rotation);
// notify
if (rotationListener != null) {
@@ -39,143 +80,206 @@ public final class Device {
}
}
}
- });
+ }, displayId);
+
+ if (options.getControl()) {
+ // If control is enabled, synchronize Android clipboard to the computer automatically
+ ClipboardManager clipboardManager = SERVICE_MANAGER.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 ScreenInfo getScreenInfo() {
return screenInfo;
}
- private ScreenInfo computeScreenInfo(Rect crop, int maxSize) {
- DisplayInfo displayInfo = serviceManager.getDisplayManager().getDisplayInfo();
- boolean rotated = (displayInfo.getRotation() & 1) != 0;
- Size deviceSize = displayInfo.getSize();
- Rect contentRect = new Rect(0, 0, deviceSize.getWidth(), deviceSize.getHeight());
- if (crop != null) {
- if (rotated) {
- // 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, rotated);
- }
-
- private static String formatCrop(Rect rect) {
- return rect.width() + ":" + rect.height() + ":" + rect.left + ":" + rect.top;
- }
-
- @SuppressWarnings("checkstyle:MagicNumber")
- 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);
+ 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
- Size videoSize = screenInfo.getVideoSize();
- Size clientVideoSize = position.getScreenSize();
- if (!videoSize.equals(clientVideoSize)) {
+
+ // 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 = position.getPoint();
- int scaledX = contentRect.left + point.getX() * contentRect.width() / videoSize.getWidth();
- int scaledY = contentRect.top + point.getY() * contentRect.height() / videoSize.getHeight();
- return new Point(scaledX, scaledY);
+ 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 boolean injectInputEvent(InputEvent inputEvent, int mode) {
- return serviceManager.getInputManager().injectInputEvent(inputEvent, mode);
+ public static boolean supportsInputEvents(int displayId) {
+ return displayId == 0 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q;
}
- public boolean isScreenOn() {
- return serviceManager.getPowerManager().isScreenOn();
+ public boolean supportsInputEvents() {
+ return supportsInputEvents;
}
- public void registerRotationWatcher(IRotationWatcher rotationWatcher) {
- serviceManager.getWindowManager().registerRotationWatcher(rotationWatcher);
+ public static boolean injectEvent(InputEvent inputEvent, int displayId) {
+ if (!supportsInputEvents(displayId)) {
+ throw new AssertionError("Could not inject input event if !supportsInputEvents()");
+ }
+
+ if (displayId != 0 && !InputManager.setDisplayId(inputEvent, displayId)) {
+ return false;
+ }
+
+ return SERVICE_MANAGER.getInputManager().injectInputEvent(inputEvent, InputManager.INJECT_INPUT_EVENT_MODE_ASYNC);
+ }
+
+ public boolean injectEvent(InputEvent event) {
+ return injectEvent(event, displayId);
+ }
+
+ public static boolean injectKeyEvent(int action, int keyCode, int repeat, int metaState, int displayId) {
+ 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);
+ }
+
+ public boolean injectKeyEvent(int action, int keyCode, int repeat, int metaState) {
+ return injectKeyEvent(action, keyCode, repeat, metaState, displayId);
+ }
+
+ public static boolean pressReleaseKeycode(int keyCode, int displayId) {
+ return injectKeyEvent(KeyEvent.ACTION_DOWN, keyCode, 0, 0, displayId) && injectKeyEvent(KeyEvent.ACTION_UP, keyCode, 0, 0, displayId);
+ }
+
+ public boolean pressReleaseKeycode(int keyCode) {
+ return pressReleaseKeycode(keyCode, displayId);
+ }
+
+ public static boolean isScreenOn() {
+ return SERVICE_MANAGER.getPowerManager().isScreenOn();
}
public synchronized void setRotationListener(RotationListener rotationListener) {
this.rotationListener = rotationListener;
}
- public void expandNotificationPanel() {
- serviceManager.getStatusBarManager().expandNotificationsPanel();
+ public synchronized void setClipboardListener(ClipboardListener clipboardListener) {
+ this.clipboardListener = clipboardListener;
}
- public void collapsePanels() {
- serviceManager.getStatusBarManager().collapsePanels();
+ public static void expandNotificationPanel() {
+ SERVICE_MANAGER.getStatusBarManager().expandNotificationsPanel();
}
- public String getClipboardText() {
- CharSequence s = serviceManager.getClipboardManager().getText();
+ public static void expandSettingsPanel() {
+ SERVICE_MANAGER.getStatusBarManager().expandSettingsPanel();
+ }
+
+ public static void collapsePanels() {
+ SERVICE_MANAGER.getStatusBarManager().collapsePanels();
+ }
+
+ public static String getClipboardText() {
+ ClipboardManager clipboardManager = SERVICE_MANAGER.getClipboardManager();
+ if (clipboardManager == null) {
+ return null;
+ }
+ CharSequence s = clipboardManager.getText();
if (s == null) {
return null;
}
return s.toString();
}
- public void setClipboardText(String text) {
- serviceManager.getClipboardManager().setText(text);
- Ln.i("Device clipboard set");
+ public boolean setClipboardText(String text) {
+ ClipboardManager clipboardManager = SERVICE_MANAGER.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 SCREEN_POWER_MODE_*} constants
+ * @param mode one of the {@code POWER_MODE_*} constants
*/
- public void setScreenPowerMode(int mode) {
+ public static boolean setScreenPowerMode(int mode) {
IBinder d = SurfaceControl.getBuiltInDisplay();
if (d == null) {
Ln.e("Could not get built-in display");
- return;
+ return false;
}
- SurfaceControl.setDisplayPowerMode(d, mode);
- Ln.i("Device screen turned " + (mode == Device.POWER_MODE_OFF ? "off" : "on"));
+ return SurfaceControl.setDisplayPowerMode(d, mode);
+ }
+
+ public static boolean powerOffScreen(int displayId) {
+ if (!isScreenOn()) {
+ return true;
+ }
+ return pressReleaseKeycode(KeyEvent.KEYCODE_POWER, displayId);
}
/**
* Disable auto-rotation (if enabled), set the screen rotation and re-enable auto-rotation (if it was enabled).
*/
- public void rotateDevice() {
- WindowManager wm = serviceManager.getWindowManager();
+ public static void rotateDevice() {
+ WindowManager wm = SERVICE_MANAGER.getWindowManager();
boolean accelerometerRotation = !wm.isRotationFrozen();
@@ -192,7 +296,7 @@ public final class Device {
}
}
- static Rect flipRect(Rect crop) {
- return new Rect(crop.top, crop.left, crop.bottom, crop.right);
+ public static ContentProvider createSettingsProvider() {
+ return SERVICE_MANAGER.getActivityManager().createSettingsProvider();
}
}
diff --git a/server/src/main/java/com/genymobile/scrcpy/DeviceMessageWriter.java b/server/src/main/java/com/genymobile/scrcpy/DeviceMessageWriter.java
index e2a3a1a2..15d91a35 100644
--- a/server/src/main/java/com/genymobile/scrcpy/DeviceMessageWriter.java
+++ b/server/src/main/java/com/genymobile/scrcpy/DeviceMessageWriter.java
@@ -7,13 +7,12 @@ import java.nio.charset.StandardCharsets;
public class DeviceMessageWriter {
- public static final int CLIPBOARD_TEXT_MAX_LENGTH = 4093;
- private static final int MAX_EVENT_SIZE = CLIPBOARD_TEXT_MAX_LENGTH + 3;
+ 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[MAX_EVENT_SIZE];
+ private final byte[] rawBuffer = new byte[MESSAGE_MAX_SIZE];
private final ByteBuffer buffer = ByteBuffer.wrap(rawBuffer);
- @SuppressWarnings("checkstyle:MagicNumber")
public void writeTo(DeviceMessage msg, OutputStream output) throws IOException {
buffer.clear();
buffer.put((byte) DeviceMessage.TYPE_CLIPBOARD);
@@ -22,7 +21,7 @@ public class DeviceMessageWriter {
String text = msg.getText();
byte[] raw = text.getBytes(StandardCharsets.UTF_8);
int len = StringUtils.getUtf8TruncationIndex(raw, CLIPBOARD_TEXT_MAX_LENGTH);
- buffer.putShort((short) len);
+ buffer.putInt(len);
buffer.put(raw, 0, len);
output.write(rawBuffer, 0, buffer.position());
break;
diff --git a/server/src/main/java/com/genymobile/scrcpy/DisplayInfo.java b/server/src/main/java/com/genymobile/scrcpy/DisplayInfo.java
index 639869b5..4b8036f8 100644
--- a/server/src/main/java/com/genymobile/scrcpy/DisplayInfo.java
+++ b/server/src/main/java/com/genymobile/scrcpy/DisplayInfo.java
@@ -1,12 +1,24 @@
package com.genymobile.scrcpy;
public final class DisplayInfo {
+ private final int displayId;
private final Size size;
private final int rotation;
+ private final int layerStack;
+ private final int flags;
- public DisplayInfo(Size size, int rotation) {
+ public static final int FLAG_SUPPORTS_PROTECTED_BUFFERS = 0x00000001;
+
+ public DisplayInfo(int displayId, Size size, int rotation, int layerStack, int flags) {
+ this.displayId = displayId;
this.size = size;
this.rotation = rotation;
+ this.layerStack = layerStack;
+ this.flags = flags;
+ }
+
+ public int getDisplayId() {
+ return displayId;
}
public Size getSize() {
@@ -16,5 +28,13 @@ public final class DisplayInfo {
public int getRotation() {
return rotation;
}
+
+ public int getLayerStack() {
+ return layerStack;
+ }
+
+ public int getFlags() {
+ return flags;
+ }
}
diff --git a/server/src/main/java/com/genymobile/scrcpy/InvalidDisplayIdException.java b/server/src/main/java/com/genymobile/scrcpy/InvalidDisplayIdException.java
new file mode 100644
index 00000000..81e3b903
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/InvalidDisplayIdException.java
@@ -0,0 +1,21 @@
+package com.genymobile.scrcpy;
+
+public class InvalidDisplayIdException extends RuntimeException {
+
+ private final int displayId;
+ private final int[] availableDisplayIds;
+
+ public InvalidDisplayIdException(int displayId, int[] availableDisplayIds) {
+ super("There is no display having id " + displayId);
+ this.displayId = displayId;
+ this.availableDisplayIds = availableDisplayIds;
+ }
+
+ public int getDisplayId() {
+ return displayId;
+ }
+
+ public int[] getAvailableDisplayIds() {
+ return availableDisplayIds;
+ }
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/InvalidEncoderException.java b/server/src/main/java/com/genymobile/scrcpy/InvalidEncoderException.java
new file mode 100644
index 00000000..1efd2989
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/InvalidEncoderException.java
@@ -0,0 +1,23 @@
+package com.genymobile.scrcpy;
+
+import android.media.MediaCodecInfo;
+
+public class InvalidEncoderException extends RuntimeException {
+
+ private final String name;
+ private final MediaCodecInfo[] availableEncoders;
+
+ public InvalidEncoderException(String name, MediaCodecInfo[] availableEncoders) {
+ super("There is no encoder having name '" + name + '"');
+ this.name = name;
+ this.availableEncoders = availableEncoders;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public MediaCodecInfo[] getAvailableEncoders() {
+ return availableEncoders;
+ }
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/Ln.java b/server/src/main/java/com/genymobile/scrcpy/Ln.java
index 26f13a56..061cda95 100644
--- a/server/src/main/java/com/genymobile/scrcpy/Ln.java
+++ b/server/src/main/java/com/genymobile/scrcpy/Ln.java
@@ -12,17 +12,35 @@ public final class Ln {
private static final String PREFIX = "[server] ";
enum Level {
- DEBUG, INFO, WARN, ERROR
+ VERBOSE, DEBUG, INFO, WARN, ERROR
}
- private static final Level THRESHOLD = BuildConfig.DEBUG ? Level.DEBUG : Level.INFO;
+ private static Level threshold = Level.INFO;
private Ln() {
// not instantiable
}
+ /**
+ * Initialize the log level.
+ *
+ * Must be called before starting any new thread.
+ *
+ * @param level the log level
+ */
+ public static void initLogLevel(Level level) {
+ threshold = level;
+ }
+
public static boolean isEnabled(Level level) {
- return level.ordinal() >= THRESHOLD.ordinal();
+ return level.ordinal() >= threshold.ordinal();
+ }
+
+ public static void v(String message) {
+ if (isEnabled(Level.VERBOSE)) {
+ Log.v(TAG, message);
+ System.out.println(PREFIX + "VERBOSE: " + message);
+ }
}
public static void d(String message) {
diff --git a/server/src/main/java/com/genymobile/scrcpy/Options.java b/server/src/main/java/com/genymobile/scrcpy/Options.java
index 5b993f30..cf11df0f 100644
--- a/server/src/main/java/com/genymobile/scrcpy/Options.java
+++ b/server/src/main/java/com/genymobile/scrcpy/Options.java
@@ -3,13 +3,29 @@ package com.genymobile.scrcpy;
import android.graphics.Rect;
public class Options {
+ private Ln.Level logLevel;
private int maxSize;
private int bitRate;
private int maxFps;
+ private int lockedVideoOrientation;
private boolean tunnelForward;
private Rect crop;
private boolean sendFrameMeta; // send PTS so that the client may record properly
private boolean control;
+ private int displayId;
+ private boolean showTouches;
+ private boolean stayAwake;
+ private String codecOptions;
+ private String encoderName;
+ private boolean powerOffScreenOnClose;
+
+ public Ln.Level getLogLevel() {
+ return logLevel;
+ }
+
+ public void setLogLevel(Ln.Level logLevel) {
+ this.logLevel = logLevel;
+ }
public int getMaxSize() {
return maxSize;
@@ -35,6 +51,14 @@ public class Options {
this.maxFps = maxFps;
}
+ public int getLockedVideoOrientation() {
+ return lockedVideoOrientation;
+ }
+
+ public void setLockedVideoOrientation(int lockedVideoOrientation) {
+ this.lockedVideoOrientation = lockedVideoOrientation;
+ }
+
public boolean isTunnelForward() {
return tunnelForward;
}
@@ -66,4 +90,52 @@ public class Options {
public void setControl(boolean control) {
this.control = control;
}
+
+ public int getDisplayId() {
+ return displayId;
+ }
+
+ public void setDisplayId(int displayId) {
+ this.displayId = displayId;
+ }
+
+ public boolean getShowTouches() {
+ return showTouches;
+ }
+
+ public void setShowTouches(boolean showTouches) {
+ this.showTouches = showTouches;
+ }
+
+ public boolean getStayAwake() {
+ return stayAwake;
+ }
+
+ public void setStayAwake(boolean stayAwake) {
+ this.stayAwake = stayAwake;
+ }
+
+ public String getCodecOptions() {
+ return codecOptions;
+ }
+
+ public void setCodecOptions(String codecOptions) {
+ this.codecOptions = codecOptions;
+ }
+
+ public String getEncoderName() {
+ return encoderName;
+ }
+
+ public void setEncoderName(String encoderName) {
+ this.encoderName = encoderName;
+ }
+
+ public void setPowerOffScreenOnClose(boolean powerOffScreenOnClose) {
+ this.powerOffScreenOnClose = powerOffScreenOnClose;
+ }
+
+ public boolean getPowerOffScreenOnClose() {
+ return this.powerOffScreenOnClose;
+ }
}
diff --git a/server/src/main/java/com/genymobile/scrcpy/Position.java b/server/src/main/java/com/genymobile/scrcpy/Position.java
index b46d2f73..e9b6d8a2 100644
--- a/server/src/main/java/com/genymobile/scrcpy/Position.java
+++ b/server/src/main/java/com/genymobile/scrcpy/Position.java
@@ -23,6 +23,19 @@ public class Position {
return screenSize;
}
+ public Position rotate(int rotation) {
+ switch (rotation) {
+ case 1:
+ return new Position(new Point(screenSize.getHeight() - point.getY(), point.getX()), screenSize.rotate());
+ case 2:
+ return new Position(new Point(screenSize.getWidth() - point.getX(), screenSize.getHeight() - point.getY()), screenSize);
+ case 3:
+ return new Position(new Point(point.getY(), screenSize.getWidth() - point.getX()), screenSize.rotate());
+ default:
+ return this;
+ }
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) {
diff --git a/server/src/main/java/com/genymobile/scrcpy/ScreenEncoder.java b/server/src/main/java/com/genymobile/scrcpy/ScreenEncoder.java
index c9a37f84..2f7109c5 100644
--- a/server/src/main/java/com/genymobile/scrcpy/ScreenEncoder.java
+++ b/server/src/main/java/com/genymobile/scrcpy/ScreenEncoder.java
@@ -5,6 +5,7 @@ import com.genymobile.scrcpy.wrappers.SurfaceControl;
import android.graphics.Rect;
import android.media.MediaCodec;
import android.media.MediaCodecInfo;
+import android.media.MediaCodecList;
import android.media.MediaFormat;
import android.os.Build;
import android.os.IBinder;
@@ -13,33 +14,35 @@ import android.view.Surface;
import java.io.FileDescriptor;
import java.io.IOException;
import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
public class ScreenEncoder implements Device.RotationListener {
private static final int DEFAULT_I_FRAME_INTERVAL = 10; // seconds
private static final int REPEAT_FRAME_DELAY_US = 100_000; // repeat after 100ms
+ private static final String KEY_MAX_FPS_TO_ENCODER = "max-fps-to-encoder";
private static final int NO_PTS = -1;
private final AtomicBoolean rotationChanged = new AtomicBoolean();
private final ByteBuffer headerBuffer = ByteBuffer.allocate(12);
+ private String encoderName;
+ private List codecOptions;
private int bitRate;
private int maxFps;
- private int iFrameInterval;
private boolean sendFrameMeta;
private long ptsOrigin;
- public ScreenEncoder(boolean sendFrameMeta, int bitRate, int maxFps, int iFrameInterval) {
+ public ScreenEncoder(boolean sendFrameMeta, int bitRate, int maxFps, List codecOptions, String encoderName) {
this.sendFrameMeta = sendFrameMeta;
this.bitRate = bitRate;
this.maxFps = maxFps;
- this.iFrameInterval = iFrameInterval;
- }
-
- public ScreenEncoder(boolean sendFrameMeta, int bitRate, int maxFps) {
- this(sendFrameMeta, bitRate, maxFps, DEFAULT_I_FRAME_INTERVAL);
+ this.codecOptions = codecOptions;
+ this.encoderName = encoderName;
}
@Override
@@ -53,21 +56,40 @@ public class ScreenEncoder implements Device.RotationListener {
public void streamScreen(Device device, FileDescriptor fd) throws IOException {
Workarounds.prepareMainLooper();
- Workarounds.fillAppInfo();
- MediaFormat format = createFormat(bitRate, maxFps, iFrameInterval);
+ try {
+ internalStreamScreen(device, fd);
+ } catch (NullPointerException e) {
+ // Retry with workarounds enabled:
+ //
+ //
+ Ln.d("Applying workarounds to avoid NullPointerException");
+ Workarounds.fillAppInfo();
+ internalStreamScreen(device, fd);
+ }
+ }
+
+ private void internalStreamScreen(Device device, FileDescriptor fd) throws IOException {
+ MediaFormat format = createFormat(bitRate, maxFps, codecOptions);
device.setRotationListener(this);
boolean alive;
try {
do {
- MediaCodec codec = createCodec();
+ MediaCodec codec = createCodec(encoderName);
IBinder display = createDisplay();
- Rect contentRect = device.getScreenInfo().getContentRect();
- Rect videoRect = device.getScreenInfo().getVideoSize().toRect();
+ ScreenInfo screenInfo = device.getScreenInfo();
+ Rect contentRect = screenInfo.getContentRect();
+ // include the locked video orientation
+ Rect videoRect = screenInfo.getVideoSize().toRect();
+ // does not include the locked video orientation
+ Rect unlockedVideoRect = screenInfo.getUnlockedVideoSize().toRect();
+ int videoRotation = screenInfo.getVideoRotation();
+ int layerStack = device.getLayerStack();
+
setSize(format, videoRect.width(), videoRect.height());
configure(codec, format);
Surface surface = codec.createInputSurface();
- setDisplaySurface(display, surface, contentRect, videoRect);
+ setDisplaySurface(display, surface, videoRotation, contentRect, unlockedVideoRect, layerStack);
codec.start();
try {
alive = encode(codec, fd);
@@ -134,33 +156,81 @@ public class ScreenEncoder implements Device.RotationListener {
IO.writeFully(fd, headerBuffer);
}
- private static MediaCodec createCodec() throws IOException {
- return MediaCodec.createEncoderByType("video/avc");
+ private static MediaCodecInfo[] listEncoders() {
+ List result = new ArrayList<>();
+ MediaCodecList list = new MediaCodecList(MediaCodecList.REGULAR_CODECS);
+ for (MediaCodecInfo codecInfo : list.getCodecInfos()) {
+ if (codecInfo.isEncoder() && Arrays.asList(codecInfo.getSupportedTypes()).contains(MediaFormat.MIMETYPE_VIDEO_AVC)) {
+ result.add(codecInfo);
+ }
+ }
+ return result.toArray(new MediaCodecInfo[result.size()]);
}
- @SuppressWarnings("checkstyle:MagicNumber")
- private static MediaFormat createFormat(int bitRate, int maxFps, int iFrameInterval) {
+ private static MediaCodec createCodec(String encoderName) throws IOException {
+ if (encoderName != null) {
+ Ln.d("Creating encoder by name: '" + encoderName + "'");
+ try {
+ return MediaCodec.createByCodecName(encoderName);
+ } catch (IllegalArgumentException e) {
+ MediaCodecInfo[] encoders = listEncoders();
+ throw new InvalidEncoderException(encoderName, encoders);
+ }
+ }
+ MediaCodec codec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC);
+ Ln.d("Using encoder: '" + codec.getName() + "'");
+ return codec;
+ }
+
+ private static void setCodecOption(MediaFormat format, CodecOption codecOption) {
+ String key = codecOption.getKey();
+ Object value = codecOption.getValue();
+
+ 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);
+ }
+
+ Ln.d("Codec option set: " + key + " (" + value.getClass().getSimpleName() + ") = " + value);
+ }
+
+ private static MediaFormat createFormat(int bitRate, int maxFps, List codecOptions) {
MediaFormat format = new MediaFormat();
- format.setString(MediaFormat.KEY_MIME, "video/avc");
+ format.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_VIDEO_AVC);
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);
- format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iFrameInterval);
+ 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
if (maxFps > 0) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- format.setFloat(MediaFormat.KEY_MAX_FPS_TO_ENCODER, maxFps);
- } else {
- Ln.w("Max FPS is only supported since Android 10, the option has been ignored");
+ // The key existed privately before Android 10:
+ //
+ //
+ format.setFloat(KEY_MAX_FPS_TO_ENCODER, maxFps);
+ }
+
+ if (codecOptions != null) {
+ for (CodecOption option : codecOptions) {
+ setCodecOption(format, option);
}
}
+
return format;
}
private static IBinder createDisplay() {
- return SurfaceControl.createDisplay("scrcpy", true);
+ // 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 configure(MediaCodec codec, MediaFormat format) {
@@ -172,12 +242,12 @@ public class ScreenEncoder implements Device.RotationListener {
format.setInteger(MediaFormat.KEY_HEIGHT, height);
}
- private static void setDisplaySurface(IBinder display, Surface surface, Rect deviceRect, Rect displayRect) {
+ 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, 0, deviceRect, displayRect);
- SurfaceControl.setDisplayLayerStack(display, 0);
+ 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
index f2fce1d6..c27322ef 100644
--- a/server/src/main/java/com/genymobile/scrcpy/ScreenInfo.java
+++ b/server/src/main/java/com/genymobile/scrcpy/ScreenInfo.java
@@ -3,29 +3,167 @@ 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
- private final Size videoSize;
- private final boolean rotated;
- public ScreenInfo(Rect contentRect, Size videoSize, boolean rotated) {
+ /**
+ * 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.videoSize = videoSize;
- this.rotated = rotated;
+ this.unlockedVideoSize = unlockedVideoSize;
+ this.deviceRotation = deviceRotation;
+ this.lockedVideoOrientation = lockedVideoOrientation;
}
public Rect getContentRect() {
return contentRect;
}
- public Size getVideoSize() {
- return videoSize;
+ /**
+ * Return the video size as if locked video orientation was not set.
+ *
+ * @return the unlocked video size
+ */
+ public Size getUnlockedVideoSize() {
+ return unlockedVideoSize;
}
- public ScreenInfo withRotation(int rotation) {
- boolean newRotated = (rotation & 1) != 0;
- if (rotated == newRotated) {
+ /**
+ * 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;
}
- return new ScreenInfo(Device.flipRect(contentRect), videoSize.rotate(), newRotated);
+ // 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(DisplayInfo displayInfo, Rect crop, int maxSize, int lockedVideoOrientation) {
+ int rotation = displayInfo.getRotation();
+
+ if (lockedVideoOrientation == Device.LOCK_VIDEO_ORIENTATION_INITIAL) {
+ // The user requested to lock the video orientation to the current orientation
+ lockedVideoOrientation = rotation;
+ }
+
+ Size deviceSize = displayInfo.getSize();
+ 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 56b738fb..fdd9db88 100644
--- a/server/src/main/java/com/genymobile/scrcpy/Server.java
+++ b/server/src/main/java/com/genymobile/scrcpy/Server.java
@@ -1,32 +1,78 @@
package com.genymobile.scrcpy;
+import com.genymobile.scrcpy.wrappers.ContentProvider;
+
import android.graphics.Rect;
import android.media.MediaCodec;
+import android.media.MediaCodecInfo;
+import android.os.BatteryManager;
import android.os.Build;
-import java.io.File;
import java.io.IOException;
+import java.util.List;
+import java.util.Locale;
public final class Server {
- private static final String SERVER_PATH = "/data/local/tmp/scrcpy-server.jar";
private Server() {
// not instantiable
}
private static void scrcpy(Options options) throws IOException {
+ Ln.i("Device: " + Build.MANUFACTURER + " " + Build.MODEL + " (Android " + Build.VERSION.RELEASE + ")");
final Device device = new Device(options);
- boolean tunnelForward = options.isTunnelForward();
- try (DesktopConnection connection = DesktopConnection.open(device, tunnelForward)) {
- ScreenEncoder screenEncoder = new ScreenEncoder(options.getSendFrameMeta(), options.getBitRate(), options.getMaxFps());
+ List codecOptions = CodecOption.parse(options.getCodecOptions());
+ boolean mustDisableShowTouchesOnCleanUp = false;
+ int restoreStayOn = -1;
+ if (options.getShowTouches() || options.getStayAwake()) {
+ try (ContentProvider settings = Device.createSettingsProvider()) {
+ if (options.getShowTouches()) {
+ String oldValue = settings.getAndPutValue(ContentProvider.TABLE_SYSTEM, "show_touches", "1");
+ // If "show touches" was disabled, it must be disabled back on clean up
+ mustDisableShowTouchesOnCleanUp = !"1".equals(oldValue);
+ }
+
+ if (options.getStayAwake()) {
+ int stayOn = BatteryManager.BATTERY_PLUGGED_AC | BatteryManager.BATTERY_PLUGGED_USB | BatteryManager.BATTERY_PLUGGED_WIRELESS;
+ String oldValue = settings.getAndPutValue(ContentProvider.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;
+ }
+ }
+ }
+ }
+
+ CleanUp.configure(options.getDisplayId(), restoreStayOn, mustDisableShowTouchesOnCleanUp, true, options.getPowerOffScreenOnClose());
+
+ boolean tunnelForward = options.isTunnelForward();
+
+ try (DesktopConnection connection = DesktopConnection.open(device, tunnelForward)) {
+ ScreenEncoder screenEncoder = new ScreenEncoder(options.getSendFrameMeta(), options.getBitRate(), options.getMaxFps(), codecOptions,
+ options.getEncoderName());
+
+ Thread controllerThread = null;
+ Thread deviceMessageSenderThread = null;
if (options.getControl()) {
- Controller controller = new Controller(device, connection);
+ final Controller controller = new Controller(device, connection);
// asynchronous
- startController(controller);
- startDeviceMessageSender(controller.getSender());
+ controllerThread = startController(controller);
+ deviceMessageSenderThread = startDeviceMessageSender(controller.getSender());
+
+ device.setClipboardListener(new Device.ClipboardListener() {
+ @Override
+ public void onClipboardTextChanged(String text) {
+ controller.getSender().pushClipboardText(text);
+ }
+ });
}
try {
@@ -35,12 +81,19 @@ public final class Server {
} catch (IOException e) {
// this is expected on close
Ln.d("Screen streaming stopped");
+ } finally {
+ if (controllerThread != null) {
+ controllerThread.interrupt();
+ }
+ if (deviceMessageSenderThread != null) {
+ deviceMessageSenderThread.interrupt();
+ }
}
}
}
- private static void startController(final Controller controller) {
- new Thread(new Runnable() {
+ private static Thread startController(final Controller controller) {
+ Thread thread = new Thread(new Runnable() {
@Override
public void run() {
try {
@@ -50,11 +103,13 @@ public final class Server {
Ln.d("Controller stopped");
}
}
- }).start();
+ });
+ thread.start();
+ return thread;
}
- private static void startDeviceMessageSender(final DeviceMessageSender sender) {
- new Thread(new Runnable() {
+ private static Thread startDeviceMessageSender(final DeviceMessageSender sender) {
+ Thread thread = new Thread(new Runnable() {
@Override
public void run() {
try {
@@ -64,10 +119,11 @@ public final class Server {
Ln.d("Device message sender stopped");
}
}
- }).start();
+ });
+ thread.start();
+ return thread;
}
- @SuppressWarnings("checkstyle:MagicNumber")
private static Options createOptions(String... args) {
if (args.length < 1) {
throw new IllegalArgumentException("Missing client version");
@@ -76,41 +132,65 @@ public final class Server {
String clientVersion = args[0];
if (!clientVersion.equals(BuildConfig.VERSION_NAME)) {
throw new IllegalArgumentException(
- "The server version (" + clientVersion + ") does not match the client " + "(" + BuildConfig.VERSION_NAME + ")");
+ "The server version (" + BuildConfig.VERSION_NAME + ") does not match the client " + "(" + clientVersion + ")");
}
- if (args.length != 8) {
- throw new IllegalArgumentException("Expecting 8 parameters");
+ final int expectedParameters = 16;
+ if (args.length != expectedParameters) {
+ throw new IllegalArgumentException("Expecting " + expectedParameters + " parameters");
}
Options options = new Options();
- int maxSize = Integer.parseInt(args[1]) & ~7; // multiple of 8
+ Ln.Level level = Ln.Level.valueOf(args[1].toUpperCase(Locale.ENGLISH));
+ options.setLogLevel(level);
+
+ int maxSize = Integer.parseInt(args[2]) & ~7; // multiple of 8
options.setMaxSize(maxSize);
- int bitRate = Integer.parseInt(args[2]);
+ int bitRate = Integer.parseInt(args[3]);
options.setBitRate(bitRate);
- int maxFps = Integer.parseInt(args[3]);
+ int maxFps = Integer.parseInt(args[4]);
options.setMaxFps(maxFps);
+ int lockedVideoOrientation = Integer.parseInt(args[5]);
+ options.setLockedVideoOrientation(lockedVideoOrientation);
+
// use "adb forward" instead of "adb tunnel"? (so the server must listen)
- boolean tunnelForward = Boolean.parseBoolean(args[4]);
+ boolean tunnelForward = Boolean.parseBoolean(args[6]);
options.setTunnelForward(tunnelForward);
- Rect crop = parseCrop(args[5]);
+ Rect crop = parseCrop(args[7]);
options.setCrop(crop);
- boolean sendFrameMeta = Boolean.parseBoolean(args[6]);
+ boolean sendFrameMeta = Boolean.parseBoolean(args[8]);
options.setSendFrameMeta(sendFrameMeta);
- boolean control = Boolean.parseBoolean(args[7]);
+ boolean control = Boolean.parseBoolean(args[9]);
options.setControl(control);
+ int displayId = Integer.parseInt(args[10]);
+ options.setDisplayId(displayId);
+
+ boolean showTouches = Boolean.parseBoolean(args[11]);
+ options.setShowTouches(showTouches);
+
+ boolean stayAwake = Boolean.parseBoolean(args[12]);
+ options.setStayAwake(stayAwake);
+
+ String codecOptions = args[13];
+ options.setCodecOptions(codecOptions);
+
+ String encoderName = "-".equals(args[14]) ? null : args[14];
+ options.setEncoderName(encoderName);
+
+ boolean powerOffScreenOnClose = Boolean.parseBoolean(args[15]);
+ options.setPowerOffScreenOnClose(powerOffScreenOnClose);
+
return options;
}
- @SuppressWarnings("checkstyle:MagicNumber")
private static Rect parseCrop(String crop) {
if ("-".equals(crop)) {
return null;
@@ -127,15 +207,6 @@ public final class Server {
return new Rect(x, y, x + width, y + height);
}
- private static void unlinkSelf() {
- try {
- new File(SERVER_PATH).delete();
- } catch (Exception e) {
- Ln.e("Could not unlink server", e);
- }
- }
-
- @SuppressWarnings("checkstyle:MagicNumber")
private static void suggestFix(Throwable e) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (e instanceof MediaCodec.CodecException) {
@@ -147,6 +218,25 @@ public final class Server {
}
}
}
+ if (e instanceof InvalidDisplayIdException) {
+ InvalidDisplayIdException idie = (InvalidDisplayIdException) e;
+ int[] displayIds = idie.getAvailableDisplayIds();
+ if (displayIds != null && displayIds.length > 0) {
+ Ln.e("Try to use one of the available display ids:");
+ for (int id : displayIds) {
+ Ln.e(" scrcpy --display " + id);
+ }
+ }
+ } else if (e instanceof InvalidEncoderException) {
+ InvalidEncoderException iee = (InvalidEncoderException) e;
+ MediaCodecInfo[] encoders = iee.getAvailableEncoders();
+ if (encoders != null && encoders.length > 0) {
+ Ln.e("Try to use one of the available encoders:");
+ for (MediaCodecInfo encoder : encoders) {
+ Ln.e(" scrcpy --encoder '" + encoder.getName() + "'");
+ }
+ }
+ }
}
public static void main(String... args) throws Exception {
@@ -158,8 +248,10 @@ public final class Server {
}
});
- unlinkSelf();
Options options = createOptions(args);
+
+ Ln.initLogLevel(options.getLogLevel());
+
scrcpy(options);
}
}
diff --git a/server/src/main/java/com/genymobile/scrcpy/StringUtils.java b/server/src/main/java/com/genymobile/scrcpy/StringUtils.java
index 199fc8c1..dac05466 100644
--- a/server/src/main/java/com/genymobile/scrcpy/StringUtils.java
+++ b/server/src/main/java/com/genymobile/scrcpy/StringUtils.java
@@ -5,7 +5,6 @@ public final class StringUtils {
// not instantiable
}
- @SuppressWarnings("checkstyle:MagicNumber")
public static int getUtf8TruncationIndex(byte[] utf8, int maxLength) {
int len = utf8.length;
if (len <= maxLength) {
diff --git a/server/src/main/java/com/genymobile/scrcpy/Workarounds.java b/server/src/main/java/com/genymobile/scrcpy/Workarounds.java
index b1b81903..0f473bc1 100644
--- a/server/src/main/java/com/genymobile/scrcpy/Workarounds.java
+++ b/server/src/main/java/com/genymobile/scrcpy/Workarounds.java
@@ -16,6 +16,7 @@ public final class Workarounds {
// not instantiable
}
+ @SuppressWarnings("deprecation")
public 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()"
@@ -28,7 +29,7 @@ public final class Workarounds {
Looper.prepareMainLooper();
}
- @SuppressLint("PrivateApi")
+ @SuppressLint("PrivateApi,DiscouragedPrivateApi")
public static void fillAppInfo() {
try {
// ActivityThread activityThread = new ActivityThread();
@@ -73,7 +74,7 @@ public final class Workarounds {
mInitialApplicationField.set(activityThread, app);
} catch (Throwable throwable) {
// this is a workaround, so failing is not an error
- Ln.w("Could not fill app info: " + throwable.getMessage());
+ Ln.d("Could not fill app info: " + throwable.getMessage());
}
}
}
diff --git a/server/src/main/java/com/genymobile/scrcpy/wrappers/ActivityManager.java b/server/src/main/java/com/genymobile/scrcpy/wrappers/ActivityManager.java
new file mode 100644
index 00000000..93ed4528
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/ActivityManager.java
@@ -0,0 +1,87 @@
+package com.genymobile.scrcpy.wrappers;
+
+import com.genymobile.scrcpy.Ln;
+
+import android.os.Binder;
+import android.os.IBinder;
+import android.os.IInterface;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+public class ActivityManager {
+
+ private final IInterface manager;
+ private Method getContentProviderExternalMethod;
+ private boolean getContentProviderExternalMethodNewVersion = true;
+ private Method removeContentProviderExternalMethod;
+
+ public ActivityManager(IInterface manager) {
+ this.manager = manager;
+ }
+
+ private Method getGetContentProviderExternalMethod() throws NoSuchMethodException {
+ if (getContentProviderExternalMethod == null) {
+ try {
+ getContentProviderExternalMethod = manager.getClass()
+ .getMethod("getContentProviderExternal", String.class, int.class, IBinder.class, String.class);
+ } catch (NoSuchMethodException e) {
+ // old version
+ getContentProviderExternalMethod = manager.getClass().getMethod("getContentProviderExternal", String.class, int.class, IBinder.class);
+ getContentProviderExternalMethodNewVersion = false;
+ }
+ }
+ return getContentProviderExternalMethod;
+ }
+
+ private Method getRemoveContentProviderExternalMethod() throws NoSuchMethodException {
+ if (removeContentProviderExternalMethod == null) {
+ removeContentProviderExternalMethod = manager.getClass().getMethod("removeContentProviderExternal", String.class, IBinder.class);
+ }
+ return removeContentProviderExternalMethod;
+ }
+
+ private ContentProvider getContentProviderExternal(String name, IBinder token) {
+ try {
+ Method method = getGetContentProviderExternalMethod();
+ Object[] args;
+ if (getContentProviderExternalMethodNewVersion) {
+ // new version
+ args = new Object[]{name, ServiceManager.USER_ID, token, null};
+ } else {
+ // old version
+ args = new Object[]{name, ServiceManager.USER_ID, token};
+ }
+ // ContentProviderHolder providerHolder = getContentProviderExternal(...);
+ Object providerHolder = method.invoke(manager, args);
+ if (providerHolder == null) {
+ return null;
+ }
+ // 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) {
+ Ln.e("Could not invoke method", e);
+ return null;
+ }
+ }
+
+ void removeContentProviderExternal(String name, IBinder token) {
+ try {
+ Method method = getRemoveContentProviderExternalMethod();
+ method.invoke(manager, name, token);
+ } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) {
+ Ln.e("Could not invoke method", e);
+ }
+ }
+
+ public ContentProvider createSettingsProvider() {
+ return getContentProviderExternal("settings", new Binder());
+ }
+}
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 592bdf6b..e25b6e99 100644
--- a/server/src/main/java/com/genymobile/scrcpy/wrappers/ClipboardManager.java
+++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/ClipboardManager.java
@@ -3,6 +3,7 @@ package com.genymobile.scrcpy.wrappers;
import com.genymobile.scrcpy.Ln;
import android.content.ClipData;
+import android.content.IOnPrimaryClipChangedListener;
import android.os.Build;
import android.os.IInterface;
@@ -10,13 +11,10 @@ import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
public class ClipboardManager {
-
- private static final String PACKAGE_NAME = "com.android.shell";
- private static final int USER_ID = 0;
-
private final IInterface manager;
private Method getPrimaryClipMethod;
private Method setPrimaryClipMethod;
+ private Method addPrimaryClipChangedListener;
public ClipboardManager(IInterface manager) {
this.manager = manager;
@@ -46,17 +44,17 @@ public class ClipboardManager {
private static ClipData getPrimaryClip(Method method, IInterface manager) throws InvocationTargetException, IllegalAccessException {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
- return (ClipData) method.invoke(manager, PACKAGE_NAME);
+ return (ClipData) method.invoke(manager, ServiceManager.PACKAGE_NAME);
}
- return (ClipData) method.invoke(manager, PACKAGE_NAME, USER_ID);
+ return (ClipData) method.invoke(manager, ServiceManager.PACKAGE_NAME, ServiceManager.USER_ID);
}
private static void setPrimaryClip(Method method, IInterface manager, ClipData clipData)
throws InvocationTargetException, IllegalAccessException {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
- method.invoke(manager, clipData, PACKAGE_NAME);
+ method.invoke(manager, clipData, ServiceManager.PACKAGE_NAME);
} else {
- method.invoke(manager, clipData, PACKAGE_NAME, USER_ID);
+ method.invoke(manager, clipData, ServiceManager.PACKAGE_NAME, ServiceManager.USER_ID);
}
}
@@ -74,13 +72,48 @@ public class ClipboardManager {
}
}
- public void setText(CharSequence text) {
+ public boolean setText(CharSequence text) {
try {
Method method = getSetPrimaryClipMethod();
ClipData clipData = ClipData.newPlainText(null, text);
setPrimaryClip(method, manager, clipData);
+ return true;
} catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) {
Ln.e("Could not invoke method", e);
+ return false;
+ }
+ }
+
+ private static void addPrimaryClipChangedListener(Method method, IInterface manager, IOnPrimaryClipChangedListener listener)
+ throws InvocationTargetException, IllegalAccessException {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ method.invoke(manager, listener, ServiceManager.PACKAGE_NAME);
+ } else {
+ method.invoke(manager, listener, ServiceManager.PACKAGE_NAME, ServiceManager.USER_ID);
+ }
+ }
+
+ 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 {
+ addPrimaryClipChangedListener = manager.getClass()
+ .getMethod("addPrimaryClipChangedListener", IOnPrimaryClipChangedListener.class, String.class, int.class);
+ }
+ }
+ return addPrimaryClipChangedListener;
+ }
+
+ public boolean addPrimaryClipChangedListener(IOnPrimaryClipChangedListener listener) {
+ try {
+ Method method = getAddPrimaryClipChangedListener();
+ addPrimaryClipChangedListener(method, manager, listener);
+ return true;
+ } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) {
+ Ln.e("Could not invoke method", e);
+ return false;
}
}
}
diff --git a/server/src/main/java/com/genymobile/scrcpy/wrappers/ContentProvider.java b/server/src/main/java/com/genymobile/scrcpy/wrappers/ContentProvider.java
new file mode 100644
index 00000000..387c7a60
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/ContentProvider.java
@@ -0,0 +1,171 @@
+package com.genymobile.scrcpy.wrappers;
+
+import com.genymobile.scrcpy.Ln;
+
+import android.annotation.SuppressLint;
+import android.os.Bundle;
+import android.os.IBinder;
+
+import java.io.Closeable;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+public class ContentProvider implements Closeable {
+
+ public static final String TABLE_SYSTEM = "system";
+ public static final String TABLE_SECURE = "secure";
+ public static final String TABLE_GLOBAL = "global";
+
+ // See android/providerHolder/Settings.java
+ private static final String CALL_METHOD_GET_SYSTEM = "GET_system";
+ private static final String CALL_METHOD_GET_SECURE = "GET_secure";
+ private static final String CALL_METHOD_GET_GLOBAL = "GET_global";
+
+ private static final String CALL_METHOD_PUT_SYSTEM = "PUT_system";
+ private static final String CALL_METHOD_PUT_SECURE = "PUT_secure";
+ private static final String CALL_METHOD_PUT_GLOBAL = "PUT_global";
+
+ private static final String CALL_METHOD_USER_KEY = "_user";
+
+ private static final String NAME_VALUE_TABLE_VALUE = "value";
+
+ private final ActivityManager manager;
+ // android.content.IContentProvider
+ private final Object provider;
+ private final String name;
+ private final IBinder token;
+
+ private Method callMethod;
+ private int callMethodVersion;
+
+ private Object attributionSource;
+
+ ContentProvider(ActivityManager manager, Object provider, String name, IBinder token) {
+ this.manager = manager;
+ this.provider = provider;
+ this.name = name;
+ this.token = token;
+ }
+
+ @SuppressLint("PrivateApi")
+ private Method getCallMethod() throws NoSuchMethodException {
+ if (callMethod == null) {
+ try {
+ Class> attributionSourceClass = Class.forName("android.content.AttributionSource");
+ callMethod = provider.getClass().getMethod("call", attributionSourceClass, String.class, String.class, String.class, Bundle.class);
+ callMethodVersion = 0;
+ } catch (NoSuchMethodException | ClassNotFoundException e0) {
+ // old versions
+ try {
+ callMethod = provider.getClass()
+ .getMethod("call", String.class, String.class, String.class, String.class, String.class, Bundle.class);
+ callMethodVersion = 1;
+ } catch (NoSuchMethodException e1) {
+ try {
+ callMethod = provider.getClass().getMethod("call", String.class, String.class, String.class, String.class, Bundle.class);
+ callMethodVersion = 2;
+ } catch (NoSuchMethodException e2) {
+ callMethod = provider.getClass().getMethod("call", String.class, String.class, String.class, Bundle.class);
+ callMethodVersion = 3;
+ }
+ }
+ }
+ }
+ return callMethod;
+ }
+
+ @SuppressLint("PrivateApi")
+ private Object getAttributionSource()
+ throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
+ if (attributionSource == null) {
+ Class> cl = Class.forName("android.content.AttributionSource$Builder");
+ Object builder = cl.getConstructor(int.class).newInstance(ServiceManager.USER_ID);
+ cl.getDeclaredMethod("setPackageName", String.class).invoke(builder, ServiceManager.PACKAGE_NAME);
+ attributionSource = cl.getDeclaredMethod("build").invoke(builder);
+ }
+
+ return attributionSource;
+ }
+
+ private Bundle call(String callMethod, String arg, Bundle extras) {
+ try {
+ Method method = getCallMethod();
+ Object[] args;
+ switch (callMethodVersion) {
+ case 0:
+ args = new Object[]{getAttributionSource(), "settings", callMethod, arg, extras};
+ break;
+ case 1:
+ args = new Object[]{ServiceManager.PACKAGE_NAME, null, "settings", callMethod, arg, extras};
+ break;
+ case 2:
+ args = new Object[]{ServiceManager.PACKAGE_NAME, "settings", callMethod, arg, extras};
+ break;
+ default:
+ args = new Object[]{ServiceManager.PACKAGE_NAME, callMethod, arg, extras};
+ break;
+ }
+ return (Bundle) method.invoke(provider, args);
+ } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException | ClassNotFoundException | InstantiationException e) {
+ Ln.e("Could not invoke method", e);
+ return null;
+ }
+ }
+
+ public void close() {
+ manager.removeContentProviderExternal(name, token);
+ }
+
+ private static String getGetMethod(String table) {
+ switch (table) {
+ case TABLE_SECURE:
+ return CALL_METHOD_GET_SECURE;
+ case TABLE_SYSTEM:
+ return CALL_METHOD_GET_SYSTEM;
+ case TABLE_GLOBAL:
+ return CALL_METHOD_GET_GLOBAL;
+ default:
+ throw new IllegalArgumentException("Invalid table: " + table);
+ }
+ }
+
+ private static String getPutMethod(String table) {
+ switch (table) {
+ case TABLE_SECURE:
+ return CALL_METHOD_PUT_SECURE;
+ case TABLE_SYSTEM:
+ return CALL_METHOD_PUT_SYSTEM;
+ case TABLE_GLOBAL:
+ return CALL_METHOD_PUT_GLOBAL;
+ default:
+ throw new IllegalArgumentException("Invalid table: " + table);
+ }
+ }
+
+ public String getValue(String table, String key) {
+ String method = getGetMethod(table);
+ Bundle arg = new Bundle();
+ arg.putInt(CALL_METHOD_USER_KEY, ServiceManager.USER_ID);
+ Bundle bundle = call(method, key, arg);
+ if (bundle == null) {
+ return null;
+ }
+ return bundle.getString("value");
+ }
+
+ public void putValue(String table, String key, String value) {
+ String method = getPutMethod(table);
+ Bundle arg = new Bundle();
+ arg.putInt(CALL_METHOD_USER_KEY, ServiceManager.USER_ID);
+ arg.putString(NAME_VALUE_TABLE_VALUE, value);
+ call(method, key, arg);
+ }
+
+ public String getAndPutValue(String table, String key, String value) {
+ String oldValue = getValue(table, key);
+ if (!value.equals(oldValue)) {
+ putValue(table, key, value);
+ }
+ return oldValue;
+ }
+}
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 568afacd..cedb3f47 100644
--- a/server/src/main/java/com/genymobile/scrcpy/wrappers/DisplayManager.java
+++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/DisplayManager.java
@@ -12,15 +12,28 @@ public final class DisplayManager {
this.manager = manager;
}
- public DisplayInfo getDisplayInfo() {
+ public DisplayInfo getDisplayInfo(int displayId) {
try {
- Object displayInfo = manager.getClass().getMethod("getDisplayInfo", int.class).invoke(manager, 0);
+ Object displayInfo = manager.getClass().getMethod("getDisplayInfo", int.class).invoke(manager, displayId);
+ if (displayInfo == null) {
+ return null;
+ }
Class> cls = displayInfo.getClass();
// width and height already take the rotation into account
int width = cls.getDeclaredField("logicalWidth").getInt(displayInfo);
int height = cls.getDeclaredField("logicalHeight").getInt(displayInfo);
int rotation = cls.getDeclaredField("rotation").getInt(displayInfo);
- return new DisplayInfo(new Size(width, height), rotation);
+ 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) {
+ throw new AssertionError(e);
+ }
+ }
+
+ public int[] getDisplayIds() {
+ try {
+ return (int[]) manager.getClass().getMethod("getDisplayIds").invoke(manager);
} catch (Exception e) {
throw new AssertionError(e);
}
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 44fa613b..e17b5a17 100644
--- a/server/src/main/java/com/genymobile/scrcpy/wrappers/InputManager.java
+++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/InputManager.java
@@ -17,6 +17,8 @@ public final class InputManager {
private final IInterface manager;
private Method injectInputEventMethod;
+ private static Method setDisplayIdMethod;
+
public InputManager(IInterface manager) {
this.manager = manager;
}
@@ -37,4 +39,22 @@ public final class InputManager {
return false;
}
}
+
+ private static Method getSetDisplayIdMethod() throws NoSuchMethodException {
+ if (setDisplayIdMethod == null) {
+ setDisplayIdMethod = InputEvent.class.getMethod("setDisplayId", int.class);
+ }
+ return setDisplayIdMethod;
+ }
+
+ public static boolean setDisplayId(InputEvent inputEvent, int displayId) {
+ try {
+ Method method = getSetDisplayIdMethod();
+ method.invoke(inputEvent, displayId);
+ return true;
+ } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) {
+ Ln.e("Cannot associate a display id to the input event", 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 0b625c92..6f4b9c04 100644
--- a/server/src/main/java/com/genymobile/scrcpy/wrappers/ServiceManager.java
+++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/ServiceManager.java
@@ -6,8 +6,12 @@ import android.os.IInterface;
import java.lang.reflect.Method;
-@SuppressLint("PrivateApi")
+@SuppressLint("PrivateApi,DiscouragedPrivateApi")
public final class ServiceManager {
+
+ public static final String PACKAGE_NAME = "com.android.shell";
+ public static final int USER_ID = 0;
+
private final Method getServiceMethod;
private WindowManager windowManager;
@@ -16,6 +20,7 @@ public final class ServiceManager {
private PowerManager powerManager;
private StatusBarManager statusBarManager;
private ClipboardManager clipboardManager;
+ private ActivityManager activityManager;
public ServiceManager() {
try {
@@ -72,8 +77,32 @@ public final class ServiceManager {
public ClipboardManager getClipboardManager() {
if (clipboardManager == null) {
- clipboardManager = new ClipboardManager(getService("clipboard", "android.content.IClipboard"));
+ IInterface clipboard = getService("clipboard", "android.content.IClipboard");
+ if (clipboard == null) {
+ // Some devices have no clipboard manager
+ //
+ //
+ return null;
+ }
+ clipboardManager = new ClipboardManager(clipboard);
}
return clipboardManager;
}
+
+ public 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);
+ }
+ }
+
+ 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 6f8941bd..5b1e5f5e 100644
--- a/server/src/main/java/com/genymobile/scrcpy/wrappers/StatusBarManager.java
+++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/StatusBarManager.java
@@ -11,6 +11,8 @@ public class StatusBarManager {
private final IInterface manager;
private Method expandNotificationsPanelMethod;
+ private Method expandSettingsPanelMethod;
+ private boolean expandSettingsPanelMethodNewVersion = true;
private Method collapsePanelsMethod;
public StatusBarManager(IInterface manager) {
@@ -24,6 +26,20 @@ public class StatusBarManager {
return expandNotificationsPanelMethod;
}
+ private Method getExpandSettingsPanel() throws NoSuchMethodException {
+ if (expandSettingsPanelMethod == null) {
+ try {
+ // Since Android 7: https://android.googlesource.com/platform/frameworks/base.git/+/a9927325eda025504d59bb6594fee8e240d95b01%5E%21/
+ expandSettingsPanelMethod = manager.getClass().getMethod("expandSettingsPanel", String.class);
+ } catch (NoSuchMethodException e) {
+ // old version
+ expandSettingsPanelMethod = manager.getClass().getMethod("expandSettingsPanel");
+ expandSettingsPanelMethodNewVersion = false;
+ }
+ }
+ return expandSettingsPanelMethod;
+ }
+
private Method getCollapsePanelsMethod() throws NoSuchMethodException {
if (collapsePanelsMethod == null) {
collapsePanelsMethod = manager.getClass().getMethod("collapsePanels");
@@ -40,6 +56,21 @@ public class StatusBarManager {
}
}
+ public void expandSettingsPanel() {
+ try {
+ Method method = getExpandSettingsPanel();
+ if (expandSettingsPanelMethodNewVersion) {
+ // new version
+ method.invoke(manager, (Object) null);
+ } else {
+ // old version
+ method.invoke(manager);
+ }
+ } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) {
+ Ln.e("Could not invoke method", e);
+ }
+ }
+
public void collapsePanels() {
try {
Method method = getCollapsePanelsMethod();
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 227bbc85..8fbb860b 100644
--- a/server/src/main/java/com/genymobile/scrcpy/wrappers/SurfaceControl.java
+++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/SurfaceControl.java
@@ -121,12 +121,14 @@ public final class SurfaceControl {
return setDisplayPowerModeMethod;
}
- public static void setDisplayPowerMode(IBinder displayToken, int mode) {
+ public static boolean setDisplayPowerMode(IBinder displayToken, int mode) {
try {
Method method = getSetDisplayPowerModeMethod();
method.invoke(null, displayToken, mode);
+ return true;
} catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException 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 cc687cd5..faa366a5 100644
--- a/server/src/main/java/com/genymobile/scrcpy/wrappers/WindowManager.java
+++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/WindowManager.java
@@ -93,13 +93,13 @@ public final class WindowManager {
}
}
- public void registerRotationWatcher(IRotationWatcher rotationWatcher) {
+ public void registerRotationWatcher(IRotationWatcher rotationWatcher, int displayId) {
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, 0);
+ cls.getMethod("watchRotation", IRotationWatcher.class, int.class).invoke(manager, rotationWatcher, displayId);
} catch (NoSuchMethodException e) {
// old version
cls.getMethod("watchRotation", IRotationWatcher.class).invoke(manager, rotationWatcher);
diff --git a/server/src/test/java/com/genymobile/scrcpy/CodecOptionsTest.java b/server/src/test/java/com/genymobile/scrcpy/CodecOptionsTest.java
new file mode 100644
index 00000000..ad802258
--- /dev/null
+++ b/server/src/test/java/com/genymobile/scrcpy/CodecOptionsTest.java
@@ -0,0 +1,114 @@
+package com.genymobile.scrcpy;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.util.List;
+
+public class CodecOptionsTest {
+
+ @Test
+ public void testIntegerImplicit() {
+ List codecOptions = CodecOption.parse("some_key=5");
+
+ Assert.assertEquals(1, codecOptions.size());
+
+ CodecOption option = codecOptions.get(0);
+ Assert.assertEquals("some_key", option.getKey());
+ Assert.assertEquals(5, option.getValue());
+ }
+
+ @Test
+ public void testInteger() {
+ List codecOptions = CodecOption.parse("some_key:int=5");
+
+ Assert.assertEquals(1, codecOptions.size());
+
+ CodecOption option = codecOptions.get(0);
+ Assert.assertEquals("some_key", option.getKey());
+ Assert.assertTrue(option.getValue() instanceof Integer);
+ Assert.assertEquals(5, option.getValue());
+ }
+
+ @Test
+ public void testLong() {
+ List codecOptions = CodecOption.parse("some_key:long=5");
+
+ Assert.assertEquals(1, codecOptions.size());
+
+ CodecOption option = codecOptions.get(0);
+ Assert.assertEquals("some_key", option.getKey());
+ Assert.assertTrue(option.getValue() instanceof Long);
+ Assert.assertEquals(5L, option.getValue());
+ }
+
+ @Test
+ public void testFloat() {
+ List codecOptions = CodecOption.parse("some_key:float=4.5");
+
+ Assert.assertEquals(1, codecOptions.size());
+
+ CodecOption option = codecOptions.get(0);
+ Assert.assertEquals("some_key", option.getKey());
+ Assert.assertTrue(option.getValue() instanceof Float);
+ Assert.assertEquals(4.5f, option.getValue());
+ }
+
+ @Test
+ public void testString() {
+ List codecOptions = CodecOption.parse("some_key:string=some_value");
+
+ Assert.assertEquals(1, codecOptions.size());
+
+ CodecOption option = codecOptions.get(0);
+ Assert.assertEquals("some_key", option.getKey());
+ Assert.assertTrue(option.getValue() instanceof String);
+ Assert.assertEquals("some_value", option.getValue());
+ }
+
+ @Test
+ public void testStringEscaped() {
+ List codecOptions = CodecOption.parse("some_key:string=warning\\,this_is_not=a_new_key");
+
+ Assert.assertEquals(1, codecOptions.size());
+
+ CodecOption option = codecOptions.get(0);
+ Assert.assertEquals("some_key", option.getKey());
+ Assert.assertTrue(option.getValue() instanceof String);
+ Assert.assertEquals("warning,this_is_not=a_new_key", option.getValue());
+ }
+
+ @Test
+ public void testList() {
+ List codecOptions = CodecOption.parse("a=1,b:int=2,c:long=3,d:float=4.5,e:string=a\\,b=c");
+
+ Assert.assertEquals(5, codecOptions.size());
+
+ CodecOption option;
+
+ option = codecOptions.get(0);
+ Assert.assertEquals("a", option.getKey());
+ Assert.assertTrue(option.getValue() instanceof Integer);
+ Assert.assertEquals(1, option.getValue());
+
+ option = codecOptions.get(1);
+ Assert.assertEquals("b", option.getKey());
+ Assert.assertTrue(option.getValue() instanceof Integer);
+ Assert.assertEquals(2, option.getValue());
+
+ option = codecOptions.get(2);
+ Assert.assertEquals("c", option.getKey());
+ Assert.assertTrue(option.getValue() instanceof Long);
+ Assert.assertEquals(3L, option.getValue());
+
+ option = codecOptions.get(3);
+ Assert.assertEquals("d", option.getKey());
+ Assert.assertTrue(option.getValue() instanceof Float);
+ Assert.assertEquals(4.5f, option.getValue());
+
+ option = codecOptions.get(4);
+ Assert.assertEquals("e", option.getKey());
+ Assert.assertTrue(option.getValue() instanceof String);
+ Assert.assertEquals("a,b=c", option.getValue());
+ }
+}
diff --git a/server/src/test/java/com/genymobile/scrcpy/ControlMessageReaderTest.java b/server/src/test/java/com/genymobile/scrcpy/ControlMessageReaderTest.java
index 5e663bb9..da568486 100644
--- a/server/src/test/java/com/genymobile/scrcpy/ControlMessageReaderTest.java
+++ b/server/src/test/java/com/genymobile/scrcpy/ControlMessageReaderTest.java
@@ -25,15 +25,20 @@ public class ControlMessageReaderTest {
dos.writeByte(ControlMessage.TYPE_INJECT_KEYCODE);
dos.writeByte(KeyEvent.ACTION_UP);
dos.writeInt(KeyEvent.KEYCODE_ENTER);
+ dos.writeInt(5); // repeat
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();
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());
}
@@ -45,7 +50,7 @@ public class ControlMessageReaderTest {
DataOutputStream dos = new DataOutputStream(bos);
dos.writeByte(ControlMessage.TYPE_INJECT_TEXT);
byte[] text = "testé".getBytes(StandardCharsets.UTF_8);
- dos.writeShort(text.length);
+ dos.writeInt(text.length);
dos.write(text);
byte[] packet = bos.toByteArray();
@@ -63,9 +68,9 @@ public class ControlMessageReaderTest {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(bos);
dos.writeByte(ControlMessage.TYPE_INJECT_TEXT);
- byte[] text = new byte[ControlMessageReader.TEXT_MAX_LENGTH];
+ byte[] text = new byte[ControlMessageReader.INJECT_TEXT_MAX_LENGTH];
Arrays.fill(text, (byte) 'a');
- dos.writeShort(text.length);
+ dos.writeInt(text.length);
dos.write(text);
byte[] packet = bos.toByteArray();
@@ -77,7 +82,6 @@ public class ControlMessageReaderTest {
}
@Test
- @SuppressWarnings("checkstyle:MagicNumber")
public void testParseTouchEvent() throws IOException {
ControlMessageReader reader = new ControlMessageReader();
@@ -95,6 +99,9 @@ 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();
@@ -110,7 +117,6 @@ public class ControlMessageReaderTest {
}
@Test
- @SuppressWarnings("checkstyle:MagicNumber")
public void testParseScrollEvent() throws IOException {
ControlMessageReader reader = new ControlMessageReader();
@@ -126,6 +132,9 @@ public class ControlMessageReaderTest {
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();
@@ -145,6 +154,7 @@ public class ControlMessageReaderTest {
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();
@@ -152,6 +162,7 @@ public class ControlMessageReaderTest {
ControlMessage event = reader.next();
Assert.assertEquals(ControlMessage.TYPE_BACK_OR_SCREEN_ON, event.getType());
+ Assert.assertEquals(KeyEvent.ACTION_UP, event.getAction());
}
@Test
@@ -171,19 +182,35 @@ public class ControlMessageReaderTest {
}
@Test
- public void testParseCollapseNotificationPanelEvent() throws IOException {
+ public void testParseExpandSettingsPanelEvent() throws IOException {
ControlMessageReader reader = new ControlMessageReader();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(bos);
- dos.writeByte(ControlMessage.TYPE_COLLAPSE_NOTIFICATION_PANEL);
+ dos.writeByte(ControlMessage.TYPE_EXPAND_SETTINGS_PANEL);
byte[] packet = bos.toByteArray();
reader.readFrom(new ByteArrayInputStream(packet));
ControlMessage event = reader.next();
- Assert.assertEquals(ControlMessage.TYPE_COLLAPSE_NOTIFICATION_PANEL, event.getType());
+ Assert.assertEquals(ControlMessage.TYPE_EXPAND_SETTINGS_PANEL, event.getType());
+ }
+
+ @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();
+
+ Assert.assertEquals(ControlMessage.TYPE_COLLAPSE_PANELS, event.getType());
}
@Test
@@ -209,8 +236,9 @@ public class ControlMessageReaderTest {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(bos);
dos.writeByte(ControlMessage.TYPE_SET_CLIPBOARD);
+ dos.writeByte(1); // paste
byte[] text = "testé".getBytes(StandardCharsets.UTF_8);
- dos.writeShort(text.length);
+ dos.writeInt(text.length);
dos.write(text);
byte[] packet = bos.toByteArray();
@@ -220,6 +248,33 @@ public class ControlMessageReaderTest {
Assert.assertEquals(ControlMessage.TYPE_SET_CLIPBOARD, event.getType());
Assert.assertEquals("testé", event.getText());
+ Assert.assertTrue(event.getPaste());
+ }
+
+ @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);
+
+ byte[] rawText = new byte[ControlMessageReader.CLIPBOARD_TEXT_MAX_LENGTH];
+ dos.writeByte(1); // paste
+ Arrays.fill(rawText, (byte) 'a');
+ String text = new String(rawText, 0, rawText.length);
+
+ dos.writeInt(rawText.length);
+ dos.write(rawText);
+
+ byte[] packet = bos.toByteArray();
+
+ reader.readFrom(new ByteArrayInputStream(packet));
+ ControlMessage event = reader.next();
+
+ Assert.assertEquals(ControlMessage.TYPE_SET_CLIPBOARD, event.getType());
+ Assert.assertEquals(text, event.getText());
+ Assert.assertTrue(event.getPaste());
}
@Test
@@ -233,6 +288,9 @@ public class ControlMessageReaderTest {
byte[] packet = bos.toByteArray();
+ // The message type (1 byte) does not count
+ Assert.assertEquals(ControlMessageReader.SET_SCREEN_POWER_MODE_PAYLOAD_LENGTH, packet.length - 1);
+
reader.readFrom(new ByteArrayInputStream(packet));
ControlMessage event = reader.next();
@@ -266,11 +324,13 @@ public class ControlMessageReaderTest {
dos.writeByte(ControlMessage.TYPE_INJECT_KEYCODE);
dos.writeByte(KeyEvent.ACTION_UP);
dos.writeInt(KeyEvent.KEYCODE_ENTER);
+ dos.writeInt(0); // repeat
dos.writeInt(KeyEvent.META_CTRL_ON);
dos.writeByte(ControlMessage.TYPE_INJECT_KEYCODE);
dos.writeByte(MotionEvent.ACTION_DOWN);
dos.writeInt(MotionEvent.BUTTON_PRIMARY);
+ dos.writeInt(1); // repeat
dos.writeInt(KeyEvent.META_CTRL_ON);
byte[] packet = bos.toByteArray();
@@ -280,12 +340,14 @@ public class ControlMessageReaderTest {
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();
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());
}
@@ -299,6 +361,7 @@ public class ControlMessageReaderTest {
dos.writeByte(ControlMessage.TYPE_INJECT_KEYCODE);
dos.writeByte(KeyEvent.ACTION_UP);
dos.writeInt(KeyEvent.KEYCODE_ENTER);
+ dos.writeInt(4); // repeat
dos.writeInt(KeyEvent.META_CTRL_ON);
dos.writeByte(ControlMessage.TYPE_INJECT_KEYCODE);
@@ -311,6 +374,7 @@ public class ControlMessageReaderTest {
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();
@@ -318,6 +382,7 @@ public class ControlMessageReaderTest {
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));
@@ -327,6 +392,7 @@ public class ControlMessageReaderTest {
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());
}
}
diff --git a/server/src/test/java/com/genymobile/scrcpy/DeviceMessageWriterTest.java b/server/src/test/java/com/genymobile/scrcpy/DeviceMessageWriterTest.java
index df12f647..88bf2af9 100644
--- a/server/src/test/java/com/genymobile/scrcpy/DeviceMessageWriterTest.java
+++ b/server/src/test/java/com/genymobile/scrcpy/DeviceMessageWriterTest.java
@@ -19,7 +19,7 @@ public class DeviceMessageWriterTest {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(bos);
dos.writeByte(DeviceMessage.TYPE_CLIPBOARD);
- dos.writeShort(data.length);
+ dos.writeInt(data.length);
dos.write(data);
byte[] expected = bos.toByteArray();
diff --git a/server/src/test/java/com/genymobile/scrcpy/StringUtilsTest.java b/server/src/test/java/com/genymobile/scrcpy/StringUtilsTest.java
index 7d89ee64..89799c5e 100644
--- a/server/src/test/java/com/genymobile/scrcpy/StringUtilsTest.java
+++ b/server/src/test/java/com/genymobile/scrcpy/StringUtilsTest.java
@@ -8,7 +8,6 @@ import java.nio.charset.StandardCharsets;
public class StringUtilsTest {
@Test
- @SuppressWarnings("checkstyle:MagicNumber")
public void testUtf8Truncate() {
String s = "aÉbÔc";
byte[] utf8 = s.getBytes(StandardCharsets.UTF_8);