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 | 79 ++++--- .../genymobile/scrcpy/DesktopConnection.java | 1 - .../java/com/genymobile/scrcpy/Device.java | 208 +++++++++++------- .../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 | 152 ++++++++++++- .../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, 1476 insertions(+), 303 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 zcmX|7Z%7ky7=F&}?sm6%hqX#n<|-oQpbvu8L~5;2`jmu0K@1N&>zwUU7t5$kN{Yh7 zii(0D`Vdl*(MVFNMK%k95G<&mFRnk*2&1CDMb9Gq@bdC|f6t%yd9&4n%ZI$_I}pcq z1MaJke$@eDae{)Q#*8pveb64aYkSGBjtax|Ru%;y+H881D%_X_zjtun+QimBb2@$#fvyYA252E@<0)sd&r5#1WD;QO+E zw9w2&_UIEcB#PVh_owbLsn;UuVdk!2J2$LACv3DK20wc@ESQKR0fS|65=XtOcr1xk z)@irJ{C2lx#uG-uvg7sV%cMrXMvzVVf1w4q9Jl>NMy5$BcLcoX`6t^;q=6(W26F+c S_9Tp6zmYUzW@ejmK>7>g#*j1s delta 443 zcmeBE+%G>tq<#hi>$VsM2CioeY~M^77AiUW(y$wgTYL65d#Bz7DGn4 zB?AM~GKMVeCI$xP5Qg&HLqPd>h8|641_s7YjFykiF)(m@G1|*HFfg!aG6o!-4is-- z+|@D(sDC9h_fHh2dUpUF zz|a0n>k&}ZNA~ZYaX|ig4jV^hpn0D;;*3rMwcp_Q$1DSM;6hFZqf~}^1`b`$#0xAy z4ThYh5nq5lyuc;5N`irb?GTs4v3)?=f~%^T3#cKR`+)XMpn=`oC-%Dm#XEVN;zEE9 z`OH&ieg)`&T|BSX90gi*nCJabpa(da_@pg?4(C|MxB9XO(8uff6&|kxs?X%l%z6ye z|BpX&g_I&tK$^eybpX)f6#iY8X8|p`!hg4OE6`^mnG6hkY@1Ip=Ce$0XEWRUmW_jX JvmRFh8vvJNZwvqc 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|AbTWg?W(x)eE=C43(M1dlY+MW( z<(3Q#jQ<$2w3`?hnA#Z1a}NR4bujd3Is?T^7%d;21Ddmz(O%Ahfq_|{G2rNQptuy{ zu9ita{b|hHSLJ}_nK2*cS^%U|SR$QnF)#=*&Sqs#(*hc}idEe7HBjU6iF3v4Kd~Ku z)xyBQ;Kuf%!3jt+uscW#Gcd4hWp~$p3N+P|y=PA!(DrrgYkGG8ZLMQ}ru7JD%N+La zo^e2N0S+5SWuRfK9C1dcf!ddF{9~2@^51hh7^MQ$o#ITqz{0@5dXuv>;tSAnH7>bT z5Pi zzXG)ID$naRM}Y?2=Xrm$3+UWv-ihh6fclg9q%DCSVOQl_eOUzP?Fsw}kJkb9wen|X zJqDVyhCg$K6p-)DU;8=$=&^JByDraS5MW>t zXNcf`DGpGWumQ1PPJVK>LVi(ZYF8O@gRl#Bqd=}-uy)733v-}x4zKTNX`gG&&1>jBvZ={pk$-G-c7MZ86F$Q<(z z8vTuheVdvyWtDiRtjI@)o T9GZEDnCdwcOG7*vp^e&q8t7?Y diff --git a/QtScrcpy/res/i18n/QtScrcpy_zh.ts b/QtScrcpy/res/i18n/QtScrcpy_zh.ts index bb0223f..27ea074 100644 --- a/QtScrcpy/res/i18n/QtScrcpy_zh.ts +++ b/QtScrcpy/res/i18n/QtScrcpy_zh.ts @@ -49,17 +49,17 @@ 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); + InputDevice.SOURCE_TOUCHSCREEN, 0); + return device.injectEvent(event); } - private boolean injectKeyEvent(int action, int keyCode, int repeat, int metaState) { - long now = SystemClock.uptimeMillis(); - KeyEvent event = new KeyEvent(now, now, action, keyCode, repeat, metaState, KeyCharacterMap.VIRTUAL_KEYBOARD, 0, 0, - InputDevice.SOURCE_KEYBOARD); - return injectEvent(event); + private boolean pressBackOrTurnScreenOn() { + int keycode = device.isScreenOn() ? KeyEvent.KEYCODE_BACK : KeyEvent.KEYCODE_POWER; + return device.injectKeycode(keycode); } - private boolean injectKeycode(int keyCode) { - return injectKeyEvent(KeyEvent.ACTION_DOWN, keyCode, 0, 0) && injectKeyEvent(KeyEvent.ACTION_UP, keyCode, 0, 0); - } + private boolean setClipboard(String text, boolean paste) { + boolean ok = device.setClipboardText(text); + if (ok) { + Ln.i("Device clipboard set"); + } - private boolean injectEvent(InputEvent event) { - return device.injectInputEvent(event, InputManager.INJECT_INPUT_EVENT_MODE_ASYNC); - } + // 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); + } - private boolean pressBackOrTurnScreenOn() { - int keycode = device.isScreenOn() ? KeyEvent.KEYCODE_BACK : KeyEvent.KEYCODE_POWER; - return injectKeycode(keycode); + 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 { } } } - }); - } - - public synchronized ScreenInfo getScreenInfo() { - return screenInfo; - } + }, 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); + } + } + } + } + }); + } - 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 - } + if ((displayInfoFlags & DisplayInfo.FLAG_SUPPORTS_PROTECTED_BUFFERS) == 0) { + Ln.w("Display doesn't have FLAG_SUPPORTS_PROTECTED_BUFFERS flag, mirroring can be restricted"); } - Size videoSize = computeVideoSize(contentRect.width(), contentRect.height(), maxSize); - return new ScreenInfo(contentRect, videoSize, rotated); + // 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"); + } } - private static String formatCrop(Rect rect) { - return rect.width() + ":" + rect.height() + ":" + rect.left + ":" + rect.top; + public synchronized ScreenInfo getScreenInfo() { + return screenInfo; } - @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 isScreenOn() { - return serviceManager.getPowerManager().isScreenOn(); + 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 void registerRotationWatcher(IRotationWatcher rotationWatcher) { - serviceManager.getWindowManager().registerRotationWatcher(rotationWatcher); + 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 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; } + /** + * Return the video size as if locked video orientation was not set. + * + * @return the unlocked video size + */ + public Size getUnlockedVideoSize() { + return unlockedVideoSize; + } + + /** + * Return the actual video size if locked video orientation is set. + * + * @return the actual video size + */ public Size getVideoSize() { - return videoSize; + if (getVideoRotation() % 2 == 0) { + return unlockedVideoSize; + } + + return unlockedVideoSize.rotate(); } - public ScreenInfo withRotation(int rotation) { - boolean newRotated = (rotation & 1) != 0; - if (rotated == newRotated) { + 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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 z%0A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 -V)9j%dW+2 zS@HuW`I;J>kdq9c=|1DK#=-HUUnZIqk6G0czEQU&qZJrKAzt|+)lF|Z-$QVIrwv%l zr#&-ggdM&~o%8)9DnUj_Pnc+lj!T&J~NLE5xr_~d5* z@w)@_7Y=e{8Yw@6ge&6p zvo|8GX-fkdAFRm@(x+S>v>k=pE|t`d9t4z7_p=3RgaU(QX7Q>(hh$COz+o$0H`enx z(My+)&&1o;-m8a50jz;VA-zsUHT%t9$(rI!a@86>m0eQnWMlL0E+&O?{{QeqEuH~t zw+ZcFZQW5mVqYl*O|FCFRD+eXlKfi?O==38%E#f8YA`Xj&^c+Ts%` zCq0rz7QC+63W@~aD*|Jh0)TacUgZWY^CYL-b5hXXwAGiIvOdkYA@Z-@n2Zd@OS&z- zOYWZ>ShE9a96f9M%Q#G=D*ybg$L|B~B9oC1Ry(k|(}w!*WGFUyvvy(yp80GQ6X1`& zu+sN{24Lrg2om8z; zYaJCKP~`oWy%fdlsBC~Ef4NG@Juk9=TBM20CVL~nGFq=Xcx|&MYPz&%tsVn(7OJTP z;{JTXD^Of7aRFnN|7i3ed0={G+Gr$d4y?$gJM1N@_P)n3&9;jw8*f#$&cg9Drt&dOB2ISIG8<{xenmi& z(S}wP5$1*?E_1mU$?3VJ! zWG5HQXpxi1H0sgCO;vFLWuCv39?^)>i-6Fl=i(FjvQ5?sJP?@Er6iZw zxl}B_jTktA(pOTYStiW^g2tAW*_r#NW9;A(WAUe_$B~(nR=qCR4kjLj0?vMQ?qG4` z%c$i)r}4mE-Hb>UHqfsKEsz6_-;{CZFQbaV+>TG-H)yr!yN3j;Z+;B5e<=bat}I52ySYA{HZ@cUiR}Nj9Q~f!asx z5X1z=pCZh1i%bG)j6*jK0R}sdu=(XwiGgQGv2yM0AZ}$Rh(E7Jmr=CFah30a<66FZCwE+d+_<3|rqzFm1P5vv z%`%77n)nCQbV-xtv=PG59Orh!NL#;Z zRJwFs$1wvoLyNJvWx-Xcu#SO&J=g5~&6%f-?2I+#}1WmT$Tzr+Ux~`p` z$1;>!5UUe)aQm8G zBgAN|^J&--Q1Z;2_W1TAUILPu}5Nt@(dgWP^c^S#X(>ax)N+HrGD8r#8?^J`j& zFo7#}vzvV4h5R^^!#BJVDt*Zkt>m9fa+^Z~3v@RTcAk5}@Zq@~g=SSX;Pjhp1`WK! zECW>kn`_twMelwZ@HWY=ec;*#_x)BfSbQqqU%?hKt&Q~YP`WmBUZbKSROc6Ydc0&W zYkJ{omm=w(!jC)vQ8yQEQ~dh(;FXhue%jGBtethHY%*t`DnDZ#TldTI*?vL#Ats)k zPNuWz7}iyZDNImCdBzOkSG?nYr1CSr69Qf*ElixMY&#;V%w)$p&HspI5!yIaltGDc&AZ@gck{c*wS9C0X!EC*0MN`MG*Q6NZ{Z_F z<_XL!ow3xK|ko#b!q(V0|tgZ)04Qf!}z8AMa?bpG0=X; zpPc>cMuYGK&o385`fotHA}jIM9r!&CEcXWnt4K*)POBjML9j8s&`F6t&4MVCiG+<4tp<3x#Amr45QSg8RW}p&ZcT z+gb0{ACPMwenpcEHM?UG_ZYX* zDT2D@RR-|_>xSLwGKLfy`~s~gUHQ^Q^fism*D5jthQXXnwrZc;V~tGW@Z zyYFGbD&Np&ZG*EUi|9+80r5TG;{0MK?UkB2NWF(+0ZUQ_S!ed*TEP-GXBNIJ)5+6dU{`c!*CJF!`YH*L`y7v)kg{RNg9X{fKDur)8>uKnk^8HsSQap;Ar; z9G1C=!DF{f0p}c=7|R`p^{Sk6<@{C~$EV*-Cdee68sGdHl^<7afJcLvL|6t~CEJUN zaZ(trk{K`*etUtjBXH0v$GPJHb&{sGd0Qt8F^(g!CG# z(u;7FBcD#g1QYMKhBCbqV#2xNY_?S`MHK=^vYo+3+jld?>UM25bM^BHUYw4PHZ#SV zcE4=6>*n`e9S8kqtXg(peT2=$3hUHHuEestgKkiZvj{RElmB6JkcZ==jYG;o5=!(? zkuSI8uBYv$()7JyIvxSfG5bUb23fb#tx7P%NYR6sWa8QEb84=Ft1T=s*Y;rcIXl-i zC1c8y|H1Y&!=K;W@*>urdyVxm*m&GGZ0M=%*QO*~5Z=`7cF43)M7kA096lJi zFi}(Cq@4(alh4I?*jn?>#CY1uecioXY?~BrAMqt_+lg_+zxz*TJu;*JWrWb%CDHKZ zY}R;LI^%scv}}rhhM~Q|gm;FhwSiEjVSxP{!m!G1v^vVLD#m_3&ahgeWUv6CYHfh3 zG$BETb5Nowel&6>3Cn^98vRI{C?cl-F*^Zpt>sFo|dYfnV ziDr!Sk0}Sv80i+?oeI;1Ej--}HtPZ232yeY>z?$IiQBUcwp!v~#TGh8@`WjBNAwgg zc>+hd6fZjhNB$Hq8v;k~6fb>(^A=f0L3QEHqO1*RVLAH6+GhO0=GITOG4ytatqr^? z4Yz>N>Q9vh`&IgXAHrMpPiKwSrJov3W2jY2*POrZ*aQfC*j&T7UT?g=MTu^&f^8{s z&VC%oA=qiPx6E_1Uw15X4<0lcDtSfcSjH~7TeTb8;BLQO`7rZ3Jn3bY{kn@%wZV3k zezRHnoV$tz7HKSL8J$#QtoT3n_84TqD|^-dvG-|pQ{R0yC%ouy=BaNj>ax+ybE zoCl6cy@PGSoJ)&4(t)bev1Lyqugp_2|IAa+zwVBzBmG@(t4dn^u9@2xZm+I^Awh85 zZsuhxyorL~&XrY7*IYw5-+onx;ZKpp;B&R$MRrB}`O z`WwbWI8g0q6#sU!o$jQYwpU#6pqr~#ob3Quz*ziSC3dk{UsKPzy)u5O@`&3B+ZZi0 zDX98fOVPbNi%@`0csLzOP zXCU)(QZBXTHizyO6U@hw_k*1pWQA!xIw3_fNt?o6sop6r^xFxZJYysCukC;J3Ncc^ z@vv?A!Y0{|3I~Q)n|iuVqSjU<3?v+$ypqmQaNNinO0Ox{{i!Sh!%dhH;ooQs*)uwM z%s(ja*69xUTlO;&(rO7b{|ppGG5L1c@Vi-DF_Qv98dBONq6dZkYIl9KC30I<8QL)# z4;9TeC(Ra_!o~z)IRt(pDMpF~|H%WY*7ELXQJB_shPLAi+vg0a>6#T~)w)LCLnJ1& zYAZWL9a}Xv%Wf>U4zZp6&u=yPd)Y#RXOeO63tg|`l9Xeb7- z`Ym&KS~Yp;S02SW^)D@$sRwd2jq9bv+aoSs!n#B?BK;1H`HkEA&Rp>ZZlmmly@VPI zU7U6klypGE3E(4&&Z$G)ZGKSH)zRIOR}1)sq@t>X>|y(dqlGh1g+03QAp`$e zr+@Wi0^^cyaA{?U!aJ*TRS&Sw9y#GETQl2C#NMA{C}%gOdwSXnV2=bJz8;g`VI1*y zxUs%i@ar3|v)__e^6I2DKAx=e))`;+R136|P3 z8r{hEFEjWkn(}euop<;A@%@WNO{e#ppHGz~S7Fc^#kxxT73VaxZUE;!RjcH4xq9Dd zhssmgJ$D2&K|H6{QmprZc=SQ3!|b zUEoj)yT;CvA30k>+ASQXeHx2UnaU&`OVIA|QHu~IWy;)2VudEZz=U|oWV;#GuCbaRNlxZFM6Z6X|nuoBcvp!DD+IkUcm6MG|;%~Kg)rfvUj|J zc;BW3pZ%cgwDPg5II0!P!lE;rH15@#;&F0%VPl<2U*jnosDMT~8Oo8`;FIc48o$+I zC>x$ksTLydOXX1=EiEg)1?p3X1`h6Q@`HHBo$@XWtC%!4gCgAg{1R+z;kk$WEE>N< z^%T>Z1wl2~k!!uIC22)q59OaG~4UHx^*&}c9#si+pEFx+Q_?6 zkP{+16uDf5XiaxPdpY)^aBQUEJ=4;|P(IQYVpn%ryqYOI@hov!lGiySDj76Be3h)V z1Cq^X)>$e+I|P-fiE!`Ty#Za)KQy^OFN8}$QycF${TO-Q6MhP;X2l1^QAwup4Ohz* z>EqP(MFL={jfqnZzDwTPedg}77Kn6ae4Tm5-Jj*>gU>OY9b=nk9laELY5oy#H2B~{m*yVj=D({l4k3lC%t6;L0(LG!w_+bk z*j%IZH>WHv1qL4+`_$&<(oe8vXJ;F_XxNOpIDrBSYWB6y1rvU`#OdCC@m*&No@>VI z2Y(Ugr9$tp^lDofQjUH7ps>v__pX%<5TcS==_S%9P20W8HDk8#mzuM9Zjp80%nrYT zBh6PM^8&P4>6}cwoXkGYd#fr7qp!-Ttc%2~vCeywHYns9$*$Kq?t_*zvOb9FBtH*u zUIHD2DWvr@)Lg=O4PxBoavV$Ik4_AaDQcGJ1!{H?`H!8C%>`t8DYRr?$fu3TN0%zT z!hcP(m1Dx}3!REcb%OMe6_SOHzIp(w37xBAn6q&)tFK7<0<%D~po|nMlrCSvORxm7H`1j;nQOZGfc~ko{l3%EW2n154$m)*cS+OC54~cfcO3 zO$zRXf02IX6D7bgx;Y?{#BSc8(Z>RN0vBGdwPUS;0Z19d}+NtZnm5<`y;o#P6fzAxBt=g^DKF? zL9EktgL?H7k$$;uw@mrt9ZB|=rL`t+lSQ*Ad$MM6NgMtsoT~yYp>ULM$-TXE0O*@GY1$^Irw!oLCGSOSs@%RbT-I!scBW3JmRLg{?Q@s|_#II1zm%Qm!cvq-U-DV3~)B=A2O#DS{|4vG0aJ0$U z-b@}n9lQE@{&!}8ozte6yd>$H(qpPu7WGfY!285mM5f#ED^-B^c;YbzK6uRAQfv5I zP+(DO)|U92IR%(Da_r=5_dG)T^I>J6HngQhrJvR|&p+j3IsktN6s*5yTw!2%xM z*FfLmtWc_%4REFmaJ?|x^&WB`H3p%hg3U?^684{LpJhGXiMvN4rEO#$?%OkwRQ};} zBYae863&|l-Y_I{BmWA8+$w=3@DxF zh@YlDGdcSg`vW!-7(nn6Xx+UX9@t$6mOgJ@yVq;s4?;T!__dDR;TaUZQv95IoYyK& zdry(~#b0;QV02v*WIyTC(hB)*zU^elhB%PR!D$1%1*Q(1?UWOo?UuDnZ)=}$*}N)= zZ(%?vKKQNs5SE5Gp=iv~cBB^-{7G=iDfa(#^-aNnHeHy(gcIAg?M!St6Wg}m*tTuk znAn_fV%zq9|IKdIp6b5nb1u3XRo#crzdUowi_327vaPBdiDM#!??}m@`<4&?hwg6AML6dA3#2RfLbfWv1qEYyH{CiN5#JsXgN$>gI z8!$omRyMbiE-sfeEy9R}SMw3ghzkV@v?WOv(iv0M+)MtK|8yzrx=nlN%r`8>| z!yhH*RSl=ntL4#pY18Mv6?$pUcSX)M0EL*J^20}Y5=I;w(i@-BI@a7)V!kJ|*ss0J z#_V0`k|Tc0!v>WiV>oR3#h-byl-AGnPVc)_A9VDFdaj z3#O6VzjnO*#HLJ>+(IfRke^kpuIVMFR))ms?lBbY)a=jv9c1L9H)!+0j@7go!zX!-rC{hrqPawraJ7QbM# ze*N!ZKZp%(`8aV`;mGXS@l1c!z$Dcfmv88Qsj&asGtY~%Ou-nrroxN5Owr^{UMqKH z)5y`W-=~{p(@}Mrxsbg2?_g&2A7J8UW9u~fZDG66MeAtmM7gBNE7>P9`W@nS%Kh8! zq?v@_nAl{^{x8a6n5yIb%s+7Xh<)jwYMh};|a`q!kkd_#mCi?0HDeV>p?0e zP|m~_Mvk9LdwhJNI>0jfl65BkI_-*+@ubwmy+|~6X!CIz_MOP6zqEE{&5;fD`J69EBm;x&E!*agI1F&%A|bBKk_b%jmAy1 z&?&aCeno8Gw^Ur#H>(&O7(;4R3+N>7JES`Hz+HK3{7iSOABcwWHw` zz2|a7_SvaGQg$nxZ#}aB!o0 z^nR=vGQH5<4z9NWYCl(Y{fA10)D(W9SG*-XtBGpz@oTMA*K0F$l;Z0v*B0V}PC@%j zFIW6^hT;n+Z@R*{%H~?GP`2)heJ#%#B@XXnxOa%jWb0M!VT!@_dWkpbwE{Q98BtMW z{&k`O{5Q56kg032HHnZoV~<=*JNSX+(>U%tve7IyO_h7J-u3Wa`#7U2#hQ;JOh7a8 zQmB&mBp}JZS)9YqSw9wW*`fHbz|a#HyuOCk%Y)7!lv(i8R8OgNRcoK`<_uQ63eB%* zGm)`M6wkERGNsf(Pt(nk#^UrI(&#cjhTDE?W3JE*I9k`qE?DY%u#XNqe=g8_jOg)1 zANy$n!6$T`RoV3y_8)k|ztp@a9C+)t1?BSbNlj_|sy&`iRoL}*5va%xyVhIcZp2-F z=xe1XvUl8yyep6FAX(4Z$om$*w6D#Fo8Zr}70X*62r#!f=b)v{@qk*#+Px0yV^!p~ z`V!t`z>eCs8~RvoN{o#!kMXbCFJ7ZV=bQFJ=Lp}AJ1HKaS3EwUSF%seLuW-NB>S!? zoR&@S^55fT^j4<~pE0_Y+8qn6@hfzUS{iDVcDhmr5v}!}s_EENiLItS)0j$$4%IH< z9{&)(fOxmc*T^Z(u7{>aud>{}1?F0Y zqdqL|G7i@kt$oj~FspuE*BPhpotFcOKKS#;skUzCjy3(3NZ#@8XG^@BLtmb`d1vT3 z;<0M_zf>N>9+&?btJifS60dB+sFTJjN5kLN>Aq#12=P=7Smy=8D+N47vowH%gK_U> z^5eEBrrku_hD_1PA$NKQ&FQBaR94AyLgy?R|vD<~P(^5%SiDro~l#^>`} z`|Jx=#>5|$DMLz~ZKO((bs^!Ev<|9os8!8z`LbWC75{9O zZ%XT080C4c*sNmv%Wv2;zPL^Sr6>0{xvZ0B?K*r8uBNXjCwQ7{#;4 z8XL}Ugr74F?~2v-aFzFllLx@DeX&%oy}`7GmkpzS4tN%I_c2lf`@koR0BLn|(Q>%w zH4i)MQgP7yjYLC}^KdR74Q-^*JK>%N-8)*`1VT`Lne@d>uhCz^!?$#_2On$pI~fEo zFVRo5cSBYAago`SJSe_cE#4e|UwUYp?e$vPff1&#&ef;TO18$giM8%Cbg#@w}qxh~9n2G1GJ1c3wMg zx`VE=fls}Fm^}RnerlkM&Teg8d7^_uTZc7aUH2~L1n&Bl?oTx8D#0&fg_!M{zS}?g z+FhDgXxp~F3Ttjh&iBCD+dpjeOsG@u_+S6_EEB)}Xuict^}TLfh=Dd8O+U6exi6ae zt*uVg;fbnm>%hM%fGhvUVpebdzS!{xIOcHa#g?^z@2y~d%aI|IoA|~ka}|` zFSgA_roVsenr?HQ*cSD45y$RE)pm!jylrsv=xZMIm$nJ9oqHRQ1n{IQTd%WJ)00;T zxYcxlhip5yuoK^Q^R`!cn|bX;m0no^l;d~Is{?&iUtr!TB_zYq*CNqLanD|=d}@_{ z110LcHG&t%Q9*dqdmG)ocF0#-czFLRa@coe2!GJtmh=j$w*4pBRTl~k!LuOUl_6>y zq@zM>&~jL9dz$pd;Q!dWnj3xT?JeQnqk>_o ztmvalPu8c9s4a=DMk0aeXV%E~(0o_f;WxD4z4%*eZ+eXHAk>j-jO!$%ND;H#`b%Fd z+%EJU>xEkP+TGveBiD!#+Rn|8##izuLeTsGw6w9q!1J+++;;*ur?HY&^|#`KBi;1U zcidlD4&(klS=?+cT<`=F+ z9a}zXW22Zi=Te}}akA+KY&T81b53#S1(Gx~8n$I$tlIqSsU-78v~2Mg zWCUWDlE|K0iMcy_KQ^yMu3=8S{n$7{ops-J$^ZSn`r`j=Z&fR;6#?zDMEn39QKajU zA2GAtx7-{vV`t}RwF_PJRa$olY=GB3Yt|OK&+Hq`S%G{1=BS%@b6X(z`Y*c`@M68N z;I+oLHsAihyu8Q|zl1xsSr{n1k6y!Hn9_jtzMk)kDV_C9z&~*>bZ)&h5Z44r)7LR| zAZMSDXq=INEuk2^tgZ9 zRgu?(>`wgD*X_dI7g1U_TFPw)_}#-Aj$=RiOlHMq9XrnP+VXbr8hiu3HI9jJnk3fG zcJF2#8MTE8BI1W#$68G2Xaq*j(-*}F)-5~oa)dD$gJGP=U6R0y$8Z( zohmux&~7r4`IWx*P?IK8lR<^$?`9l$bOEom-3(;TSm(xsaZD6>TO4yDWSRRW;Lb8aPG4Sd#672 zk=#^^_d};?d~V|)yu@Cr50L>B&jI2A`zV-S+27oQ_t28$kh@Z^!Fxq_$eYNV(bz9P zY178h#1uCLLpMKjJKAvITq^ZkW4vN~*K26}zc}AFd36hXDbMjv*XlwrKYFnX=1)0^ zxG7HV>(tE6b*)8DH~TbS$M@WFU&pit#@LVTHOybX4W!G|x!!xFX~gq?;fB(4aXn>Z zU27ZA>--9ID)1C(aTvKOOQQN@-HZEC@_Z&ukM7!GrBkycY+9xbu$JlZ_8J+A=t967 zT}LjpMeFJsmQ*#moEt5Xe7>F6L?149sH9Culr;+d;zFdvrPWra>Z!F}sa<5eqEo)e zCuq#jdhc%wobAcCrjJpgGlVPtD_oyiXN&&5s}X4)DAS*E@tKC-~+eK~G z+nlI0;HILBulsWrFhAc(S2)#}H@eL!)iPb4=dnPr>kQ^-tS&nBX|{u~J&s{SN2gUE zYi&i5?z7KadZMt-osc4Q?Pc#WTP#;nx1ezqEI&0qzpBf!(e2LGa$c?p5h=T_<7%;9 z(R0CIm^gDwVh6q=BU6^amNuFH~T8iWTEG_WgM* zoJv5nrTQD;-)b8)Z9;U1>WPA^a&D*t_&=u~{i7~kvELtI-+aS>-cTN+iSso{J53lo zuDYYE7)0X0H`~veqhJ4}aJ(hy%tRkohrC&PcDkpS#a$GqBfqBlx@_`VwM?PU2Gn(do8k(2 zA)z0rJ%LdE-ovr;|-Jg}n`dA^0mT`WV{pef%m}5h0 zIle!-&r^9-s94BQp)A||N~ETI)qox?)niNlnFIJ@LsgcL&hOyw8Q>SPgA?d+y$(+7 z3%Spss|If6vxaGp0ah=wzdy`LzE8&ity_Tbs*5V^8!nAw7QlH?&_}12b%e#_((ki|mtbUNAJ);6o^M8uHclk|BpU~-mbX8L8XE3s= zb)J}Ifko*$S($>)x$Wc6GR1#UsIVf;a0)lGCB8(ghF-jIdSkU8u>=L1<)u}CXAZ2^ zTUJ_+yp$bsZcDYuY6KiL3qP&Pr6_}&2d+ER=W@Q3=?j+?9Og=W?&LP~+8M5>SQIB? zl+^9%XUHBew?_i^+&KueDh&rv&+XwO8lodBzxZv2>v_I3V667PJGDr|3c(v0lx!Cg zKmzr7ZHOBnHcg#}+aZ2pEINLNrUL6?Iho8)l?t>R@Y=2hhcYQWVQS^FWp+AsA=Xz_ zEezESTJSBQw#|)U(lc(-wQkbuGIdoaEtVInztDBhN1iH+%#Lzh7ERq@Cu&rWD2^D^ z4IfS#4K7ws(b4zMXhsmU6@SlL>xk7wotM@Gifr+Yr?z_12)E%s9SNx0z5oI_zKA}m z8YwqD_GXDwWCx7#AoK{9{{q1Rv^Z0O{c>a}T*eOKSd-8KaR^x%$7xybN1nG15SKD( zQWl3(>SVP4DMzI)SC(rUYopV4p6oHue6?Pwo3>P4M0QmZs%;#qZ1^2-cyDKMCc!Pn zF&%GtUv+%X@IP-je|JCUUIFdL>{#vA=BJIBn=YPmw3==CDC&!ibpb*4)LUQuZ0i}q zC9drHBQS;LSC5u+C0z~&k9=~tR+h&2zvHC74#{Q=`JI_q7tmcViWW#RLYoE|$G=Hd zo-cescu)lzwfW)FmtwbibLJ&ZV&Ekk0;+w3TNme36q(WVyxx`X?10^^Nh)vEryegw zGW^*@nAm^38v%)eb#=q;hN^$F&aVA%zAir@REqvH&ROhfxtv>%*NnwdYiUT>3F;$0 zt`tsyNh_KsbgcZ@>&$foW7|M*NvM7!>1i;-V5BW3$W7?_ z1z9(Ua&E<}e^e`i`43nl(Yb(ev$q9T?J7v^4uUy2(@Gy~AfB`q(yF`mJaX?NS{IyF z5IXFO(dvUrms7td15G^`(Q;yJJ+#!)m$2LaD4}PsOJIu93(=n)g4`l|!x}N1{W*KR zhaMQYNwZ-`<8+NgcY=8(KW2XU)5R3uwtTREs1kshL{8GJgVZlNOAYjd;qmemVT!|j^%g?q;eErrnaB8Ok{q6z+rBII3Y_X^!E#larq zYCXg|%i)=8i&$j%3#O%yIeb>-2G_&*!1$@nojlbJu?P@*22D6(Mt%_cYBR20E zVa`HM(dGT(Z90rUr!AJXZFc`=JAl7Givi=q+W};S@c!^EbEvs)3j3i8{cqpwy73F- zoD?%@AE%4oSXg+?`CVB27sxImmq)t$})?ZLmO<02iWqRCASB4{PD{d&s+^%cn^M;TJXxe?rbvopzdP zj13^2`;_EG$#c2gzMHBYaiA~f^R*NptGu#UoHu8-XBm;vkyG4lN`-_CHyr*x0^cT| zP?^QKeW6j>C_SK>7+g!SaK=8=;x&!r?~vZEfe2n~9qt_iH03o{-%_#<-1mUBEo4j;ciOB7@0$NU zNsZKPrOm`8KvWQLad$z+qH!6sJ%1xJlz0WGPLYWQellqJ-jG$|ZEmHSE5vHD&e8ut7KiqKLN>1EXxxPqLa6>h9pmzbe8#NaXboQ`=VvFA9j zODzF5qQhA#m7_EEbUEQktd3@3D_&1DH zr45bWntcQv6PBh)EG?1fTK1_1MRK%MWgxZK44fHv7R_&HUaB;5*Z$WD(1!nEBlbWb&^K!ga_S#X77u{fm_w4pQgObrg) z>0rz>WUQQI{Xvd|20|DlW}_tiOH`WG0B?!cSe8BuLPw6k{5V^Nd`%NaO~~WG2IGaf zQYc2_sAz;}C5K4lh=<*n89qH`xZl z5sCl#6K|i$au!@Fe^?IVtt8Hb>p+TcF$fT59i-;hntq;U8y41QHD)q@w7pRts3M>R z7nqph^YEW;bHmS5W5H*AOwXaFi-%c_XEY>(bq@SRKf~J)&g>N41OoaceaQG0@Zldq z#Rq(E717wxZ(T!-`Yk+&wg|~xbqfj{kVv}3LkH$xh(tMIR0%205W)-`U?X(?m0)%G zKmU1xLrcGYXQ-`3N&U!Ajz5RsnC`-M$S5wlR}Z()Wi_Sv-Fc^HLCJ>XWi6!<0LJF* z1&Je1!=0I?)ekPH3(WdL2rsMpM>RBJMs~<(O#4Jzjq$2*31`MQ+MeSRFUylQwgN+v5Z>LeEA#wTDsCjse|Qg9wlC^eQee_=#AiRt^h(*nn#lBJdZ2(TAXccd>xM zq(@VDDDna#PPVzKXp`z3n)QVmMlZnRaXcV3Ir!NvlQsC60jeuE`Nmecxx)pgKfFAg zZ6?J<139YO2op#(r_3eZ8hp<)b~D*Iuo$5z%A_g|uwz-mS|pI68BlYB7bB9O^_dRY zYzUrdibE_sEjvKEAc#_CM{Z}&1iA&hde;!TAdWp+NGB14Ge)<~C^T@Z)zdBuf&S67tAbTgjH4Dm?Y45-Zh%o&X3r_NY~o|WqbOCrY~ zN+S6&IS5AUQVrXI;E7BAChKNjfFD2z)J0wEM0fDNzCs|42qqyg42)a2iV*d1f&($b zf;PIrrI;jPxsFv&uhp6lS4OS=yFUI1p|lGJ+~CmVM^hJWn@CW;9IQtcEY1#t*wnUX$mTj_RzeXQ6F}p!ljdb#prZmiXtk@OE5cMM-~;V)9pht3cS6 zGKOO*oxyx`DR>?SVrf<0u98ZIa(8YAct@C8Z5IJ9=A8M0#V^dNg>!cnXHgFuVpVn00{M+$@cvShc$)d)E4-<%i*gY9ToDX7v)1jureB(9)Ng3GAV zl{q@cqT(sdW;C(#7EU80tHo%+A#)6r0O_mF63)V7^pWI0McFxfE}5nTCP_G^7TLk^ znCyzu{4L|lgJ}*sVKoZvD0Gj0MN3cc=9x@J$wM?yc>~;nOA}K=v@>odD^n)^2R-id z3nW@SG96v4`6|N*x>~X7-N4LJ$^aPaIw_c%kS{j1VFy?ZW>?645LmejLu`mFZ$|}Lf^&ahj323skKteW%sTlT;Y@aN zi=*EgT(vR@g}>JuZ!^)_nv>u)%F^37MB#mx&{9>8P^-JGz*PG+qS}6crg938OuutV zPSJlYpzJb@D;Z!?z&^Jn#gyzx1K7VxAgbrUs5gTWWMzKX^s1glim{YHfnz#@Uq*(& ztcKI)=)s*V_)@-j-&<`z~Pi>VC!T@yBk$_-BdhSk%}vB zILSq}=_TzgFNg%H?6|0q=xzF<^JB3(I&wp+#4BZ<)PAJuoX!ith@+HS0YxnqKwWeB zI(1}Q?3N(07_NAUT69jKgXM<&vMz0av+X_P7*w-VHWtJydsWb8sMR4nD@>T!%t`Wh zHp>egRcWMKQS$hem$d z{a;=H4}BH7AObTS&f^z^S$$B;)` zWpN_8)gbi?kz+g>VAA-fUqXq38;{aw`k1J~J^takJBwZ?XZpug*GHB3^yl7uKjKf3j;hu!Gto3(RPy(GG2XcBsUXWIkaSJ zuoW^m;WahRt~tiwjW>+v%xEZH4mga6V#Zg;3=gpp7S~(ha-e%B5tR@kZx6?y&mDnb&_Fql$eUdXzU5u8Lr3s9e*6Jm#tN~Y8YiS>gA z7$_M06u2gG1^;vpDfBsRuM_x(8y;^3RoT~-l?*Q`0&MQu4u#=PFUNAd)!;iG9rNz} z;<%{^Y)3&Vvw~=C+I?J2 z8y8AaAV?>gDk=hAAgGfK2?`FrE&*w5VyVh3>-THkCjG8iKR4N(db+sPLrTITI(f9y z>xL*o6&MkoC#DL-H{uu-zD@WLqF|z2RO`Oo3}kCCU_(2@b!2J|uVh&!yZ{wm#+2I# z9mO|Gmud)HUQ_bzClS1%vcHYmpGkM!iy5T}8Nre8ny+sS@AsZ9&SV?qOd=l+#VM>0T}%3peBHS7#94vt;cLTcXT)Zf~4x$ zk}5N;{?%Bxd4c6iJ&+(Df1dDFKK$_YOL_XnO#)c}@mYc-AOLH^n{UGw{~qzjn?ZPu z30i?I(wP=jZ|<NXbbJPwN(l~ID^zP^hCWc#=TpekRQ#>jA-XzRj z?~LaLOD+GfGV*9yZ8)R&p#|wzR1F=^1+WiW<@F&M*pRFcKNefXzMBHD?4d>IS(4mc zX-HQjx&0o`W46VXOii>!a%R=*lzTgFNqKawMlTth1Q4L92njR|?0f?}YX}XywD7Kj zjIuf+yLLYe_pps9Nq6Gn8w(sne}o3u(0a6nN*yBQ7Ty5S4L1*RE=HUI(w!uaXtf#dV2C$%?0 zcLgewS7EXZ1PacSSZf%QCXdFzQO{StA;;CX`W{k|<>{!de``3uZ4GIbivVTfVK&lKZf zMR0(H3v%!#9#)Yff!5Qq08)D?^8@l0YnCU%L3!GsC5Cxw0rBaj*;uorI5wNs_AfZa zK4n(o4e#?oZEliF@^*I!yS4<%ttzbjNRVmtsQz?NyU{`$9;Y!cNOvue71Qse^*3^| zNlrR+{-5wBmAPXjhL$W4l70(9d?)h}E4|SoXDIizUP}~_Pv>JK04}OVsq7R)DdrIt zY+?}V%3(C|h_o6BU}qG67Mvh8+DC*~6A-S4|wUj>R#BzrTxJBNhx&rfM za)Qb^yqV`^^5di7Iy7n_=%qt3(+Il3B8upt+NxLWfIqet&rfz!DM7T9Zd+*E-Ev~~ z3JZ%z(5h8plz&(8Ks1zgEMF37<_JM?Li6BwlxH7?sGRmt8ShBwu7$ZYN1Y3d2a(1Y)r+*e#&R@s$mI6eq z#A|jOCYe=RJcrgfRNFnyIXU$B@}ES6qPBT zq%0e}YsOl-dU=^ktnL*PP45vV9RqPvG)Oz;ddLxVWhrW=J)FdL$5a)rl~=E9Rm$7D z6b@U*>gaWzaEX;Oqs9*!4v!93^SP{MalT?k)flw1{u)W;D*x3}81gz6nv|*FN^9(m zL$Jo@BNHrEfB-_FSgMF61p@mzXxv+M!_*)soNYRe$zy6lnb~MVZBKX1U(q{PiQ~9K zqClt^j5tXjv1?kyXKI(H63=RrvaEpEXy~my3KO9}Ze?Pbc}blgHm8Mmwre=VQ0eCi zmShK}pnqJY*RHF4y9Ya~!;)t!4mHboKz4a?T9^a0g|I6|~MAq;O&M3n9b zS*(O~|M~znyX8s#=?3K1AM+(lw;$d8Yb0C}&>SqpqtIyyL2}l>{&`PqUd0Y&jYH4Z zEZWRdd#TLQtvFa|-d205Uj2`wo~ricot{h`x2_RFb_DhT(h%RQ5$8|v@IMPV9V%pG zPp$cWC&>#VB^z25HjY44+eEqd&rYk&LedmEt7t-|lqNy5X!H$Q1y}QqFAzpn^z=MN zAnJEWekRNMpM4mQ5@uA2!LiZ6HOe@o=pj%xreC)HW4Eke6lxn9rmtxHQ}08t0ct$M zXSTLKBQ?V;r;Qw$1J&goH3R0D95Wb2tGlyL=LkLetb{xM^B}VDZ2Z{8Na5FNfON~Z zv&nTavge*^^oeK2!47KU>2%B#Vqk^52fPWYG3koxIj%9W84c6B?T}8+Vdq*ccP+$i zh-(dip%L~?6P+L1CfWM1$L#8V*U&UH59y%QB!vFUQ8Jl=IB)^s)bP|^Wyx5{+g9CQ zVf-y_Q~4)!X!LNLWTe5P`LG@rinXF!vuV#!79q2!ZTL}Vr1NLQU7##3%0Cd?Vc=LH zbhvp_NJ27+;teWdq@3wCPT;eO%bgrnQepxn@% z|SQakRbAIj#!9v1XSgt}apX5e01ED?%}4vGTkfW*~Iv2O4FC)QhPk zckRE@Hwh{9E0;BqtdMFs_r3i%Ct!#9XT^b^;X$PsPXe79=5pdvr74GVrXhW=TpL3v49JbVmJNMe`|{3Uu*c=-up~C{=ZB(rc+L$usHw~T z;gJ}Pes^OJe8^%|=v+i=#Sb}#g|WGV5Z7jf7Id~!8s+8qHm13I3f~y3A;7slQMy0U z7zNpG$zRMWghswB+#1v3_D(m@atkdyJRx4F6Ei?<*mJQ&&McfUW#!^3nFTgRo;rgW zNnAl%u8M+)9UD?Y+Tx?6B4o)|Yyu@*Y)7Prwse>V`2gigzTr0r@in-hKe1}s)sU^= zsF?9WJi>lxWwsN^i!+mbIpZHs!?Wzff=@0e$**ybzfaYB z@>Tg-8`!E$OW39hzs1Re2QOG?%KK@c;fnL=C=BghmBpCRi+X{xnLg zN5o+wr*ZH|lku?znb!Q3Z&~JXy`RPM(Hi*&Lu5z)b5A*Fu7g*x_7sppzg(1_?)OTA z4Ag@;ztjEmvy_$pH{T(d+7z;R;LxP7PfCCH1rKP2`PV?nnKIN&hjNYMP6v%E$=&%P zYygK?fz4T+iJo0<5)THIW(kK@`JNb!0@XujnC)(o*zaQsdNgB&6W6sp{@>-XCAna! z_C25|4PG&QgK^4W*!w^Mh&+$rHRq^l(&dH7@g8WP)d3>dMdB+c=}}EqXSGogeHG{# zSA?_5vL*MTsjJU|>?y?Sjz&Ykqk)v>CKgl#-jA#_TUR$>^QZIM?A(S&VX)ON&^tp!EP8GdVQHaVs>-e{@kr#JPHZZ zu5&P`m8q;lWYj=hW_w-@tDMFNK}V3`sBZ5BSg2-`UyL5xp7rBikpU(arbz|zf%&_b z_p+%G=8XBlG_U@bqiOgt)S;aZN&F%)U3hJ~rSKuZkPuJ;dNMfn*`)S0yP{ zQ8%!YYz2KjGRFAHOd$}C$t&_|t5oFCp=XOmw;~mz)C~AMW5+%=5-(3qhxSa-yPk(E zQTAola2{z_N8NLcHkS6M+Ycq0ic3u{C;S|n+B8yVVu)+uIH7^lnQhc13S!)jAJ@B) zcoB^XN!Aj@o)Jqnq%VyAAek6rEUYK$9qKoERf|+=mS)Ci9Bp{Lx1%6>j%;CmGTns* z`{{slqX{Ue6JNlFIohp`a4R!0|Cxfl#6mQfi)S86C2Jl|a7Bj{m$fc2h(4BON@bl2 z4fYBr(tu_{s7E0nEus4DAlNoYf`880 zKot#uTHQNyB`Lv>IW`gx*d7MY9X5#$2x)4T- zx3syg99R?`eU-IkGA=j9p3@WL33oRRDv#o)SPuh{rXR*oVz4m{vBYMJni94~kL#5B z%>d*i78IR14F_esyr6C{le0(84@WUmr-dx8k*NrF&}}uHtEo_<2ipr*!&MqyE+mdU z*CP%J?|V|b$kRC+h^dU^K~U$cOuen&z@eut%ew&HZ<`9ckAesw~Cq zwVFh=LSn0fSi$nhJaVm@-u5fD&ushRzp$7!KRVgq4v*xfjUa9H*5POeU75;Jx}tUd zS!}VsiR!!N#`W=|B4rgD*QG!U5{yQXswCi_{yJW?7@wf%PM}B+;(sAn7pF3 z_VM1@Zq2!~DK1^l+?ruc%pA!o^;FLtsce;NuE{hC^^0|$9h@5x>y9UyZ9hdA(=6m$ zSXvgu)rU}4T9^L`-@*%Zf(&gNsR*Pl=sN1JNyna+Y{08jxrQEk=BZ8l%5mprEUL@k z;(FlIc*_4O7tVH4O|huMb)adX+1pRxbUZfu>04xBY7Bp@-!vRyMWDCzA|~aTAc~aeiKa zItlTvxW0pfa3Ca4l+g<5{;{XxT`}o>=ojvB8^k6w8xWVKoDlxqlamIedpD&=`Q<+0a*sArJsL?2g_63*<)x7 zg{~#KVheOeoQKsYsq)3P4Fm*&C+9J%%Wm8e+03{7&Jv$|gOyLg0+Rrk9g;v_LWmoh zKoP>C#OpbIp{QBX^j9ojLjFEX76C39+LMgWWc8Qgt3z$g+&PA;WdT9rcUA?#r?g)P z9aDTEYa;w5#&IBAK|71YAW#IuECf(Dcl&5(Oi+sttHd-ZU7Meir@)>2qRQyD2wJ+^ z8+Q5pT3Pu4ziY7cLW+XY*z-wQ{snh?n}5s&a)eZN?*ZtvW(ywZn-sIf~%QmEd*=r0zG=zf0v!g!_O zs`zw*PcVNK=vX{(2jIW0V+7;+LKk=WsKWXdI}(WdT03S!@h6iSH-%li;A(vYG1=w+ z@_a*aIX`ZY5Qwv1P8rGHqO4z*5RmDE!S+oxbvd9aE>+bPPrD8@`-Dg7c1q3--Vl(e z2=iK)WZ|Igi)na|{d+BF+6T#ub>~0%k^?4AC(vu~c0dk0v4|8?(Z+(S! zULPxpLKPQEbl1$X4`=pDc$<`dGKf+SJNEI+-%#kw*5&m`ctbm{D^$I|&y!GGR(rRV z_MVaj0ArYA=k#fEixT(mb0TcUZOVZB55yOZ8+K{{-1{by^9~Q&N%YX1kq-XRjv=#JvlYe*& zyVq+`J_qpJ=0Ut5cYcGy9EHg2JohwW{@IWK1ug)jKaM|+36t~FCIrG-{=>Na`a`;a z9gzzuKd*ua>Ve*G`a=TiPxwBf%*#RpFwn^G3#=x`TXaC_*;sUn;Kz{R4@`oj@O64$ zp%dCux+8&iGKD>EYtLvq zaR6SxPVV3i?^`q3Yb+qmLpkppMvlmTBQ;0N=qFrBci*m`KN7k?+7d>s=V z{b3%5SKU4oYxgN@2%nT>yXT7?;XZgs1IWjDc_}}U7kpq|=mVZWD+P#`CW$Z?rhYD* z6J^fID2J$iiA(x65LY`1;SNK7IUniHExPol| z13NspS)?Np9T+1`%4Xv8MROd%7I0bW+{G+bgBFTbcD*KzxCtKZzy0dXB!*vnGOYCF zH|~Ap>-+HXzVYzqee+PQc9fpkbD`hKUxB&iQJ=|2;e%6lQ^F6bH!2MzTXcKvTfA!L z8HB;`A_@T6*rnx?&&Ln_t;h;OhqYrpc0hLU{bxc z=fdheq3V)Q$M*Zg>YXwTSbU-?Y`X)JC+8nrrPVvDJ5N@fEXLKeF;3 zfC#0T;J0;_T96)VyrhiJS$uMiOu1{(P1vY&$Nysbz8bIl`m-(Oq167Z(Y`>~q5UfH zg&-o(&dK{>kACY8-6qj4GSH*le*I1gr0sp~Tm@|xfqhf&5}RVwoAVuU=Du z-^MpGDJt)uRlz;ZDe+`GQzSmJJ2gO}!*&cG=!bW(4BoZ#FaX<|1?h-WKIUj_4BVdHYp@l@9zsr5Z?too#{Vv2WgZT1} z^*EDE3)*)4^9{Yh?Vx~p`(BpLxiiC{$VdsPKjMvG!GJce%K+O&i(!|iw#_8E0w#y3 z+s;qwZx#6R{9h~<rnpZ$5o-nq`Q5D z%lnV7qW>lf|HkeeMZ19#{SXm*Z4Y@5{sZW=E5JUZ4s0qat=bM?AEK>yXmK=KStzSycxa<6aw#Pei`$jSP!W}C^CLwzjo^RGyy$#2aOxz^?gOe3l zzVDd@>rF+1zxP47d%kb{y7V0TJG;#p^@bM q>;Hpn|9>=*x9w;AE!& delta 25645 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