From fc8f465ea26600db569ffcdfff43b176787eb1d1 Mon Sep 17 00:00:00 2001 From: rankun Date: Sun, 14 Jun 2020 14:13:50 +0800 Subject: [PATCH] feat: sync scrcpy --- QtScrcpy/QtScrcpy.pro | 5 + QtScrcpy/device/controller/controller.cpp | 2 +- .../controller/inputconvert/controlmsg.cpp | 8 +- .../controller/inputconvert/controlmsg.h | 7 +- QtScrcpy/device/device.cpp | 2 + QtScrcpy/device/device.h | 2 + QtScrcpy/device/server/server.cpp | 37 ++- QtScrcpy/device/server/server.h | 18 +- QtScrcpy/device/ui/videoform.cpp | 9 +- QtScrcpy/dialog.cpp | 9 + QtScrcpy/dialog.ui | 50 ++++- QtScrcpy/main.cpp | 43 +++- QtScrcpy/res/i18n/QtScrcpy_en.qm | Bin 4031 -> 4238 bytes QtScrcpy/res/i18n/QtScrcpy_en.ts | 75 ++++--- QtScrcpy/res/i18n/QtScrcpy_zh.qm | Bin 3058 -> 3221 bytes QtScrcpy/res/i18n/QtScrcpy_zh.ts | 75 ++++--- QtScrcpy/util/config.cpp | 14 +- QtScrcpy/util/config.h | 1 + config/config.ini | 5 +- docs/DEVELOP.md | 13 +- docs/FAQ.md | 28 ++- docs/TODO.md | 11 +- .../IOnPrimaryClipChangedListener.aidl | 24 ++ .../java/com/genymobile/scrcpy/CleanUp.java | 77 +++++++ .../com/genymobile/scrcpy/CodecOption.java | 112 ++++++++++ .../com/genymobile/scrcpy/ControlMessage.java | 12 +- .../scrcpy/ControlMessageReader.java | 26 ++- .../com/genymobile/scrcpy/Controller.java | 83 ++++--- .../genymobile/scrcpy/DesktopConnection.java | 1 - .../java/com/genymobile/scrcpy/Device.java | 210 +++++++++++------- .../scrcpy/DeviceMessageWriter.java | 1 - .../com/genymobile/scrcpy/DisplayInfo.java | 22 +- .../scrcpy/InvalidDisplayIdException.java | 21 ++ .../main/java/com/genymobile/scrcpy/Ln.java | 15 +- .../java/com/genymobile/scrcpy/Options.java | 54 +++++ .../java/com/genymobile/scrcpy/Position.java | 13 ++ .../com/genymobile/scrcpy/ScreenEncoder.java | 88 +++++--- .../com/genymobile/scrcpy/ScreenInfo.java | 154 ++++++++++++- .../java/com/genymobile/scrcpy/Server.java | 114 +++++++--- .../com/genymobile/scrcpy/StringUtils.java | 1 - .../com/genymobile/scrcpy/Workarounds.java | 4 +- .../scrcpy/wrappers/ActivityManager.java | 87 ++++++++ .../scrcpy/wrappers/ClipboardManager.java | 51 ++++- .../scrcpy/wrappers/ContentProvider.java | 132 +++++++++++ .../scrcpy/wrappers/DisplayManager.java | 19 +- .../scrcpy/wrappers/InputManager.java | 20 ++ .../scrcpy/wrappers/ServiceManager.java | 24 +- .../scrcpy/wrappers/SurfaceControl.java | 4 +- .../scrcpy/wrappers/WindowManager.java | 4 +- third_party/scrcpy-server | Bin 26142 -> 33142 bytes 50 files changed, 1480 insertions(+), 307 deletions(-) create mode 100644 server/src/main/aidl/android/content/IOnPrimaryClipChangedListener.aidl create mode 100644 server/src/main/java/com/genymobile/scrcpy/CleanUp.java create mode 100644 server/src/main/java/com/genymobile/scrcpy/CodecOption.java create mode 100644 server/src/main/java/com/genymobile/scrcpy/InvalidDisplayIdException.java create mode 100644 server/src/main/java/com/genymobile/scrcpy/wrappers/ActivityManager.java create mode 100644 server/src/main/java/com/genymobile/scrcpy/wrappers/ContentProvider.java diff --git a/QtScrcpy/QtScrcpy.pro b/QtScrcpy/QtScrcpy.pro index c2ef405..c947acc 100644 --- a/QtScrcpy/QtScrcpy.pro +++ b/QtScrcpy/QtScrcpy.pro @@ -32,6 +32,11 @@ msvc{ *g++*: QMAKE_CXXFLAGS += -Werror *msvc*: QMAKE_CXXFLAGS += /WX /wd4566 +# run a server debugger and wait for a client to be attached +# DEFINES += SERVER_DEBUGGER +# select the debugger method ('old' for Android < 9, 'new' for Android >= 9) +# DEFINES += SERVER_DEBUGGER_METHOD_NEW + # 源码 SOURCES += \ main.cpp \ diff --git a/QtScrcpy/device/controller/controller.cpp b/QtScrcpy/device/controller/controller.cpp index 4f45854..d24c69f 100644 --- a/QtScrcpy/device/controller/controller.cpp +++ b/QtScrcpy/device/controller/controller.cpp @@ -135,7 +135,7 @@ void Controller::onSetDeviceClipboard() if (!controlMsg) { return; } - controlMsg->setSetClipboardMsgData(text); + controlMsg->setSetClipboardMsgData(text, true); postControlMsg(controlMsg); } diff --git a/QtScrcpy/device/controller/inputconvert/controlmsg.cpp b/QtScrcpy/device/controller/inputconvert/controlmsg.cpp index 021b097..27e56eb 100644 --- a/QtScrcpy/device/controller/inputconvert/controlmsg.cpp +++ b/QtScrcpy/device/controller/inputconvert/controlmsg.cpp @@ -29,9 +29,9 @@ void ControlMsg::setInjectKeycodeMsgData(AndroidKeyeventAction action, AndroidKe void ControlMsg::setInjectTextMsgData(QString &text) { // write length (2 byte) + string (non nul-terminated) - if (CONTROL_MSG_TEXT_MAX_LENGTH < text.length()) { + if (CONTROL_MSG_INJECT_TEXT_MAX_LENGTH < text.length()) { // injecting a text takes time, so limit the text length - text = text.left(CONTROL_MSG_TEXT_MAX_LENGTH); + text = text.left(CONTROL_MSG_INJECT_TEXT_MAX_LENGTH); } QByteArray tmp = text.toUtf8(); m_data.injectText.text = new char[tmp.length() + 1]; @@ -55,7 +55,7 @@ void ControlMsg::setInjectScrollMsgData(QRect position, qint32 hScroll, qint32 v m_data.injectScroll.vScroll = vScroll; } -void ControlMsg::setSetClipboardMsgData(QString &text) +void ControlMsg::setSetClipboardMsgData(QString &text, bool paste) { if (text.isEmpty()) { return; @@ -68,6 +68,7 @@ void ControlMsg::setSetClipboardMsgData(QString &text) m_data.setClipboard.text = new char[tmp.length() + 1]; memcpy(m_data.setClipboard.text, tmp.data(), tmp.length()); m_data.setClipboard.text[tmp.length()] = '\0'; + m_data.setClipboard.paste = paste; } void ControlMsg::setSetScreenPowerModeData(ControlMsg::ScreenPowerMode mode) @@ -124,6 +125,7 @@ QByteArray ControlMsg::serializeData() BufferUtil::write32(buffer, m_data.injectScroll.vScroll); break; case CMT_SET_CLIPBOARD: + buffer.putChar(!!m_data.setClipboard.paste); BufferUtil::write16(buffer, static_cast(strlen(m_data.setClipboard.text))); buffer.write(m_data.setClipboard.text, strlen(m_data.setClipboard.text)); break; diff --git a/QtScrcpy/device/controller/inputconvert/controlmsg.h b/QtScrcpy/device/controller/inputconvert/controlmsg.h index 73f4669..380247d 100644 --- a/QtScrcpy/device/controller/inputconvert/controlmsg.h +++ b/QtScrcpy/device/controller/inputconvert/controlmsg.h @@ -9,8 +9,8 @@ #include "keycodes.h" #include "qscrcpyevent.h" -#define CONTROL_MSG_TEXT_MAX_LENGTH 300 -#define CONTROL_MSG_CLIPBOARD_TEXT_MAX_LENGTH 4093 +#define CONTROL_MSG_INJECT_TEXT_MAX_LENGTH 300 +#define CONTROL_MSG_CLIPBOARD_TEXT_MAX_LENGTH 4092 #define POINTER_ID_MOUSE static_cast(-1) // ControlMsg class ControlMsg : public QScrcpyEvent @@ -48,7 +48,7 @@ public: // position action动作对应的位置 void setInjectTouchMsgData(quint64 id, AndroidMotioneventAction action, AndroidMotioneventButtons buttons, QRect position, float pressure); void setInjectScrollMsgData(QRect position, qint32 hScroll, qint32 vScroll); - void setSetClipboardMsgData(QString &text); + void setSetClipboardMsgData(QString &text, bool paste); void setSetScreenPowerModeData(ControlMsg::ScreenPowerMode mode); QByteArray serializeData(); @@ -90,6 +90,7 @@ private: struct { char *text = Q_NULLPTR; + bool paste = true; } setClipboard; struct { diff --git a/QtScrcpy/device/device.cpp b/QtScrcpy/device/device.cpp index bbc5c3b..4e19d00 100644 --- a/QtScrcpy/device/device.cpp +++ b/QtScrcpy/device/device.cpp @@ -303,6 +303,8 @@ void Device::startServer() params.crop = "-"; params.control = true; params.useReverse = m_params.useReverse; + params.lockVideoOrientation = m_params.lockVideoOrientation; + params.stayAwake = m_params.stayAwake; m_server->start(params); }); } diff --git a/QtScrcpy/device/device.h b/QtScrcpy/device/device.h index de8b109..f574493 100644 --- a/QtScrcpy/device/device.h +++ b/QtScrcpy/device/device.h @@ -35,6 +35,8 @@ public: bool display = true; // 是否显示画面(或者仅仅后台录制) QString gameScript = ""; // 游戏映射脚本 bool renderExpiredFrames = false; // 是否渲染延迟视频帧 + int lockVideoOrientation = -1; // 是否锁定视频方向 + int stayAwake = false; // 是否保持唤醒 }; enum GroupControlState { diff --git a/QtScrcpy/device/server/server.cpp b/QtScrcpy/device/server/server.cpp index 2886eba..ec51093 100644 --- a/QtScrcpy/device/server/server.cpp +++ b/QtScrcpy/device/server/server.cpp @@ -124,12 +124,29 @@ bool Server::execute() args << "shell"; args << QString("CLASSPATH=%1").arg(Config::getInstance().getServerPath()); args << "app_process"; - args << "/"; // unused; + +#ifdef SERVER_DEBUGGER +#define SERVER_DEBUGGER_PORT "5005" + + args << +#ifdef SERVER_DEBUGGER_METHOD_NEW + /* Android 9 and above */ + "-XjdwpProvider:internal -XjdwpOptions:transport=dt_socket,suspend=y,server=y,address=" +#else + /* Android 8 and below */ + "-agentlib:jdwp=transport=dt_socket,suspend=y,server=y,address=" +#endif + SERVER_DEBUGGER_PORT, +#endif + + args << "/"; // unused; args << "com.genymobile.scrcpy.Server"; args << Config::getInstance().getServerVersion(); + args << Config::getInstance().getLogLevel(); args << QString::number(m_params.maxSize); args << QString::number(m_params.bitRate); args << QString::number(m_params.maxFps); + args << QString::number(m_params.lockVideoOrientation); args << (m_tunnelForward ? "true" : "false"); if (m_params.crop.isEmpty()) { args << "-"; @@ -138,6 +155,24 @@ bool Server::execute() } args << "true"; // always send frame meta (packet boundaries + timestamp) args << (m_params.control ? "true" : "false"); + args << "0"; // display id + args << "false"; // show touch + args << (m_params.stayAwake ? "true" : "false"); // stay awake + // code option + // https://github.com/Genymobile/scrcpy/commit/080a4ee3654a9b7e96c8ffe37474b5c21c02852a + // + args << "-"; + +#ifdef SERVER_DEBUGGER + qInfo("Server debugger waiting for a client on device port " SERVER_DEBUGGER_PORT "..."); + // From the computer, run + // adb forward tcp:5005 tcp:5005 + // Then, from Android Studio: Run > Debug > Edit configurations... + // On the left, click on '+', "Remote", with: + // Host: localhost + // Port: 5005 + // Then click on "Debug" +#endif // adb -s P7C0218510000537 shell CLASSPATH=/data/local/tmp/scrcpy-server app_process / com.genymobile.scrcpy.Server 0 8000000 false // mark: crop input format: "width:height:x:y" or - for no crop, for example: "100:200:0:0" diff --git a/QtScrcpy/device/server/server.h b/QtScrcpy/device/server/server.h index d45a573..496398b 100644 --- a/QtScrcpy/device/server/server.h +++ b/QtScrcpy/device/server/server.h @@ -26,14 +26,16 @@ class Server : public QObject public: struct ServerParams { - QString serial = ""; // 设备序列号 - quint16 localPort = 27183; // reverse时本地监听端口 - quint16 maxSize = 720; // 视频分辨率 - quint32 bitRate = 8000000; // 视频比特率 - quint32 maxFps = 60; // 视频最大帧率 - QString crop = "-"; // 视频裁剪 - bool control = true; // 安卓端是否接收键鼠控制 - bool useReverse = true; // true:先使用adb reverse,失败后自动使用adb forward;false:直接使用adb forward + QString serial = ""; // 设备序列号 + quint16 localPort = 27183; // reverse时本地监听端口 + quint16 maxSize = 720; // 视频分辨率 + quint32 bitRate = 8000000; // 视频比特率 + quint32 maxFps = 60; // 视频最大帧率 + QString crop = "-"; // 视频裁剪 + bool control = true; // 安卓端是否接收键鼠控制 + bool useReverse = true; // true:先使用adb reverse,失败后自动使用adb forward;false:直接使用adb forward + int lockVideoOrientation = -1; // 是否锁定视频方向 + int stayAwake = false; // 是否保持唤醒 }; explicit Server(QObject *parent = nullptr); diff --git a/QtScrcpy/device/ui/videoform.cpp b/QtScrcpy/device/ui/videoform.cpp index a833cd7..001d037 100644 --- a/QtScrcpy/device/ui/videoform.cpp +++ b/QtScrcpy/device/ui/videoform.cpp @@ -400,6 +400,11 @@ void VideoForm::updateShowSize(const QSize &newSize) if (isFullScreen() && m_device) { emit m_device->switchFullScreen(); } + + if (isMaximized()) { + showNormal(); + } + if (m_skin) { QMargins m = getMargins(vertical); showSize.setWidth(showSize.width() + m.left() + m.right()); @@ -570,7 +575,9 @@ void VideoForm::mouseMoveEvent(QMouseEvent *event) void VideoForm::mouseDoubleClickEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton && !m_videoWidget->geometry().contains(event->pos())) { - removeBlackRect(); + if (!isMaximized()) { + removeBlackRect(); + } } if (event->button() == Qt::RightButton && m_device) { diff --git a/QtScrcpy/dialog.cpp b/QtScrcpy/dialog.cpp index 3a716f5..2417e64 100644 --- a/QtScrcpy/dialog.cpp +++ b/QtScrcpy/dialog.cpp @@ -109,6 +109,13 @@ void Dialog::initUI() ui->formatBox->addItem("mkv"); ui->formatBox->setCurrentIndex(Config::getInstance().getRecordFormatIndex()); + ui->lockOrientationBox->addItem(tr("no lock")); + ui->lockOrientationBox->addItem("0"); + ui->lockOrientationBox->addItem("90"); + ui->lockOrientationBox->addItem("180"); + ui->lockOrientationBox->addItem("270"); + ui->lockOrientationBox->setCurrentIndex(0); + ui->recordPathEdt->setText(Config::getInstance().getRecordPath()); ui->framelessCheck->setChecked(Config::getInstance().getFramelessWindow()); @@ -186,6 +193,8 @@ void Dialog::on_startServerBtn_clicked() params.useReverse = ui->useReverseCheck->isChecked(); params.display = !ui->notDisplayCheck->isChecked(); params.renderExpiredFrames = Config::getInstance().getRenderExpiredFrames(); + params.lockVideoOrientation = ui->lockOrientationBox->currentIndex() - 1; + params.stayAwake = ui->stayAwakeCheck->isChecked(); m_deviceManage.connectDevice(params); diff --git a/QtScrcpy/dialog.ui b/QtScrcpy/dialog.ui index 05c7eef..ae80cab 100644 --- a/QtScrcpy/dialog.ui +++ b/QtScrcpy/dialog.ui @@ -7,7 +7,7 @@ 0 0 420 - 492 + 517 @@ -106,6 +106,47 @@ + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + lock orientation: + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + @@ -290,6 +331,13 @@ + + + + stay awake + + + diff --git a/QtScrcpy/main.cpp b/QtScrcpy/main.cpp index 1fe6794..2638961 100644 --- a/QtScrcpy/main.cpp +++ b/QtScrcpy/main.cpp @@ -17,6 +17,9 @@ static QtMessageHandler g_oldMessageHandler = Q_NULLPTR; void myMessageOutput(QtMsgType type, const QMessageLogContext &context, const QString &msg); void installTranslator(); +static QtMsgType g_msgType = QtInfoMsg; +QtMsgType covertLogLevel(const QString &logLevel); + int main(int argc, char *argv[]) { // set env @@ -38,6 +41,8 @@ int main(int argc, char *argv[]) qputenv("QTSCRCPY_KEYMAP_PATH", "../../../keymap"); #endif + g_msgType = covertLogLevel(Config::getInstance().getLogLevel()); + // set on QApplication before int opengl = Config::getInstance().getDesktopOpenGL(); if (0 == opengl) { @@ -136,17 +141,53 @@ void installTranslator() qApp->installTranslator(&translator); } +QtMsgType covertLogLevel(const QString &logLevel) +{ + if ("debug" == logLevel) { + return QtDebugMsg; + } + + if ("info" == logLevel) { + return QtInfoMsg; + } + + if ("warn" == logLevel) { + return QtWarningMsg; + } + + if ("error" == logLevel) { + return QtCriticalMsg; + } + +#ifdef QT_NO_DEBUG + return QtInfoMsg; +#else + return QtDebugMsg; +#endif +} + void myMessageOutput(QtMsgType type, const QMessageLogContext &context, const QString &msg) { if (g_oldMessageHandler) { g_oldMessageHandler(type, context, msg); } - if (QtDebugMsg < type) { + // qt log info big than warning? + float fLogLevel = 1.0f * g_msgType; + if (QtInfoMsg == g_msgType) { + fLogLevel = QtDebugMsg + 0.5f; + } + float fLogLevel2 = 1.0f * type; + if (QtInfoMsg == type) { + fLogLevel2 = QtDebugMsg + 0.5f; + } + + if (fLogLevel <= fLogLevel2) { if (g_mainDlg && g_mainDlg->isVisible() && !g_mainDlg->filterLog(msg)) { g_mainDlg->outLog(msg); } } + if (QtFatalMsg == type) { //abort(); } diff --git a/QtScrcpy/res/i18n/QtScrcpy_en.qm b/QtScrcpy/res/i18n/QtScrcpy_en.qm index 62f5dce1ad3e9dfea8e1591940c906de5a7ae323..9022ff731f0c660c0f09547455660407884bb3df 100644 GIT binary patch delta 605 zcmdll-={c1q<#Yf>$VsM2JX2GY~M^77^*z>7#Mh+*w^&#U|`@r#{Nv}5d#Cq3ij`waSRMRZ5%d^$_xz5pE=@; zPBSnt+~D}fEW=RGz$(w_V3f+h!107L@d6721N(Z;(ugk%3``fei`A@rWF2NmuE3Bum~39p%`pzC4&M(B11VtB11Mq XDqJmBaYtq<#hi>$VsM2CioeY~M^77nIq2VGy?;}4UT`z zG7JnX3ppK(QW@$QICMD^FR(B$up4rgMtoslV7kC1w@QM6f$b2N!?Aq~3~Ux$Rn1%s z3@q8)2efZ8FmQKspV;rlz`)kY;}jRdz`**Mr_THe0|U=4p4V%RGB7Y7=6Qd#i-CcY ziBH-iNPuVY|f&E(I_dd$GU_K!bvg_I%#1FJND?dt#r2BsAL tU6*GuFtA+VzuUQ$fq_vZlYxPcZSyI{e3r@WY-XF^vT-nP*5gWG0|1v~Zwvqc diff --git a/QtScrcpy/res/i18n/QtScrcpy_en.ts b/QtScrcpy/res/i18n/QtScrcpy_en.ts index 4b9df55..9c131b5 100644 --- a/QtScrcpy/res/i18n/QtScrcpy_en.ts +++ b/QtScrcpy/res/i18n/QtScrcpy_en.ts @@ -49,17 +49,17 @@ Dialog - + Wireless Wireless - + wireless connect wireless connect - + wireless disconnect wireless disconnect @@ -69,13 +69,13 @@ Start Config - + record save path: record save path: - - + + select path select path @@ -85,47 +85,57 @@ record format: - + record screen record screen - + frameless frameless - + + lock orientation: + lock orientation: + + + show fps show fps - + + stay awake + stay awake + + + stop all server stop all server - + adb command: adb command: - + terminate terminate - + execute execute - + clear clear - + reverse connection reverse connection @@ -134,17 +144,17 @@ auto enable - + background record background record - + screen-off screen-off - + apply apply @@ -154,37 +164,37 @@ max size: - + always on top always on top - + refresh script refresh script - + get device IP get device IP - + USB line USB line - + stop server stop server - + start server start server - + device serial: device serial: @@ -198,20 +208,25 @@ bit rate: - + start adbd start adbd - + refresh devices refresh devices - + original original + + + no lock + no lock + QObject @@ -226,7 +241,7 @@ You can download it at the following address: This software is completely open source and free.\nStrictly used for illegal purposes, or at your own risk.\nYou can download it at the following address: - + This software is completely open source and free. Strictly used for illegal purposes, or at your own risk. You can download it at the following address: This software is completely open source and free. Strictly used for illegal purposes, or at your own risk. You can download it at the following address: @@ -322,7 +337,7 @@ You can download it at the following address: file transfer failed - + file does not exist file does not exist diff --git a/QtScrcpy/res/i18n/QtScrcpy_zh.qm b/QtScrcpy/res/i18n/QtScrcpy_zh.qm index 2a6e5c8bb6f8a3d0edf945302a25bbc51d5c51a6..3c6f972cf02d4e1179df67440f7c501b07e3c8fa 100644 GIT binary patch delta 541 zcmew)K2>spNc{!|)@?Bi3>=XRY~M^77&vVh*lj`?7&wy|WHwtcFmN$4n29c8U|{27 z$SAjDU|{^mkfq(kz`)eTP@a2;fq}Dwp-0o1fq}7v(elwb1_q9`jP`O43=GWri~&cd zGcYhpG45)a#K6Fo#>{jrhXAz@)|{w@QM6fwhjy;n+R~hI&>RuBv7(1_tJt-1&7=85r0s zxesXHWMJU9$9-bI8v_GtAdgdA2m=GlYo0puD+~;rS9xBqIm*Dmbf4$_(JlrC_GsRT z>9ZIZn3MUWEn65E*j4#fUlw6t;F`d%@OT{q14}D^X4Yc{2G%wFnJc6i7+AddYhMR2 zFfg9u-*tHwg8&1I82{bQtqcqdS27tGxK3~0#F)XXI+ZbcR_e|W!T(YmpfF)$U|+C&A{&3sMYM#-5k208+WxoHdF`mc##UGGnO9#7S^@ UuHur!N`=Jo#O&0~jqHUi092xe(f|Me delta 404 zcmbO#`AK|&Nc{{3)@?Bi4D9C^*uI%EFmP;SV7CcnVBomHAhX$mfq}D(!Ax`!0|VWwSa+v^CnBA(=7%DCC1sT>}gsI3{0z7+g}SY zFff#`RXpxuU|@=3o51J7z`&M1aj&>$IJ>*{Qw9b`PxhWYeGCko%Is@;cQ7!pe`bHC z^@xFiO_2S&XB-0qN9)AD;`I|b9gI>L7})GN6ECnZFtAp0mPUMGU|>??l3OLgz`&}& z<#22t0|U!GuBv7(1_tJ2?gQF485lT%xlinOV_;zU%;OXn!oa}N%Ts55g@J+7i|6&4 zqYMm8_j%qQ?P6eHyTB)H*}}lUHj8idWf2AjE>?bp$LkmvSk(D5vmP@rsI&U Dialog - + Wireless 无线 - + wireless connect 无线连接 - + wireless disconnect 无线断开 @@ -69,13 +69,13 @@ 启动配置 - + record save path: 录像保存路径: - - + + select path 选择路径 @@ -85,47 +85,57 @@ 录制格式: - + record screen 录制屏幕 - + frameless 无边框 - + + lock orientation: + 锁定方向: + + + show fps 显示fps - + + stay awake + 保持唤醒 + + + stop all server 停止所有服务 - + adb command: adb命令: - + terminate 终止 - + execute 执行 - + clear 清理 - + reverse connection 反向连接 @@ -134,17 +144,17 @@ 自动启用脚本 - + background record 后台录制 - + screen-off 自动息屏 - + apply 应用脚本 @@ -154,37 +164,37 @@ 最大尺寸: - + always on top 窗口置顶 - + refresh script 刷新脚本 - + get device IP 获取设备IP - + USB line USB线 - + stop server 停止服务 - + start server 启动服务 - + device serial: 设备序列号: @@ -198,20 +208,25 @@ 比特率: - + start adbd 启动adbd - + refresh devices 刷新设备列表 - + original 原始 + + + no lock + 不锁定 + QObject @@ -226,7 +241,7 @@ You can download it at the following address: 本软件完全开源免费.\n严禁用于非法用途,否则后果自负.\n你可以在下面地址下载: - + This software is completely open source and free. Strictly used for illegal purposes, or at your own risk. You can download it at the following address: 本软件完全开源免费,严禁用于非法用途,否则后果自负,你可以在下面地址下载: @@ -322,7 +337,7 @@ You can download it at the following address: 文件传输失败 - + file does not exist 文件不存在 diff --git a/QtScrcpy/util/config.cpp b/QtScrcpy/util/config.cpp index bf9c43a..e363bc4 100644 --- a/QtScrcpy/util/config.cpp +++ b/QtScrcpy/util/config.cpp @@ -34,6 +34,9 @@ #define COMMON_ADB_PATH_KEY "AdbPath" #define COMMON_ADB_PATH_DEF "" +#define COMMON_LOG_LEVEL_KEY "LogLevel" +#define COMMON_LOG_LEVEL_DEF "info" + // user data #define COMMON_RECORD_KEY "RecordPath" #define COMMON_RECORD_DEF "" @@ -74,7 +77,7 @@ Config &Config::getInstance() static Config config; return config; } - +#include const QString &Config::getConfigPath() { if (s_configPath.isEmpty()) { @@ -265,6 +268,15 @@ QString Config::getAdbPath() return adbPath; } +QString Config::getLogLevel() +{ + QString logLevel; + m_settings->beginGroup(GROUP_COMMON); + logLevel = m_settings->value(COMMON_LOG_LEVEL_KEY, COMMON_LOG_LEVEL_DEF).toString(); + m_settings->endGroup(); + return logLevel; +} + QString Config::getTitle() { QString title; diff --git a/QtScrcpy/util/config.h b/QtScrcpy/util/config.h index 274c9e5..8f82fda 100644 --- a/QtScrcpy/util/config.h +++ b/QtScrcpy/util/config.h @@ -21,6 +21,7 @@ public: QString getPushFilePath(); QString getServerPath(); QString getAdbPath(); + QString getLogLevel(); // user data QString getRecordPath(); diff --git a/config/config.ini b/config/config.ini index 9a36955..f72d577 100644 --- a/config/config.ini +++ b/config/config.ini @@ -10,8 +10,11 @@ RenderExpiredFrames=0 # 视频解码方式:-1 自动,0 软解,1 dx硬解,2 opengl硬解 UseDesktopOpenGL=-1 # scrcpy-server的版本号(不要修改) -ServerVersion=1.12.1 +ServerVersion=1.14 # scrcpy-server推送到安卓设备的路径 ServerPath=/data/local/tmp/scrcpy-server.jar # 自定义adb路径,例如D:/android/tools/adb.exe AdbPath= + +# Set the log level (debug, info, warn, error) +LogLevel=info diff --git a/docs/DEVELOP.md b/docs/DEVELOP.md index 92c3ce8..4d8acc5 100644 --- a/docs/DEVELOP.md +++ b/docs/DEVELOP.md @@ -189,7 +189,7 @@ The client uses 4 threads: recording, - the **controller** thread, sending _control messages_ to the server, - the **receiver** thread (managed by the controller), receiving _device - messages_ from the client. + messages_ from the server. In addition, another thread can be started if necessary to handle APK installation or file push requests (via drag&drop on the main window) or to @@ -214,7 +214,7 @@ When a new decoded frame is available, the decoder _swaps_ the decoding and rendering frame (with proper synchronization). Thus, it immediatly starts to decode a new frame while the main thread renders the last one. -If a [recorder] is present (i.e. `--record` is enabled), then its muxes the raw +If a [recorder] is present (i.e. `--record` is enabled), then it muxes the raw H.264 packet to the output video file. [stream]: https://github.com/Genymobile/scrcpy/blob/ffe0417228fb78ab45b7ee4e202fc06fc8875bf3/app/src/stream.h @@ -282,6 +282,15 @@ meson x -Dserver_debugger=true meson configure x -Dserver_debugger=true ``` +If your device runs Android 8 or below, set the `server_debugger_method` to +`old` in addition: + +```bash +meson x -Dserver_debugger=true -Dserver_debugger_method=old +# or, if x is already configured +meson configure x -Dserver_debugger=true -Dserver_debugger_method=old +``` + Then recompile. When you start scrcpy, it will start a debugger on port 5005 on the device. diff --git a/docs/FAQ.md b/docs/FAQ.md index 7ea6efa..74284f1 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -3,6 +3,26 @@ 如果在此文档没有解决你的问题,描述你的问题,截图软件控制台中打印的日志,一起发到QQ群里提问。 +# adb问题 +## ADB版本之间的冲突 +``` +adb server version (41) doesn't match this client (39); killing... +``` +当你的电脑中运行不同版本的adb时,会发生此错误。你必须保证所有程序使用相同版本的adb。 +现在你有两个办法解决这个问题: +1. 任务管理器找到adb进程并杀死 +2. 配置QtScrcpy的config.ini中的AdbPath路径指向当前使用的adb + +## 手机通过数据线连接电脑,刷新设备列表以后,没有任何设备出现 +随便下载一个手机助手,尝试连接成功以后,再用QtScrcpy刷新设备列表连接 + +# 控制问题 +## 可以看到画面,但无法控制 +有些手机(小米等手机)需要额外打开控制权限,检查是否USB调试里打开了允许模拟点击 + +![image](image/USB调试(安全设置).jpg) + +# 其它 ## 支持声音(软件不做支持) [关于转发安卓声音到PC的讨论](https://github.com/Genymobile/scrcpy/issues/14#issuecomment-543204526) @@ -21,19 +41,11 @@ QtScrcpy.exe>属性>兼容性>更改高DPI设置>覆盖高DPI缩放行为>由以 ## 无法输入中文 手机端安装搜狗输入法/QQ输入法就可以支持输入中文了 -## 可以看到画面,但无法控制 -有些手机(小米等手机)需要额外打开控制权限,检查是否USB调试里打开了允许模拟点击 - -![image](image/USB调试(安全设置).jpg) - ## 可以控制,但无法看到画面 控制台错误信息可能会包含 QOpenGLShaderProgram::attributeLocation(vertexIn): shader program is not linked 一般是由于显卡不支持当前的视频渲染方式,config.ini里修改下解码方式,改成1或者2试试 -## 手机通过数据线连接电脑,刷新设备列表以后,没有任何设备出现 -随便下载一个手机助手,尝试连接成功以后,再用QtScrcpy刷新设备列表连接 - ## 错误信息:AdbProcess::error:adb server version (40) doesnt match this client (41) 任务管理找到adb进程并杀死,重新操作即可 diff --git a/docs/TODO.md b/docs/TODO.md index ef510a9..4d3ca81 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,21 +1,22 @@ -最后同步scrcpy 31bd95022bc525be42ca273d59a3211d964d278b +最后同步scrcpy 3c0fc8f54f42bf6e7eca35b352a7d343749b65c4 # TODO ## 低优先级 -- [单独线程统计帧率](https://github.com/Genymobile/scrcpy/commit/e2a272bf99ecf48fcb050177113f903b3fb323c4) - text转换 https://github.com/Genymobile/scrcpy/commit/c916af0984f72a60301d13fa8ef9a85112f54202?tdsourcetag=s_pctim_aiomsg +- 关闭number lock时的数字小键盘处理 https://github.com/Genymobile/scrcpy/commit/cd69eb4a4fecf8167208399def4ef536b59c9d22 +- mipmapping https://github.com/Genymobile/scrcpy/commit/bea7658807d276aeab7d18d856a366c83ee05827 ## 中优先级 - 脚本 - 某些机器软解不行 - opengles 3.0 兼容性参考[这里](https://github.com/libretro/glsl-shaders/blob/master/nnedi3/shaders/yuv-to-rgb-2x.glsl) +- 通过host:track-devices实现自动连接 https://www.jianshu.com/p/2cb86c6de76c +- 旋转 https://github.com/Genymobile/scrcpy/commit/d48b375a1dbc8bed92e3424b5967e59c2d8f6ca1 ## 高优先级 - linux打包以及版本号 - 关于 -- 旋转 -- ubuntu自动打包 -- 版本号抽离优化 +- 音频转发 https://github.com/rom1v/sndcpy # mark ## ffmpeg 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 0000000..46d7f7c --- /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 0000000..7455563 --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/CleanUp.java @@ -0,0 +1,77 @@ +package com.genymobile.scrcpy; + +import com.genymobile.scrcpy.wrappers.ContentProvider; +import com.genymobile.scrcpy.wrappers.ServiceManager; + +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"; + + private CleanUp() { + // not instantiable + } + + public static void configure(boolean disableShowTouches, int restoreStayOn) throws IOException { + boolean needProcess = disableShowTouches || restoreStayOn != -1; + if (needProcess) { + startProcess(disableShowTouches, restoreStayOn); + } else { + // There is no additional clean up to do when scrcpy dies + unlinkSelf(); + } + } + + private static void startProcess(boolean disableShowTouches, int restoreStayOn) throws IOException { + String[] cmd = {"app_process", "/", CleanUp.class.getName(), String.valueOf(disableShowTouches), String.valueOf(restoreStayOn)}; + + 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"); + + boolean disableShowTouches = Boolean.parseBoolean(args[0]); + int restoreStayOn = Integer.parseInt(args[1]); + + if (disableShowTouches || restoreStayOn != -1) { + ServiceManager serviceManager = new ServiceManager(); + try (ContentProvider settings = serviceManager.getActivityManager().createSettingsProvider()) { + if (disableShowTouches) { + Ln.i("Disabling \"show touches\""); + settings.putValue(ContentProvider.TABLE_SYSTEM, "show_touches", "0"); + } + if (restoreStayOn != -1) { + Ln.i("Restoring \"stay awake\""); + settings.putValue(ContentProvider.TABLE_GLOBAL, "stay_on_while_plugged_in", String.valueOf(restoreStayOn)); + } + } + } + } +} 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 0000000..1897bda --- /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 195b04b..7d0ab7a 100644 --- a/server/src/main/java/com/genymobile/scrcpy/ControlMessage.java +++ b/server/src/main/java/com/genymobile/scrcpy/ControlMessage.java @@ -17,6 +17,8 @@ public final class ControlMessage { public static final int TYPE_SET_SCREEN_POWER_MODE = 9; public static final int TYPE_ROTATE_DEVICE = 10; + public static final int FLAGS_PASTE = 1; + private int type; private String text; private int metaState; // KeyEvent.META_* @@ -28,6 +30,7 @@ public final class ControlMessage { private Position position; private int hScroll; private int vScroll; + private int flags; private ControlMessage() { } @@ -68,10 +71,13 @@ public final class ControlMessage { return msg; } - public static ControlMessage createSetClipboard(String text) { + public static ControlMessage createSetClipboard(String text, boolean paste) { ControlMessage msg = new ControlMessage(); msg.type = TYPE_SET_CLIPBOARD; msg.text = text; + if (paste) { + msg.flags = FLAGS_PASTE; + } return msg; } @@ -134,4 +140,8 @@ public final class ControlMessage { public int getVScroll() { return vScroll; } + + public int getFlags() { + return flags; + } } diff --git a/server/src/main/java/com/genymobile/scrcpy/ControlMessageReader.java b/server/src/main/java/com/genymobile/scrcpy/ControlMessageReader.java index 726b565..fbf49a6 100644 --- a/server/src/main/java/com/genymobile/scrcpy/ControlMessageReader.java +++ b/server/src/main/java/com/genymobile/scrcpy/ControlMessageReader.java @@ -8,15 +8,16 @@ 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 = 9; + static final int INJECT_TOUCH_EVENT_PAYLOAD_LENGTH = 27; + static final int INJECT_SCROLL_EVENT_PAYLOAD_LENGTH = 20; + 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; + public static final int CLIPBOARD_TEXT_MAX_LENGTH = 4092; // 4096 - 1 (type) - 1 (parse flag) - 2 (length) + public static final int INJECT_TEXT_MAX_LENGTH = 300; + + private static final int RAW_BUFFER_SIZE = 4096; private final byte[] rawBuffer = new byte[RAW_BUFFER_SIZE]; private final ByteBuffer buffer = ByteBuffer.wrap(rawBuffer); @@ -122,7 +123,6 @@ public class ControlMessageReader { return ControlMessage.createInjectText(text); } - @SuppressWarnings("checkstyle:MagicNumber") private ControlMessage parseInjectTouchEvent() { if (buffer.remaining() < INJECT_TOUCH_EVENT_PAYLOAD_LENGTH) { return null; @@ -149,11 +149,15 @@ public class ControlMessageReader { } private ControlMessage parseSetClipboard() { + if (buffer.remaining() < SET_CLIPBOARD_FIXED_PAYLOAD_LENGTH) { + return null; + } + boolean parse = buffer.get() != 0; String text = parseString(); if (text == null) { return null; } - return ControlMessage.createSetClipboard(text); + return ControlMessage.createSetClipboard(text, parse); } private ControlMessage parseSetScreenPowerMode() { @@ -172,12 +176,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 dc0fa67..960c6a6 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Controller.java +++ b/server/src/main/java/com/genymobile/scrcpy/Controller.java @@ -1,10 +1,8 @@ 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; @@ -47,11 +45,10 @@ public class Controller { } } - @SuppressWarnings("checkstyle:MagicNumber") public void control() throws IOException { // on start, power on the device if (!device.isScreenOn()) { - injectKeycode(KeyEvent.KEYCODE_POWER); + device.injectKeycode(KeyEvent.KEYCODE_POWER); // dirty hack // After POWER is injected, the device is powered on asynchronously. @@ -76,19 +73,29 @@ 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.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(); + } break; case ControlMessage.TYPE_EXPAND_NOTIFICATION_PANEL: device.expandNotificationPanel(); @@ -98,13 +105,22 @@ public class Controller { break; case ControlMessage.TYPE_GET_CLIPBOARD: String clipboardText = device.getClipboardText(); - sender.pushClipboardText(clipboardText); + if (clipboardText != null) { + sender.pushClipboardText(clipboardText); + } break; case ControlMessage.TYPE_SET_CLIPBOARD: - device.setClipboardText(msg.getText()); + boolean paste = (msg.getFlags() & ControlMessage.FLAGS_PASTE) != 0; + setClipboard(msg.getText(), paste); 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) { + Ln.i("Device screen turned " + (mode == Device.POWER_MODE_OFF ? "off" : "on")); + } + } break; case ControlMessage.TYPE_ROTATE_DEVICE: device.rotateDevice(); @@ -115,7 +131,7 @@ public class Controller { } private boolean injectKeycode(int action, int keycode, int metaState) { - return injectKeyEvent(action, keycode, 0, metaState); + return device.injectKeyEvent(action, keycode, 0, metaState); } private boolean injectChar(char c) { @@ -126,7 +142,7 @@ public class Controller { return false; } for (KeyEvent event : events) { - if (!injectEvent(event)) { + if (!device.injectEvent(event)) { return false; } } @@ -182,7 +198,7 @@ public class Controller { 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); + return device.injectEvent(event); } private boolean injectScroll(Position position, int hScroll, int vScroll) { @@ -204,27 +220,26 @@ public class Controller { 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); - } - - 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); - } - - private boolean injectKeycode(int keyCode) { - return injectKeyEvent(KeyEvent.ACTION_DOWN, keyCode, 0, 0) && injectKeyEvent(KeyEvent.ACTION_UP, keyCode, 0, 0); - } - - private boolean injectEvent(InputEvent event) { - return device.injectInputEvent(event, InputManager.INJECT_INPUT_EVENT_MODE_ASYNC); + InputDevice.SOURCE_TOUCHSCREEN, 0); + return device.injectEvent(event); } private boolean pressBackOrTurnScreenOn() { int keycode = device.isScreenOn() ? KeyEvent.KEYCODE_BACK : KeyEvent.KEYCODE_POWER; - return injectKeycode(keycode); + return device.injectKeycode(keycode); + } + + private boolean setClipboard(String text, boolean paste) { + boolean ok = device.setClipboardText(text); + if (ok) { + Ln.i("Device clipboard set"); + } + + // On Android >= 7, also press the PASTE key if requested + if (paste && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && device.supportsInputEvents()) { + device.injectKeycode(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 a725d83..0ec4304 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 9448098..349486c 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Device.java +++ b/server/src/main/java/com/genymobile/scrcpy/Device.java @@ -1,15 +1,23 @@ package com.genymobile.scrcpy; +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 { @@ -20,18 +28,47 @@ public final class Device { void onRotationChanged(int rotation); } + public interface ClipboardListener { + void onClipboardTextChanged(String text); + } + private final ServiceManager serviceManager = new ServiceManager(); 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 = serviceManager.getDisplayManager().getDisplayInfo(displayId); + if (displayInfo == null) { + int[] displayIds = serviceManager.getDisplayManager().getDisplayIds(); + throw new InvalidDisplayIdException(displayId, displayIds); + } + + int displayInfoFlags = displayInfo.getFlags(); + + screenInfo = ScreenInfo.computeScreenInfo(displayInfo, options.getCrop(), options.getMaxSize(), options.getLockedVideoOrientation()); + layerStack = displayInfo.getLayerStack(); + + serviceManager.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,104 +76,120 @@ public final class Device { } } } - }); + }, displayId); + + if (options.getControl()) { + // If control is enabled, synchronize Android clipboard to the computer automatically + serviceManager.getClipboardManager().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); + } + } + } + } + }); + } + + 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) { + public boolean supportsInputEvents() { + return supportsInputEvents; + } + + public boolean injectEvent(InputEvent inputEvent, int mode) { + if (!supportsInputEvents()) { + throw new AssertionError("Could not inject input event if !supportsInputEvents()"); + } + + if (displayId != 0 && !InputManager.setDisplayId(inputEvent, displayId)) { + return false; + } + return serviceManager.getInputManager().injectInputEvent(inputEvent, mode); } + public boolean injectEvent(InputEvent event) { + return injectEvent(event, InputManager.INJECT_INPUT_EVENT_MODE_ASYNC); + } + + public 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); + } + + public boolean injectKeycode(int keyCode) { + return injectKeyEvent(KeyEvent.ACTION_DOWN, keyCode, 0, 0) && injectKeyEvent(KeyEvent.ACTION_UP, keyCode, 0, 0); + } + public boolean isScreenOn() { return serviceManager.getPowerManager().isScreenOn(); } - public void registerRotationWatcher(IRotationWatcher rotationWatcher) { - serviceManager.getWindowManager().registerRotationWatcher(rotationWatcher); - } - public synchronized void setRotationListener(RotationListener rotationListener) { this.rotationListener = rotationListener; } + public synchronized void setClipboardListener(ClipboardListener clipboardListener) { + this.clipboardListener = clipboardListener; + } + public void expandNotificationPanel() { serviceManager.getStatusBarManager().expandNotificationsPanel(); } @@ -153,22 +206,23 @@ public final class Device { return s.toString(); } - public void setClipboardText(String text) { - serviceManager.getClipboardManager().setText(text); - Ln.i("Device clipboard set"); + public boolean setClipboardText(String text) { + isSettingClipboard.set(true); + boolean ok = serviceManager.getClipboardManager().setText(text); + isSettingClipboard.set(false); + return ok; } /** * @param mode one of the {@code SCREEN_POWER_MODE_*} constants */ - public void setScreenPowerMode(int mode) { + public 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); } /** @@ -192,7 +246,7 @@ public final class Device { } } - static Rect flipRect(Rect crop) { - return new Rect(crop.top, crop.left, crop.bottom, crop.right); + public ContentProvider createSettingsProvider() { + return serviceManager.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 e2a3a1a..6c7f363 100644 --- a/server/src/main/java/com/genymobile/scrcpy/DeviceMessageWriter.java +++ b/server/src/main/java/com/genymobile/scrcpy/DeviceMessageWriter.java @@ -13,7 +13,6 @@ public class DeviceMessageWriter { private final byte[] rawBuffer = new byte[MAX_EVENT_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); diff --git a/server/src/main/java/com/genymobile/scrcpy/DisplayInfo.java b/server/src/main/java/com/genymobile/scrcpy/DisplayInfo.java index 639869b..4b8036f 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 0000000..81e3b90 --- /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/Ln.java b/server/src/main/java/com/genymobile/scrcpy/Ln.java index 26f13a5..c218fa0 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Ln.java +++ b/server/src/main/java/com/genymobile/scrcpy/Ln.java @@ -15,14 +15,25 @@ public final class Ln { 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 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 5b993f3..06312a3 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Options.java +++ b/server/src/main/java/com/genymobile/scrcpy/Options.java @@ -3,13 +3,27 @@ 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; + + public Ln.Level getLogLevel() { + return logLevel; + } + + public void setLogLevel(Ln.Level logLevel) { + this.logLevel = logLevel; + } public int getMaxSize() { return maxSize; @@ -35,6 +49,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 +88,36 @@ 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; + } } diff --git a/server/src/main/java/com/genymobile/scrcpy/Position.java b/server/src/main/java/com/genymobile/scrcpy/Position.java index b46d2f7..e9b6d8a 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 c9a37f8..d722388 100644 --- a/server/src/main/java/com/genymobile/scrcpy/ScreenEncoder.java +++ b/server/src/main/java/com/genymobile/scrcpy/ScreenEncoder.java @@ -6,40 +6,37 @@ import android.graphics.Rect; import android.media.MediaCodec; import android.media.MediaCodecInfo; import android.media.MediaFormat; -import android.os.Build; import android.os.IBinder; import android.view.Surface; import java.io.FileDescriptor; import java.io.IOException; import java.nio.ByteBuffer; +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 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) { 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; } @Override @@ -53,21 +50,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(); 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); @@ -135,27 +151,49 @@ public class ScreenEncoder implements Device.RotationListener { } private static MediaCodec createCodec() throws IOException { - return MediaCodec.createEncoderByType("video/avc"); + return MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC); } - @SuppressWarnings("checkstyle:MagicNumber") - private static MediaFormat createFormat(int bitRate, int maxFps, int iFrameInterval) { + 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; } @@ -172,12 +210,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 f2fce1d..10acfb5 100644 --- a/server/src/main/java/com/genymobile/scrcpy/ScreenInfo.java +++ b/server/src/main/java/com/genymobile/scrcpy/ScreenInfo.java @@ -3,29 +3,161 @@ 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(); + 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 56b738f..44b3afd 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Server.java +++ b/server/src/main/java/com/genymobile/scrcpy/Server.java @@ -1,32 +1,74 @@ package com.genymobile.scrcpy; +import com.genymobile.scrcpy.wrappers.ContentProvider; + import android.graphics.Rect; import android.media.MediaCodec; +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); + 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(mustDisableShowTouchesOnCleanUp, restoreStayOn); + boolean tunnelForward = options.isTunnelForward(); + try (DesktopConnection connection = DesktopConnection.open(device, tunnelForward)) { - ScreenEncoder screenEncoder = new ScreenEncoder(options.getSendFrameMeta(), options.getBitRate(), options.getMaxFps()); + ScreenEncoder screenEncoder = new ScreenEncoder(options.getSendFrameMeta(), options.getBitRate(), options.getMaxFps(), codecOptions); if (options.getControl()) { - Controller controller = new Controller(device, connection); + final Controller controller = new Controller(device, connection); // asynchronous startController(controller); startDeviceMessageSender(controller.getSender()); + + device.setClipboardListener(new Device.ClipboardListener() { + @Override + public void onClipboardTextChanged(String text) { + controller.getSender().pushClipboardText(text); + } + }); } try { @@ -67,7 +109,6 @@ public final class Server { }).start(); } - @SuppressWarnings("checkstyle:MagicNumber") private static Options createOptions(String... args) { if (args.length < 1) { throw new IllegalArgumentException("Missing client version"); @@ -76,41 +117,59 @@ 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 = 14; + 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); + return options; } - @SuppressWarnings("checkstyle:MagicNumber") private static Rect parseCrop(String crop) { if ("-".equals(crop)) { return null; @@ -127,15 +186,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 +197,16 @@ 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); + } + } + } } public static void main(String... args) throws Exception { @@ -158,8 +218,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 199fc8c..dac0546 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 b1b8190..351cc57 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Workarounds.java +++ b/server/src/main/java/com/genymobile/scrcpy/Workarounds.java @@ -28,7 +28,7 @@ public final class Workarounds { Looper.prepareMainLooper(); } - @SuppressLint("PrivateApi") + @SuppressLint("PrivateApi,DiscouragedPrivateApi") public static void fillAppInfo() { try { // ActivityThread activityThread = new ActivityThread(); @@ -73,7 +73,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 0000000..71967c5 --- /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 getContentProviderExternalMethodLegacy; + 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); + getContentProviderExternalMethodLegacy = true; + } + } + 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 (!getContentProviderExternalMethodLegacy) { + // 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 592bdf6..e25b6e9 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 0000000..b43494c --- /dev/null +++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/ContentProvider.java @@ -0,0 +1,132 @@ +package com.genymobile.scrcpy.wrappers; + +import com.genymobile.scrcpy.Ln; + +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 boolean callMethodLegacy; + + ContentProvider(ActivityManager manager, Object provider, String name, IBinder token) { + this.manager = manager; + this.provider = provider; + this.name = name; + this.token = token; + } + + private Method getCallMethod() throws NoSuchMethodException { + if (callMethod == null) { + try { + callMethod = provider.getClass().getMethod("call", String.class, String.class, String.class, String.class, Bundle.class); + } catch (NoSuchMethodException e) { + // old version + callMethod = provider.getClass().getMethod("call", String.class, String.class, String.class, Bundle.class); + callMethodLegacy = true; + } + } + return callMethod; + } + + private Bundle call(String callMethod, String arg, Bundle extras) { + try { + Method method = getCallMethod(); + Object[] args; + if (!callMethodLegacy) { + args = new Object[]{ServiceManager.PACKAGE_NAME, "settings", callMethod, arg, extras}; + } else { + args = new Object[]{ServiceManager.PACKAGE_NAME, callMethod, arg, extras}; + } + return (Bundle) method.invoke(provider, args); + } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException 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 568afac..cedb3f4 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 44fa613..e17b5a1 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 0b625c9..c4ce59c 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 { @@ -76,4 +81,21 @@ public final class ServiceManager { } 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/SurfaceControl.java b/server/src/main/java/com/genymobile/scrcpy/wrappers/SurfaceControl.java index 227bbc8..8fbb860 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 cc687cd..faa366a 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/third_party/scrcpy-server b/third_party/scrcpy-server index 640d0cfb10144ccad9cd78a16c9767e2e00a182f..73d292aa873c3c39d107c223603b221f4c4ec46f 100644 GIT binary patch delta 32717 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 C;AE!& delta 25645 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