feat: sync scrcpy

This commit is contained in:
rankun 2020-06-14 14:13:50 +08:00
commit fc8f465ea2
50 changed files with 1480 additions and 307 deletions

View file

@ -32,6 +32,11 @@ msvc{
*g++*: QMAKE_CXXFLAGS += -Werror *g++*: QMAKE_CXXFLAGS += -Werror
*msvc*: QMAKE_CXXFLAGS += /WX /wd4566 *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 += \ SOURCES += \
main.cpp \ main.cpp \

View file

@ -135,7 +135,7 @@ void Controller::onSetDeviceClipboard()
if (!controlMsg) { if (!controlMsg) {
return; return;
} }
controlMsg->setSetClipboardMsgData(text); controlMsg->setSetClipboardMsgData(text, true);
postControlMsg(controlMsg); postControlMsg(controlMsg);
} }

View file

@ -29,9 +29,9 @@ void ControlMsg::setInjectKeycodeMsgData(AndroidKeyeventAction action, AndroidKe
void ControlMsg::setInjectTextMsgData(QString &text) void ControlMsg::setInjectTextMsgData(QString &text)
{ {
// write length (2 byte) + string (non nul-terminated) // 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 // 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(); QByteArray tmp = text.toUtf8();
m_data.injectText.text = new char[tmp.length() + 1]; 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; m_data.injectScroll.vScroll = vScroll;
} }
void ControlMsg::setSetClipboardMsgData(QString &text) void ControlMsg::setSetClipboardMsgData(QString &text, bool paste)
{ {
if (text.isEmpty()) { if (text.isEmpty()) {
return; return;
@ -68,6 +68,7 @@ void ControlMsg::setSetClipboardMsgData(QString &text)
m_data.setClipboard.text = new char[tmp.length() + 1]; m_data.setClipboard.text = new char[tmp.length() + 1];
memcpy(m_data.setClipboard.text, tmp.data(), tmp.length()); memcpy(m_data.setClipboard.text, tmp.data(), tmp.length());
m_data.setClipboard.text[tmp.length()] = '\0'; m_data.setClipboard.text[tmp.length()] = '\0';
m_data.setClipboard.paste = paste;
} }
void ControlMsg::setSetScreenPowerModeData(ControlMsg::ScreenPowerMode mode) void ControlMsg::setSetScreenPowerModeData(ControlMsg::ScreenPowerMode mode)
@ -124,6 +125,7 @@ QByteArray ControlMsg::serializeData()
BufferUtil::write32(buffer, m_data.injectScroll.vScroll); BufferUtil::write32(buffer, m_data.injectScroll.vScroll);
break; break;
case CMT_SET_CLIPBOARD: case CMT_SET_CLIPBOARD:
buffer.putChar(!!m_data.setClipboard.paste);
BufferUtil::write16(buffer, static_cast<quint32>(strlen(m_data.setClipboard.text))); BufferUtil::write16(buffer, static_cast<quint32>(strlen(m_data.setClipboard.text)));
buffer.write(m_data.setClipboard.text, strlen(m_data.setClipboard.text)); buffer.write(m_data.setClipboard.text, strlen(m_data.setClipboard.text));
break; break;

View file

@ -9,8 +9,8 @@
#include "keycodes.h" #include "keycodes.h"
#include "qscrcpyevent.h" #include "qscrcpyevent.h"
#define CONTROL_MSG_TEXT_MAX_LENGTH 300 #define CONTROL_MSG_INJECT_TEXT_MAX_LENGTH 300
#define CONTROL_MSG_CLIPBOARD_TEXT_MAX_LENGTH 4093 #define CONTROL_MSG_CLIPBOARD_TEXT_MAX_LENGTH 4092
#define POINTER_ID_MOUSE static_cast<quint64>(-1) #define POINTER_ID_MOUSE static_cast<quint64>(-1)
// ControlMsg // ControlMsg
class ControlMsg : public QScrcpyEvent class ControlMsg : public QScrcpyEvent
@ -48,7 +48,7 @@ public:
// position action动作对应的位置 // position action动作对应的位置
void setInjectTouchMsgData(quint64 id, AndroidMotioneventAction action, AndroidMotioneventButtons buttons, QRect position, float pressure); void setInjectTouchMsgData(quint64 id, AndroidMotioneventAction action, AndroidMotioneventButtons buttons, QRect position, float pressure);
void setInjectScrollMsgData(QRect position, qint32 hScroll, qint32 vScroll); void setInjectScrollMsgData(QRect position, qint32 hScroll, qint32 vScroll);
void setSetClipboardMsgData(QString &text); void setSetClipboardMsgData(QString &text, bool paste);
void setSetScreenPowerModeData(ControlMsg::ScreenPowerMode mode); void setSetScreenPowerModeData(ControlMsg::ScreenPowerMode mode);
QByteArray serializeData(); QByteArray serializeData();
@ -90,6 +90,7 @@ private:
struct struct
{ {
char *text = Q_NULLPTR; char *text = Q_NULLPTR;
bool paste = true;
} setClipboard; } setClipboard;
struct struct
{ {

View file

@ -303,6 +303,8 @@ void Device::startServer()
params.crop = "-"; params.crop = "-";
params.control = true; params.control = true;
params.useReverse = m_params.useReverse; params.useReverse = m_params.useReverse;
params.lockVideoOrientation = m_params.lockVideoOrientation;
params.stayAwake = m_params.stayAwake;
m_server->start(params); m_server->start(params);
}); });
} }

View file

@ -35,6 +35,8 @@ public:
bool display = true; // 是否显示画面(或者仅仅后台录制) bool display = true; // 是否显示画面(或者仅仅后台录制)
QString gameScript = ""; // 游戏映射脚本 QString gameScript = ""; // 游戏映射脚本
bool renderExpiredFrames = false; // 是否渲染延迟视频帧 bool renderExpiredFrames = false; // 是否渲染延迟视频帧
int lockVideoOrientation = -1; // 是否锁定视频方向
int stayAwake = false; // 是否保持唤醒
}; };
enum GroupControlState enum GroupControlState
{ {

View file

@ -124,12 +124,29 @@ bool Server::execute()
args << "shell"; args << "shell";
args << QString("CLASSPATH=%1").arg(Config::getInstance().getServerPath()); args << QString("CLASSPATH=%1").arg(Config::getInstance().getServerPath());
args << "app_process"; args << "app_process";
#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 << "/"; // unused;
args << "com.genymobile.scrcpy.Server"; args << "com.genymobile.scrcpy.Server";
args << Config::getInstance().getServerVersion(); args << Config::getInstance().getServerVersion();
args << Config::getInstance().getLogLevel();
args << QString::number(m_params.maxSize); args << QString::number(m_params.maxSize);
args << QString::number(m_params.bitRate); args << QString::number(m_params.bitRate);
args << QString::number(m_params.maxFps); args << QString::number(m_params.maxFps);
args << QString::number(m_params.lockVideoOrientation);
args << (m_tunnelForward ? "true" : "false"); args << (m_tunnelForward ? "true" : "false");
if (m_params.crop.isEmpty()) { if (m_params.crop.isEmpty()) {
args << "-"; args << "-";
@ -138,6 +155,24 @@ bool Server::execute()
} }
args << "true"; // always send frame meta (packet boundaries + timestamp) args << "true"; // always send frame meta (packet boundaries + timestamp)
args << (m_params.control ? "true" : "false"); 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
// <https://d.android.com/reference/android/media/MediaFormat>
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 // 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" // mark: crop input format: "width:height:x:y" or - for no crop, for example: "100:200:0:0"

View file

@ -34,6 +34,8 @@ public:
QString crop = "-"; // 视频裁剪 QString crop = "-"; // 视频裁剪
bool control = true; // 安卓端是否接收键鼠控制 bool control = true; // 安卓端是否接收键鼠控制
bool useReverse = true; // true:先使用adb reverse失败后自动使用adb forwardfalse:直接使用adb forward bool useReverse = true; // true:先使用adb reverse失败后自动使用adb forwardfalse:直接使用adb forward
int lockVideoOrientation = -1; // 是否锁定视频方向
int stayAwake = false; // 是否保持唤醒
}; };
explicit Server(QObject *parent = nullptr); explicit Server(QObject *parent = nullptr);

View file

@ -400,6 +400,11 @@ void VideoForm::updateShowSize(const QSize &newSize)
if (isFullScreen() && m_device) { if (isFullScreen() && m_device) {
emit m_device->switchFullScreen(); emit m_device->switchFullScreen();
} }
if (isMaximized()) {
showNormal();
}
if (m_skin) { if (m_skin) {
QMargins m = getMargins(vertical); QMargins m = getMargins(vertical);
showSize.setWidth(showSize.width() + m.left() + m.right()); showSize.setWidth(showSize.width() + m.left() + m.right());
@ -570,8 +575,10 @@ void VideoForm::mouseMoveEvent(QMouseEvent *event)
void VideoForm::mouseDoubleClickEvent(QMouseEvent *event) void VideoForm::mouseDoubleClickEvent(QMouseEvent *event)
{ {
if (event->button() == Qt::LeftButton && !m_videoWidget->geometry().contains(event->pos())) { if (event->button() == Qt::LeftButton && !m_videoWidget->geometry().contains(event->pos())) {
if (!isMaximized()) {
removeBlackRect(); removeBlackRect();
} }
}
if (event->button() == Qt::RightButton && m_device) { if (event->button() == Qt::RightButton && m_device) {
emit m_device->postBackOrScreenOn(); emit m_device->postBackOrScreenOn();

View file

@ -109,6 +109,13 @@ void Dialog::initUI()
ui->formatBox->addItem("mkv"); ui->formatBox->addItem("mkv");
ui->formatBox->setCurrentIndex(Config::getInstance().getRecordFormatIndex()); 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->recordPathEdt->setText(Config::getInstance().getRecordPath());
ui->framelessCheck->setChecked(Config::getInstance().getFramelessWindow()); ui->framelessCheck->setChecked(Config::getInstance().getFramelessWindow());
@ -186,6 +193,8 @@ void Dialog::on_startServerBtn_clicked()
params.useReverse = ui->useReverseCheck->isChecked(); params.useReverse = ui->useReverseCheck->isChecked();
params.display = !ui->notDisplayCheck->isChecked(); params.display = !ui->notDisplayCheck->isChecked();
params.renderExpiredFrames = Config::getInstance().getRenderExpiredFrames(); params.renderExpiredFrames = Config::getInstance().getRenderExpiredFrames();
params.lockVideoOrientation = ui->lockOrientationBox->currentIndex() - 1;
params.stayAwake = ui->stayAwakeCheck->isChecked();
m_deviceManage.connectDevice(params); m_deviceManage.connectDevice(params);

View file

@ -7,7 +7,7 @@
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>420</width> <width>420</width>
<height>492</height> <height>517</height>
</rect> </rect>
</property> </property>
<property name="minimumSize"> <property name="minimumSize">
@ -106,6 +106,47 @@
</layout> </layout>
</widget> </widget>
</item> </item>
<item>
<widget class="QWidget" name="configWidget5" native="true">
<layout class="QHBoxLayout" name="horizontalLayout_7">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QLabel" name="label_8">
<property name="text">
<string>lock orientation:</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="lockOrientationBox"/>
</item>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</item>
<item> <item>
<widget class="QWidget" name="configWidget2" native="true"> <widget class="QWidget" name="configWidget2" native="true">
<layout class="QHBoxLayout" name="horizontalLayout_6"> <layout class="QHBoxLayout" name="horizontalLayout_6">
@ -290,6 +331,13 @@
</property> </property>
</widget> </widget>
</item> </item>
<item row="1" column="4">
<widget class="QCheckBox" name="stayAwakeCheck">
<property name="text">
<string>stay awake</string>
</property>
</widget>
</item>
</layout> </layout>
</widget> </widget>
</item> </item>

View file

@ -17,6 +17,9 @@ static QtMessageHandler g_oldMessageHandler = Q_NULLPTR;
void myMessageOutput(QtMsgType type, const QMessageLogContext &context, const QString &msg); void myMessageOutput(QtMsgType type, const QMessageLogContext &context, const QString &msg);
void installTranslator(); void installTranslator();
static QtMsgType g_msgType = QtInfoMsg;
QtMsgType covertLogLevel(const QString &logLevel);
int main(int argc, char *argv[]) int main(int argc, char *argv[])
{ {
// set env // set env
@ -38,6 +41,8 @@ int main(int argc, char *argv[])
qputenv("QTSCRCPY_KEYMAP_PATH", "../../../keymap"); qputenv("QTSCRCPY_KEYMAP_PATH", "../../../keymap");
#endif #endif
g_msgType = covertLogLevel(Config::getInstance().getLogLevel());
// set on QApplication before // set on QApplication before
int opengl = Config::getInstance().getDesktopOpenGL(); int opengl = Config::getInstance().getDesktopOpenGL();
if (0 == opengl) { if (0 == opengl) {
@ -136,17 +141,53 @@ void installTranslator()
qApp->installTranslator(&translator); 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) void myMessageOutput(QtMsgType type, const QMessageLogContext &context, const QString &msg)
{ {
if (g_oldMessageHandler) { if (g_oldMessageHandler) {
g_oldMessageHandler(type, context, msg); 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)) { if (g_mainDlg && g_mainDlg->isVisible() && !g_mainDlg->filterLog(msg)) {
g_mainDlg->outLog(msg); g_mainDlg->outLog(msg);
} }
} }
if (QtFatalMsg == type) { if (QtFatalMsg == type) {
//abort(); //abort();
} }

Binary file not shown.

View file

@ -49,17 +49,17 @@
<context> <context>
<name>Dialog</name> <name>Dialog</name>
<message> <message>
<location filename="../../dialog.ui" line="429"/> <location filename="../../dialog.ui" line="477"/>
<source>Wireless</source> <source>Wireless</source>
<translation>Wireless</translation> <translation>Wireless</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="513"/> <location filename="../../dialog.ui" line="561"/>
<source>wireless connect</source> <source>wireless connect</source>
<translation>wireless connect</translation> <translation>wireless connect</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="529"/> <location filename="../../dialog.ui" line="577"/>
<source>wireless disconnect</source> <source>wireless disconnect</source>
<translation>wireless disconnect</translation> <translation>wireless disconnect</translation>
</message> </message>
@ -69,13 +69,13 @@
<translation>Start Config</translation> <translation>Start Config</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="127"/> <location filename="../../dialog.ui" line="168"/>
<source>record save path:</source> <source>record save path:</source>
<translation>record save path:</translation> <translation>record save path:</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="144"/> <location filename="../../dialog.ui" line="185"/>
<location filename="../../dialog.cpp" line="325"/> <location filename="../../dialog.cpp" line="338"/>
<source>select path</source> <source>select path</source>
<translation>select path</translation> <translation>select path</translation>
</message> </message>
@ -85,47 +85,57 @@
<translation>record format:</translation> <translation>record format:</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="282"/> <location filename="../../dialog.ui" line="323"/>
<source>record screen</source> <source>record screen</source>
<translation>record screen</translation> <translation>record screen</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="227"/> <location filename="../../dialog.ui" line="268"/>
<source>frameless</source> <source>frameless</source>
<translation>frameless</translation> <translation>frameless</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="289"/> <location filename="../../dialog.ui" line="127"/>
<source>lock orientation:</source>
<translation>lock orientation:</translation>
</message>
<message>
<location filename="../../dialog.ui" line="330"/>
<source>show fps</source> <source>show fps</source>
<translation>show fps</translation> <translation>show fps</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="386"/> <location filename="../../dialog.ui" line="337"/>
<source>stay awake</source>
<translation>stay awake</translation>
</message>
<message>
<location filename="../../dialog.ui" line="434"/>
<source>stop all server</source> <source>stop all server</source>
<translation>stop all server</translation> <translation>stop all server</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="563"/> <location filename="../../dialog.ui" line="611"/>
<source>adb command:</source> <source>adb command:</source>
<translation>adb command:</translation> <translation>adb command:</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="599"/> <location filename="../../dialog.ui" line="647"/>
<source>terminate</source> <source>terminate</source>
<translation>terminate</translation> <translation>terminate</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="586"/> <location filename="../../dialog.ui" line="634"/>
<source>execute</source> <source>execute</source>
<translation>execute</translation> <translation>execute</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="612"/> <location filename="../../dialog.ui" line="660"/>
<source>clear</source> <source>clear</source>
<translation>clear</translation> <translation>clear</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="272"/> <location filename="../../dialog.ui" line="313"/>
<source>reverse connection</source> <source>reverse connection</source>
<translation>reverse connection</translation> <translation>reverse connection</translation>
</message> </message>
@ -134,17 +144,17 @@
<translation type="vanished">auto enable</translation> <translation type="vanished">auto enable</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="256"/> <location filename="../../dialog.ui" line="297"/>
<source>background record</source> <source>background record</source>
<translation>background record</translation> <translation>background record</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="220"/> <location filename="../../dialog.ui" line="261"/>
<source>screen-off</source> <source>screen-off</source>
<translation>screen-off</translation> <translation>screen-off</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="189"/> <location filename="../../dialog.ui" line="230"/>
<source>apply</source> <source>apply</source>
<translation>apply</translation> <translation>apply</translation>
</message> </message>
@ -154,37 +164,37 @@
<translation>max size:</translation> <translation>max size:</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="240"/> <location filename="../../dialog.ui" line="281"/>
<source>always on top</source> <source>always on top</source>
<translation>always on top</translation> <translation>always on top</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="182"/> <location filename="../../dialog.ui" line="223"/>
<source>refresh script</source> <source>refresh script</source>
<translation>refresh script</translation> <translation>refresh script</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="403"/> <location filename="../../dialog.ui" line="451"/>
<source>get device IP</source> <source>get device IP</source>
<translation>get device IP</translation> <translation>get device IP</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="302"/> <location filename="../../dialog.ui" line="350"/>
<source>USB line</source> <source>USB line</source>
<translation>USB line</translation> <translation>USB line</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="358"/> <location filename="../../dialog.ui" line="406"/>
<source>stop server</source> <source>stop server</source>
<translation>stop server</translation> <translation>stop server</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="348"/> <location filename="../../dialog.ui" line="396"/>
<source>start server</source> <source>start server</source>
<translation>start server</translation> <translation>start server</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="338"/> <location filename="../../dialog.ui" line="386"/>
<source>device serial:</source> <source>device serial:</source>
<translation>device serial:</translation> <translation>device serial:</translation>
</message> </message>
@ -198,20 +208,25 @@
<translation>bit rate:</translation> <translation>bit rate:</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="413"/> <location filename="../../dialog.ui" line="461"/>
<source>start adbd</source> <source>start adbd</source>
<translation>start adbd</translation> <translation>start adbd</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="393"/> <location filename="../../dialog.ui" line="441"/>
<source>refresh devices</source> <source>refresh devices</source>
<translation>refresh devices</translation> <translation>refresh devices</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.cpp" line="101"/> <location filename="../../dialog.cpp" line="105"/>
<source>original</source> <source>original</source>
<translation>original</translation> <translation>original</translation>
</message> </message>
<message>
<location filename="../../dialog.cpp" line="112"/>
<source>no lock</source>
<translation>no lock</translation>
</message>
</context> </context>
<context> <context>
<name>QObject</name> <name>QObject</name>
@ -226,7 +241,7 @@ You can download it at the following address:</source>
<translation type="vanished">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:</translation> <translation type="vanished">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:</translation>
</message> </message>
<message> <message>
<location filename="../../main.cpp" line="103"/> <location filename="../../main.cpp" line="108"/>
<source>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:</source> <source>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:</source>
<translation>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:</translation> <translation>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:</translation>
</message> </message>
@ -322,7 +337,7 @@ You can download it at the following address:</source>
<translation type="vanished">file transfer failed</translation> <translation type="vanished">file transfer failed</translation>
</message> </message>
<message> <message>
<location filename="../../device/ui/videoform.cpp" line="671"/> <location filename="../../device/ui/videoform.cpp" line="710"/>
<source>file does not exist</source> <source>file does not exist</source>
<translation>file does not exist</translation> <translation>file does not exist</translation>
</message> </message>

Binary file not shown.

View file

@ -49,17 +49,17 @@
<context> <context>
<name>Dialog</name> <name>Dialog</name>
<message> <message>
<location filename="../../dialog.ui" line="429"/> <location filename="../../dialog.ui" line="477"/>
<source>Wireless</source> <source>Wireless</source>
<translation>线</translation> <translation>线</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="513"/> <location filename="../../dialog.ui" line="561"/>
<source>wireless connect</source> <source>wireless connect</source>
<translation>线</translation> <translation>线</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="529"/> <location filename="../../dialog.ui" line="577"/>
<source>wireless disconnect</source> <source>wireless disconnect</source>
<translation>线</translation> <translation>线</translation>
</message> </message>
@ -69,13 +69,13 @@
<translation></translation> <translation></translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="127"/> <location filename="../../dialog.ui" line="168"/>
<source>record save path:</source> <source>record save path:</source>
<translation></translation> <translation></translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="144"/> <location filename="../../dialog.ui" line="185"/>
<location filename="../../dialog.cpp" line="325"/> <location filename="../../dialog.cpp" line="338"/>
<source>select path</source> <source>select path</source>
<translation></translation> <translation></translation>
</message> </message>
@ -85,47 +85,57 @@
<translation></translation> <translation></translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="282"/> <location filename="../../dialog.ui" line="323"/>
<source>record screen</source> <source>record screen</source>
<translation></translation> <translation></translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="227"/> <location filename="../../dialog.ui" line="268"/>
<source>frameless</source> <source>frameless</source>
<translation></translation> <translation></translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="289"/> <location filename="../../dialog.ui" line="127"/>
<source>lock orientation:</source>
<translation></translation>
</message>
<message>
<location filename="../../dialog.ui" line="330"/>
<source>show fps</source> <source>show fps</source>
<translation>fps</translation> <translation>fps</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="386"/> <location filename="../../dialog.ui" line="337"/>
<source>stay awake</source>
<translation></translation>
</message>
<message>
<location filename="../../dialog.ui" line="434"/>
<source>stop all server</source> <source>stop all server</source>
<translation></translation> <translation></translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="563"/> <location filename="../../dialog.ui" line="611"/>
<source>adb command:</source> <source>adb command:</source>
<translation>adb命令</translation> <translation>adb命令</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="599"/> <location filename="../../dialog.ui" line="647"/>
<source>terminate</source> <source>terminate</source>
<translation></translation> <translation></translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="586"/> <location filename="../../dialog.ui" line="634"/>
<source>execute</source> <source>execute</source>
<translation></translation> <translation></translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="612"/> <location filename="../../dialog.ui" line="660"/>
<source>clear</source> <source>clear</source>
<translation></translation> <translation></translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="272"/> <location filename="../../dialog.ui" line="313"/>
<source>reverse connection</source> <source>reverse connection</source>
<translation></translation> <translation></translation>
</message> </message>
@ -134,17 +144,17 @@
<translation type="vanished"></translation> <translation type="vanished"></translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="256"/> <location filename="../../dialog.ui" line="297"/>
<source>background record</source> <source>background record</source>
<translation></translation> <translation></translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="220"/> <location filename="../../dialog.ui" line="261"/>
<source>screen-off</source> <source>screen-off</source>
<translation></translation> <translation></translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="189"/> <location filename="../../dialog.ui" line="230"/>
<source>apply</source> <source>apply</source>
<translation></translation> <translation></translation>
</message> </message>
@ -154,37 +164,37 @@
<translation></translation> <translation></translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="240"/> <location filename="../../dialog.ui" line="281"/>
<source>always on top</source> <source>always on top</source>
<translation></translation> <translation></translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="182"/> <location filename="../../dialog.ui" line="223"/>
<source>refresh script</source> <source>refresh script</source>
<translation></translation> <translation></translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="403"/> <location filename="../../dialog.ui" line="451"/>
<source>get device IP</source> <source>get device IP</source>
<translation>IP</translation> <translation>IP</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="302"/> <location filename="../../dialog.ui" line="350"/>
<source>USB line</source> <source>USB line</source>
<translation>USB线</translation> <translation>USB线</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="358"/> <location filename="../../dialog.ui" line="406"/>
<source>stop server</source> <source>stop server</source>
<translation></translation> <translation></translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="348"/> <location filename="../../dialog.ui" line="396"/>
<source>start server</source> <source>start server</source>
<translation></translation> <translation></translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="338"/> <location filename="../../dialog.ui" line="386"/>
<source>device serial:</source> <source>device serial:</source>
<translation></translation> <translation></translation>
</message> </message>
@ -198,20 +208,25 @@
<translation></translation> <translation></translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="413"/> <location filename="../../dialog.ui" line="461"/>
<source>start adbd</source> <source>start adbd</source>
<translation>adbd</translation> <translation>adbd</translation>
</message> </message>
<message> <message>
<location filename="../../dialog.ui" line="393"/> <location filename="../../dialog.ui" line="441"/>
<source>refresh devices</source> <source>refresh devices</source>
<translation></translation> <translation></translation>
</message> </message>
<message> <message>
<location filename="../../dialog.cpp" line="101"/> <location filename="../../dialog.cpp" line="105"/>
<source>original</source> <source>original</source>
<translation></translation> <translation></translation>
</message> </message>
<message>
<location filename="../../dialog.cpp" line="112"/>
<source>no lock</source>
<translation></translation>
</message>
</context> </context>
<context> <context>
<name>QObject</name> <name>QObject</name>
@ -226,7 +241,7 @@ You can download it at the following address:</source>
<translation type="vanished">.\n严禁用于非法用途.\n你可以在下面地址下载:</translation> <translation type="vanished">.\n严禁用于非法用途.\n你可以在下面地址下载:</translation>
</message> </message>
<message> <message>
<location filename="../../main.cpp" line="103"/> <location filename="../../main.cpp" line="108"/>
<source>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:</source> <source>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:</source>
<translation>:</translation> <translation>:</translation>
</message> </message>
@ -322,7 +337,7 @@ You can download it at the following address:</source>
<translation type="vanished"></translation> <translation type="vanished"></translation>
</message> </message>
<message> <message>
<location filename="../../device/ui/videoform.cpp" line="671"/> <location filename="../../device/ui/videoform.cpp" line="710"/>
<source>file does not exist</source> <source>file does not exist</source>
<translation></translation> <translation></translation>
</message> </message>

View file

@ -34,6 +34,9 @@
#define COMMON_ADB_PATH_KEY "AdbPath" #define COMMON_ADB_PATH_KEY "AdbPath"
#define COMMON_ADB_PATH_DEF "" #define COMMON_ADB_PATH_DEF ""
#define COMMON_LOG_LEVEL_KEY "LogLevel"
#define COMMON_LOG_LEVEL_DEF "info"
// user data // user data
#define COMMON_RECORD_KEY "RecordPath" #define COMMON_RECORD_KEY "RecordPath"
#define COMMON_RECORD_DEF "" #define COMMON_RECORD_DEF ""
@ -74,7 +77,7 @@ Config &Config::getInstance()
static Config config; static Config config;
return config; return config;
} }
#include <QDebug>
const QString &Config::getConfigPath() const QString &Config::getConfigPath()
{ {
if (s_configPath.isEmpty()) { if (s_configPath.isEmpty()) {
@ -265,6 +268,15 @@ QString Config::getAdbPath()
return adbPath; 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 Config::getTitle()
{ {
QString title; QString title;

View file

@ -21,6 +21,7 @@ public:
QString getPushFilePath(); QString getPushFilePath();
QString getServerPath(); QString getServerPath();
QString getAdbPath(); QString getAdbPath();
QString getLogLevel();
// user data // user data
QString getRecordPath(); QString getRecordPath();

View file

@ -10,8 +10,11 @@ RenderExpiredFrames=0
# 视频解码方式:-1 自动0 软解1 dx硬解2 opengl硬解 # 视频解码方式:-1 自动0 软解1 dx硬解2 opengl硬解
UseDesktopOpenGL=-1 UseDesktopOpenGL=-1
# scrcpy-server的版本号不要修改 # scrcpy-server的版本号不要修改
ServerVersion=1.12.1 ServerVersion=1.14
# scrcpy-server推送到安卓设备的路径 # scrcpy-server推送到安卓设备的路径
ServerPath=/data/local/tmp/scrcpy-server.jar ServerPath=/data/local/tmp/scrcpy-server.jar
# 自定义adb路径例如D:/android/tools/adb.exe # 自定义adb路径例如D:/android/tools/adb.exe
AdbPath= AdbPath=
# Set the log level (debug, info, warn, error)
LogLevel=info

View file

@ -189,7 +189,7 @@ The client uses 4 threads:
recording, recording,
- the **controller** thread, sending _control messages_ to the server, - the **controller** thread, sending _control messages_ to the server,
- the **receiver** thread (managed by the controller), receiving _device - 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 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 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 rendering frame (with proper synchronization). Thus, it immediatly starts
to decode a new frame while the main thread renders the last one. 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. H.264 packet to the output video file.
[stream]: https://github.com/Genymobile/scrcpy/blob/ffe0417228fb78ab45b7ee4e202fc06fc8875bf3/app/src/stream.h [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 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. Then recompile.
When you start scrcpy, it will start a debugger on port 5005 on the device. When you start scrcpy, it will start a debugger on port 5005 on the device.

View file

@ -3,6 +3,26 @@
如果在此文档没有解决你的问题描述你的问题截图软件控制台中打印的日志一起发到QQ群里提问。 如果在此文档没有解决你的问题描述你的问题截图软件控制台中打印的日志一起发到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) [关于转发安卓声音到PC的讨论](https://github.com/Genymobile/scrcpy/issues/14#issuecomment-543204526)
@ -21,19 +41,11 @@ QtScrcpy.exe>属性>兼容性>更改高DPI设置>覆盖高DPI缩放行为>由以
## 无法输入中文 ## 无法输入中文
手机端安装搜狗输入法/QQ输入法就可以支持输入中文了 手机端安装搜狗输入法/QQ输入法就可以支持输入中文了
## 可以看到画面,但无法控制
有些手机(小米等手机)需要额外打开控制权限检查是否USB调试里打开了允许模拟点击
![image](image/USB调试(安全设置).jpg)
## 可以控制,但无法看到画面 ## 可以控制,但无法看到画面
控制台错误信息可能会包含 QOpenGLShaderProgram::attributeLocation(vertexIn): shader program is not linked 控制台错误信息可能会包含 QOpenGLShaderProgram::attributeLocation(vertexIn): shader program is not linked
一般是由于显卡不支持当前的视频渲染方式config.ini里修改下解码方式改成1或者2试试 一般是由于显卡不支持当前的视频渲染方式config.ini里修改下解码方式改成1或者2试试
## 手机通过数据线连接电脑,刷新设备列表以后,没有任何设备出现
随便下载一个手机助手尝试连接成功以后再用QtScrcpy刷新设备列表连接
## 错误信息AdbProcess::error:adb server version (40) doesnt match this client (41) ## 错误信息AdbProcess::error:adb server version (40) doesnt match this client (41)
任务管理找到adb进程并杀死重新操作即可 任务管理找到adb进程并杀死重新操作即可

View file

@ -1,21 +1,22 @@
最后同步scrcpy 31bd95022bc525be42ca273d59a3211d964d278b 最后同步scrcpy 3c0fc8f54f42bf6e7eca35b352a7d343749b65c4
# TODO # TODO
## 低优先级 ## 低优先级
- [单独线程统计帧率](https://github.com/Genymobile/scrcpy/commit/e2a272bf99ecf48fcb050177113f903b3fb323c4)
- text转换 https://github.com/Genymobile/scrcpy/commit/c916af0984f72a60301d13fa8ef9a85112f54202?tdsourcetag=s_pctim_aiomsg - 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) - 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打包以及版本号 - linux打包以及版本号
- 关于 - 关于
- 旋转 - 音频转发 https://github.com/rom1v/sndcpy
- ubuntu自动打包
- 版本号抽离优化
# mark # mark
## ffmpeg ## ffmpeg

View file

@ -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();
}

View file

@ -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.
* <p>
* 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));
}
}
}
}
}

View file

@ -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<CodecOption> parse(String codecOptions) {
if ("-".equals(codecOptions)) {
return null;
}
List<CodecOption> 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);
}
}

View file

@ -17,6 +17,8 @@ public final class ControlMessage {
public static final int TYPE_SET_SCREEN_POWER_MODE = 9; public static final int TYPE_SET_SCREEN_POWER_MODE = 9;
public static final int TYPE_ROTATE_DEVICE = 10; public static final int TYPE_ROTATE_DEVICE = 10;
public static final int FLAGS_PASTE = 1;
private int type; private int type;
private String text; private String text;
private int metaState; // KeyEvent.META_* private int metaState; // KeyEvent.META_*
@ -28,6 +30,7 @@ public final class ControlMessage {
private Position position; private Position position;
private int hScroll; private int hScroll;
private int vScroll; private int vScroll;
private int flags;
private ControlMessage() { private ControlMessage() {
} }
@ -68,10 +71,13 @@ public final class ControlMessage {
return msg; return msg;
} }
public static ControlMessage createSetClipboard(String text) { public static ControlMessage createSetClipboard(String text, boolean paste) {
ControlMessage msg = new ControlMessage(); ControlMessage msg = new ControlMessage();
msg.type = TYPE_SET_CLIPBOARD; msg.type = TYPE_SET_CLIPBOARD;
msg.text = text; msg.text = text;
if (paste) {
msg.flags = FLAGS_PASTE;
}
return msg; return msg;
} }
@ -134,4 +140,8 @@ public final class ControlMessage {
public int getVScroll() { public int getVScroll() {
return vScroll; return vScroll;
} }
public int getFlags() {
return flags;
}
} }

View file

@ -8,15 +8,16 @@ import java.nio.charset.StandardCharsets;
public class ControlMessageReader { public class ControlMessageReader {
private static final int INJECT_KEYCODE_PAYLOAD_LENGTH = 9; static final int INJECT_KEYCODE_PAYLOAD_LENGTH = 9;
private static final int INJECT_MOUSE_EVENT_PAYLOAD_LENGTH = 17; static final int INJECT_TOUCH_EVENT_PAYLOAD_LENGTH = 27;
private static final int INJECT_TOUCH_EVENT_PAYLOAD_LENGTH = 21; static final int INJECT_SCROLL_EVENT_PAYLOAD_LENGTH = 20;
private static final int INJECT_SCROLL_EVENT_PAYLOAD_LENGTH = 20; static final int SET_SCREEN_POWER_MODE_PAYLOAD_LENGTH = 1;
private 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 = 4092; // 4096 - 1 (type) - 1 (parse flag) - 2 (length)
public static final int CLIPBOARD_TEXT_MAX_LENGTH = 4093; public static final int INJECT_TEXT_MAX_LENGTH = 300;
private static final int RAW_BUFFER_SIZE = 1024;
private static final int RAW_BUFFER_SIZE = 4096;
private final byte[] rawBuffer = new byte[RAW_BUFFER_SIZE]; private final byte[] rawBuffer = new byte[RAW_BUFFER_SIZE];
private final ByteBuffer buffer = ByteBuffer.wrap(rawBuffer); private final ByteBuffer buffer = ByteBuffer.wrap(rawBuffer);
@ -122,7 +123,6 @@ public class ControlMessageReader {
return ControlMessage.createInjectText(text); return ControlMessage.createInjectText(text);
} }
@SuppressWarnings("checkstyle:MagicNumber")
private ControlMessage parseInjectTouchEvent() { private ControlMessage parseInjectTouchEvent() {
if (buffer.remaining() < INJECT_TOUCH_EVENT_PAYLOAD_LENGTH) { if (buffer.remaining() < INJECT_TOUCH_EVENT_PAYLOAD_LENGTH) {
return null; return null;
@ -149,11 +149,15 @@ public class ControlMessageReader {
} }
private ControlMessage parseSetClipboard() { private ControlMessage parseSetClipboard() {
if (buffer.remaining() < SET_CLIPBOARD_FIXED_PAYLOAD_LENGTH) {
return null;
}
boolean parse = buffer.get() != 0;
String text = parseString(); String text = parseString();
if (text == null) { if (text == null) {
return null; return null;
} }
return ControlMessage.createSetClipboard(text); return ControlMessage.createSetClipboard(text, parse);
} }
private ControlMessage parseSetScreenPowerMode() { private ControlMessage parseSetScreenPowerMode() {
@ -172,12 +176,10 @@ public class ControlMessageReader {
return new Position(x, y, screenWidth, screenHeight); return new Position(x, y, screenWidth, screenHeight);
} }
@SuppressWarnings("checkstyle:MagicNumber")
private static int toUnsigned(short value) { private static int toUnsigned(short value) {
return value & 0xffff; return value & 0xffff;
} }
@SuppressWarnings("checkstyle:MagicNumber")
private static int toUnsigned(byte value) { private static int toUnsigned(byte value) {
return value & 0xff; return value & 0xff;
} }

View file

@ -1,10 +1,8 @@
package com.genymobile.scrcpy; package com.genymobile.scrcpy;
import com.genymobile.scrcpy.wrappers.InputManager; import android.os.Build;
import android.os.SystemClock; import android.os.SystemClock;
import android.view.InputDevice; import android.view.InputDevice;
import android.view.InputEvent;
import android.view.KeyCharacterMap; import android.view.KeyCharacterMap;
import android.view.KeyEvent; import android.view.KeyEvent;
import android.view.MotionEvent; import android.view.MotionEvent;
@ -47,11 +45,10 @@ public class Controller {
} }
} }
@SuppressWarnings("checkstyle:MagicNumber")
public void control() throws IOException { public void control() throws IOException {
// on start, power on the device // on start, power on the device
if (!device.isScreenOn()) { if (!device.isScreenOn()) {
injectKeycode(KeyEvent.KEYCODE_POWER); device.injectKeycode(KeyEvent.KEYCODE_POWER);
// dirty hack // dirty hack
// After POWER is injected, the device is powered on asynchronously. // After POWER is injected, the device is powered on asynchronously.
@ -76,19 +73,29 @@ public class Controller {
ControlMessage msg = connection.receiveControlMessage(); ControlMessage msg = connection.receiveControlMessage();
switch (msg.getType()) { switch (msg.getType()) {
case ControlMessage.TYPE_INJECT_KEYCODE: case ControlMessage.TYPE_INJECT_KEYCODE:
if (device.supportsInputEvents()) {
injectKeycode(msg.getAction(), msg.getKeycode(), msg.getMetaState()); injectKeycode(msg.getAction(), msg.getKeycode(), msg.getMetaState());
}
break; break;
case ControlMessage.TYPE_INJECT_TEXT: case ControlMessage.TYPE_INJECT_TEXT:
if (device.supportsInputEvents()) {
injectText(msg.getText()); injectText(msg.getText());
}
break; break;
case ControlMessage.TYPE_INJECT_TOUCH_EVENT: case ControlMessage.TYPE_INJECT_TOUCH_EVENT:
if (device.supportsInputEvents()) {
injectTouch(msg.getAction(), msg.getPointerId(), msg.getPosition(), msg.getPressure(), msg.getButtons()); injectTouch(msg.getAction(), msg.getPointerId(), msg.getPosition(), msg.getPressure(), msg.getButtons());
}
break; break;
case ControlMessage.TYPE_INJECT_SCROLL_EVENT: case ControlMessage.TYPE_INJECT_SCROLL_EVENT:
if (device.supportsInputEvents()) {
injectScroll(msg.getPosition(), msg.getHScroll(), msg.getVScroll()); injectScroll(msg.getPosition(), msg.getHScroll(), msg.getVScroll());
}
break; break;
case ControlMessage.TYPE_BACK_OR_SCREEN_ON: case ControlMessage.TYPE_BACK_OR_SCREEN_ON:
if (device.supportsInputEvents()) {
pressBackOrTurnScreenOn(); pressBackOrTurnScreenOn();
}
break; break;
case ControlMessage.TYPE_EXPAND_NOTIFICATION_PANEL: case ControlMessage.TYPE_EXPAND_NOTIFICATION_PANEL:
device.expandNotificationPanel(); device.expandNotificationPanel();
@ -98,13 +105,22 @@ public class Controller {
break; break;
case ControlMessage.TYPE_GET_CLIPBOARD: case ControlMessage.TYPE_GET_CLIPBOARD:
String clipboardText = device.getClipboardText(); String clipboardText = device.getClipboardText();
if (clipboardText != null) {
sender.pushClipboardText(clipboardText); sender.pushClipboardText(clipboardText);
}
break; break;
case ControlMessage.TYPE_SET_CLIPBOARD: case ControlMessage.TYPE_SET_CLIPBOARD:
device.setClipboardText(msg.getText()); boolean paste = (msg.getFlags() & ControlMessage.FLAGS_PASTE) != 0;
setClipboard(msg.getText(), paste);
break; break;
case ControlMessage.TYPE_SET_SCREEN_POWER_MODE: 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; break;
case ControlMessage.TYPE_ROTATE_DEVICE: case ControlMessage.TYPE_ROTATE_DEVICE:
device.rotateDevice(); device.rotateDevice();
@ -115,7 +131,7 @@ public class Controller {
} }
private boolean injectKeycode(int action, int keycode, int metaState) { 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) { private boolean injectChar(char c) {
@ -126,7 +142,7 @@ public class Controller {
return false; return false;
} }
for (KeyEvent event : events) { for (KeyEvent event : events) {
if (!injectEvent(event)) { if (!device.injectEvent(event)) {
return false; return false;
} }
} }
@ -182,7 +198,7 @@ public class Controller {
MotionEvent event = MotionEvent MotionEvent event = MotionEvent
.obtain(lastTouchDown, now, action, pointerCount, pointerProperties, pointerCoords, 0, buttons, 1f, 1f, DEVICE_ID_VIRTUAL, 0, .obtain(lastTouchDown, now, action, pointerCount, pointerProperties, pointerCoords, 0, buttons, 1f, 1f, DEVICE_ID_VIRTUAL, 0,
InputDevice.SOURCE_TOUCHSCREEN, 0); InputDevice.SOURCE_TOUCHSCREEN, 0);
return injectEvent(event); return device.injectEvent(event);
} }
private boolean injectScroll(Position position, int hScroll, int vScroll) { private boolean injectScroll(Position position, int hScroll, int vScroll) {
@ -204,27 +220,26 @@ public class Controller {
MotionEvent event = MotionEvent MotionEvent event = MotionEvent
.obtain(lastTouchDown, now, MotionEvent.ACTION_SCROLL, 1, pointerProperties, pointerCoords, 0, 0, 1f, 1f, DEVICE_ID_VIRTUAL, 0, .obtain(lastTouchDown, now, MotionEvent.ACTION_SCROLL, 1, pointerProperties, pointerCoords, 0, 0, 1f, 1f, DEVICE_ID_VIRTUAL, 0,
InputDevice.SOURCE_MOUSE, 0); InputDevice.SOURCE_TOUCHSCREEN, 0);
return injectEvent(event); 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 injectKeycode(int keyCode) {
return injectKeyEvent(KeyEvent.ACTION_DOWN, keyCode, 0, 0) && injectKeyEvent(KeyEvent.ACTION_UP, keyCode, 0, 0);
}
private boolean injectEvent(InputEvent event) {
return device.injectInputEvent(event, InputManager.INJECT_INPUT_EVENT_MODE_ASYNC);
} }
private boolean pressBackOrTurnScreenOn() { private boolean pressBackOrTurnScreenOn() {
int keycode = device.isScreenOn() ? KeyEvent.KEYCODE_BACK : KeyEvent.KEYCODE_POWER; int keycode = device.isScreenOn() ? KeyEvent.KEYCODE_BACK : KeyEvent.KEYCODE_POWER;
return injectKeycode(keycode); return device.injectKeycode(keycode);
}
private boolean setClipboard(String text, boolean paste) {
boolean ok = device.setClipboardText(text);
if (ok) {
Ln.i("Device clipboard set");
}
// On Android >= 7, also press the PASTE key if requested
if (paste && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && device.supportsInputEvents()) {
device.injectKeycode(KeyEvent.KEYCODE_PASTE);
}
return ok;
} }
} }

View file

@ -84,7 +84,6 @@ public final class DesktopConnection implements Closeable {
controlSocket.close(); controlSocket.close();
} }
@SuppressWarnings("checkstyle:MagicNumber")
private void send(String deviceName, int width, int height) throws IOException { private void send(String deviceName, int width, int height) throws IOException {
byte[] buffer = new byte[DEVICE_NAME_FIELD_LENGTH + 4]; byte[] buffer = new byte[DEVICE_NAME_FIELD_LENGTH + 4];

View file

@ -1,15 +1,23 @@
package com.genymobile.scrcpy; 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.ServiceManager;
import com.genymobile.scrcpy.wrappers.SurfaceControl; import com.genymobile.scrcpy.wrappers.SurfaceControl;
import com.genymobile.scrcpy.wrappers.WindowManager; import com.genymobile.scrcpy.wrappers.WindowManager;
import android.content.IOnPrimaryClipChangedListener;
import android.graphics.Rect; import android.graphics.Rect;
import android.os.Build; import android.os.Build;
import android.os.IBinder; import android.os.IBinder;
import android.os.RemoteException; import android.os.SystemClock;
import android.view.IRotationWatcher; import android.view.IRotationWatcher;
import android.view.InputDevice;
import android.view.InputEvent; import android.view.InputEvent;
import android.view.KeyCharacterMap;
import android.view.KeyEvent;
import java.util.concurrent.atomic.AtomicBoolean;
public final class Device { public final class Device {
@ -20,18 +28,47 @@ public final class Device {
void onRotationChanged(int rotation); void onRotationChanged(int rotation);
} }
public interface ClipboardListener {
void onClipboardTextChanged(String text);
}
private final ServiceManager serviceManager = new ServiceManager(); private final ServiceManager serviceManager = new ServiceManager();
private ScreenInfo screenInfo; private ScreenInfo screenInfo;
private RotationListener rotationListener; 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) { public Device(Options options) {
screenInfo = computeScreenInfo(options.getCrop(), options.getMaxSize()); displayId = options.getDisplayId();
registerRotationWatcher(new IRotationWatcher.Stub() { 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 @Override
public void onRotationChanged(int rotation) throws RemoteException { public void onRotationChanged(int rotation) {
synchronized (Device.this) { synchronized (Device.this) {
screenInfo = screenInfo.withRotation(rotation); screenInfo = screenInfo.withDeviceRotation(rotation);
// notify // notify
if (rotationListener != null) { if (rotationListener != null) {
@ -39,104 +76,120 @@ public final class Device {
} }
} }
} }
}, displayId);
if (options.getControl()) {
// If control is enabled, synchronize Android clipboard to the computer automatically
serviceManager.getClipboardManager().addPrimaryClipChangedListener(new IOnPrimaryClipChangedListener.Stub() {
@Override
public void dispatchPrimaryClipChanged() {
if (isSettingClipboard.get()) {
// This is a notification for the change we are currently applying, ignore it
return;
}
synchronized (Device.this) {
if (clipboardListener != null) {
String text = getClipboardText();
if (text != null) {
clipboardListener.onClipboardTextChanged(text);
}
}
}
}
}); });
} }
if ((displayInfoFlags & DisplayInfo.FLAG_SUPPORTS_PROTECTED_BUFFERS) == 0) {
Ln.w("Display doesn't have FLAG_SUPPORTS_PROTECTED_BUFFERS flag, mirroring can be restricted");
}
// main display or any display on Android >= Q
supportsInputEvents = displayId == 0 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q;
if (!supportsInputEvents) {
Ln.w("Input events are not supported for secondary displays before Android 10");
}
}
public synchronized ScreenInfo getScreenInfo() { public synchronized ScreenInfo getScreenInfo() {
return screenInfo; return screenInfo;
} }
private ScreenInfo computeScreenInfo(Rect crop, int maxSize) { public int getLayerStack() {
DisplayInfo displayInfo = serviceManager.getDisplayManager().getDisplayInfo(); return layerStack;
boolean rotated = (displayInfo.getRotation() & 1) != 0;
Size deviceSize = displayInfo.getSize();
Rect contentRect = new Rect(0, 0, deviceSize.getWidth(), deviceSize.getHeight());
if (crop != null) {
if (rotated) {
// the crop (provided by the user) is expressed in the natural orientation
crop = flipRect(crop);
}
if (!contentRect.intersect(crop)) {
// intersect() changes contentRect so that it is intersected with crop
Ln.w("Crop rectangle (" + formatCrop(crop) + ") does not intersect device screen (" + formatCrop(deviceSize.toRect()) + ")");
contentRect = new Rect(); // empty
}
}
Size videoSize = computeVideoSize(contentRect.width(), contentRect.height(), maxSize);
return new ScreenInfo(contentRect, videoSize, rotated);
}
private static String formatCrop(Rect rect) {
return rect.width() + ":" + rect.height() + ":" + rect.left + ":" + rect.top;
}
@SuppressWarnings("checkstyle:MagicNumber")
private static Size computeVideoSize(int w, int h, int maxSize) {
// Compute the video size and the padding of the content inside this video.
// Principle:
// - scale down the great side of the screen to maxSize (if necessary);
// - scale down the other side so that the aspect ratio is preserved;
// - round this value to the nearest multiple of 8 (H.264 only accepts multiples of 8)
w &= ~7; // in case it's not a multiple of 8
h &= ~7;
if (maxSize > 0) {
if (BuildConfig.DEBUG && maxSize % 8 != 0) {
throw new AssertionError("Max size must be a multiple of 8");
}
boolean portrait = h > w;
int major = portrait ? h : w;
int minor = portrait ? w : h;
if (major > maxSize) {
int minorExact = minor * maxSize / major;
// +4 to round the value to the nearest multiple of 8
minor = (minorExact + 4) & ~7;
major = maxSize;
}
w = portrait ? minor : major;
h = portrait ? major : minor;
}
return new Size(w, h);
} }
public Point getPhysicalPoint(Position position) { public Point getPhysicalPoint(Position position) {
// it hides the field on purpose, to read it with a lock // it hides the field on purpose, to read it with a lock
@SuppressWarnings("checkstyle:HiddenField") @SuppressWarnings("checkstyle:HiddenField")
ScreenInfo screenInfo = getScreenInfo(); // read with synchronization ScreenInfo screenInfo = getScreenInfo(); // read with synchronization
Size videoSize = screenInfo.getVideoSize();
Size clientVideoSize = position.getScreenSize(); // ignore the locked video orientation, the events will apply in coordinates considered in the physical device orientation
if (!videoSize.equals(clientVideoSize)) { 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 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 // the device may have been rotated since the event was generated, so ignore the event
return null; return null;
} }
Rect contentRect = screenInfo.getContentRect(); Rect contentRect = screenInfo.getContentRect();
Point point = position.getPoint(); Point point = devicePosition.getPoint();
int scaledX = contentRect.left + point.getX() * contentRect.width() / videoSize.getWidth(); int convertedX = contentRect.left + point.getX() * contentRect.width() / unlockedVideoSize.getWidth();
int scaledY = contentRect.top + point.getY() * contentRect.height() / videoSize.getHeight(); int convertedY = contentRect.top + point.getY() * contentRect.height() / unlockedVideoSize.getHeight();
return new Point(scaledX, scaledY); return new Point(convertedX, convertedY);
} }
public static String getDeviceName() { public static String getDeviceName() {
return Build.MODEL; 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); return serviceManager.getInputManager().injectInputEvent(inputEvent, mode);
} }
public boolean injectEvent(InputEvent event) {
return injectEvent(event, InputManager.INJECT_INPUT_EVENT_MODE_ASYNC);
}
public boolean injectKeyEvent(int action, int keyCode, int repeat, int metaState) {
long now = SystemClock.uptimeMillis();
KeyEvent event = new KeyEvent(now, now, action, keyCode, repeat, metaState, KeyCharacterMap.VIRTUAL_KEYBOARD, 0, 0,
InputDevice.SOURCE_KEYBOARD);
return injectEvent(event);
}
public boolean injectKeycode(int keyCode) {
return injectKeyEvent(KeyEvent.ACTION_DOWN, keyCode, 0, 0) && injectKeyEvent(KeyEvent.ACTION_UP, keyCode, 0, 0);
}
public boolean isScreenOn() { public boolean isScreenOn() {
return serviceManager.getPowerManager().isScreenOn(); return serviceManager.getPowerManager().isScreenOn();
} }
public void registerRotationWatcher(IRotationWatcher rotationWatcher) {
serviceManager.getWindowManager().registerRotationWatcher(rotationWatcher);
}
public synchronized void setRotationListener(RotationListener rotationListener) { public synchronized void setRotationListener(RotationListener rotationListener) {
this.rotationListener = rotationListener; this.rotationListener = rotationListener;
} }
public synchronized void setClipboardListener(ClipboardListener clipboardListener) {
this.clipboardListener = clipboardListener;
}
public void expandNotificationPanel() { public void expandNotificationPanel() {
serviceManager.getStatusBarManager().expandNotificationsPanel(); serviceManager.getStatusBarManager().expandNotificationsPanel();
} }
@ -153,22 +206,23 @@ public final class Device {
return s.toString(); return s.toString();
} }
public void setClipboardText(String text) { public boolean setClipboardText(String text) {
serviceManager.getClipboardManager().setText(text); isSettingClipboard.set(true);
Ln.i("Device clipboard set"); boolean ok = serviceManager.getClipboardManager().setText(text);
isSettingClipboard.set(false);
return ok;
} }
/** /**
* @param mode one of the {@code SCREEN_POWER_MODE_*} constants * @param mode one of the {@code SCREEN_POWER_MODE_*} constants
*/ */
public void setScreenPowerMode(int mode) { public boolean setScreenPowerMode(int mode) {
IBinder d = SurfaceControl.getBuiltInDisplay(); IBinder d = SurfaceControl.getBuiltInDisplay();
if (d == null) { if (d == null) {
Ln.e("Could not get built-in display"); Ln.e("Could not get built-in display");
return; return false;
} }
SurfaceControl.setDisplayPowerMode(d, mode); return SurfaceControl.setDisplayPowerMode(d, mode);
Ln.i("Device screen turned " + (mode == Device.POWER_MODE_OFF ? "off" : "on"));
} }
/** /**
@ -192,7 +246,7 @@ public final class Device {
} }
} }
static Rect flipRect(Rect crop) { public ContentProvider createSettingsProvider() {
return new Rect(crop.top, crop.left, crop.bottom, crop.right); return serviceManager.getActivityManager().createSettingsProvider();
} }
} }

View file

@ -13,7 +13,6 @@ public class DeviceMessageWriter {
private final byte[] rawBuffer = new byte[MAX_EVENT_SIZE]; private final byte[] rawBuffer = new byte[MAX_EVENT_SIZE];
private final ByteBuffer buffer = ByteBuffer.wrap(rawBuffer); private final ByteBuffer buffer = ByteBuffer.wrap(rawBuffer);
@SuppressWarnings("checkstyle:MagicNumber")
public void writeTo(DeviceMessage msg, OutputStream output) throws IOException { public void writeTo(DeviceMessage msg, OutputStream output) throws IOException {
buffer.clear(); buffer.clear();
buffer.put((byte) DeviceMessage.TYPE_CLIPBOARD); buffer.put((byte) DeviceMessage.TYPE_CLIPBOARD);

View file

@ -1,12 +1,24 @@
package com.genymobile.scrcpy; package com.genymobile.scrcpy;
public final class DisplayInfo { public final class DisplayInfo {
private final int displayId;
private final Size size; private final Size size;
private final int rotation; 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.size = size;
this.rotation = rotation; this.rotation = rotation;
this.layerStack = layerStack;
this.flags = flags;
}
public int getDisplayId() {
return displayId;
} }
public Size getSize() { public Size getSize() {
@ -16,5 +28,13 @@ public final class DisplayInfo {
public int getRotation() { public int getRotation() {
return rotation; return rotation;
} }
public int getLayerStack() {
return layerStack;
}
public int getFlags() {
return flags;
}
} }

View file

@ -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;
}
}

View file

@ -15,14 +15,25 @@ public final class Ln {
DEBUG, INFO, WARN, ERROR DEBUG, INFO, WARN, ERROR
} }
private static final Level THRESHOLD = BuildConfig.DEBUG ? Level.DEBUG : Level.INFO; private static Level threshold = Level.INFO;
private Ln() { private Ln() {
// not instantiable // not instantiable
} }
/**
* Initialize the log level.
* <p>
* 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) { public static boolean isEnabled(Level level) {
return level.ordinal() >= THRESHOLD.ordinal(); return level.ordinal() >= threshold.ordinal();
} }
public static void d(String message) { public static void d(String message) {

View file

@ -3,13 +3,27 @@ package com.genymobile.scrcpy;
import android.graphics.Rect; import android.graphics.Rect;
public class Options { public class Options {
private Ln.Level logLevel;
private int maxSize; private int maxSize;
private int bitRate; private int bitRate;
private int maxFps; private int maxFps;
private int lockedVideoOrientation;
private boolean tunnelForward; private boolean tunnelForward;
private Rect crop; private Rect crop;
private boolean sendFrameMeta; // send PTS so that the client may record properly private boolean sendFrameMeta; // send PTS so that the client may record properly
private boolean control; 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() { public int getMaxSize() {
return maxSize; return maxSize;
@ -35,6 +49,14 @@ public class Options {
this.maxFps = maxFps; this.maxFps = maxFps;
} }
public int getLockedVideoOrientation() {
return lockedVideoOrientation;
}
public void setLockedVideoOrientation(int lockedVideoOrientation) {
this.lockedVideoOrientation = lockedVideoOrientation;
}
public boolean isTunnelForward() { public boolean isTunnelForward() {
return tunnelForward; return tunnelForward;
} }
@ -66,4 +88,36 @@ public class Options {
public void setControl(boolean control) { public void setControl(boolean control) {
this.control = 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;
}
} }

View file

@ -23,6 +23,19 @@ public class Position {
return screenSize; 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 @Override
public boolean equals(Object o) { public boolean equals(Object o) {
if (this == o) { if (this == o) {

View file

@ -6,40 +6,37 @@ import android.graphics.Rect;
import android.media.MediaCodec; import android.media.MediaCodec;
import android.media.MediaCodecInfo; import android.media.MediaCodecInfo;
import android.media.MediaFormat; import android.media.MediaFormat;
import android.os.Build;
import android.os.IBinder; import android.os.IBinder;
import android.view.Surface; import android.view.Surface;
import java.io.FileDescriptor; import java.io.FileDescriptor;
import java.io.IOException; import java.io.IOException;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
public class ScreenEncoder implements Device.RotationListener { public class ScreenEncoder implements Device.RotationListener {
private static final int DEFAULT_I_FRAME_INTERVAL = 10; // seconds 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 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 static final int NO_PTS = -1;
private final AtomicBoolean rotationChanged = new AtomicBoolean(); private final AtomicBoolean rotationChanged = new AtomicBoolean();
private final ByteBuffer headerBuffer = ByteBuffer.allocate(12); private final ByteBuffer headerBuffer = ByteBuffer.allocate(12);
private List<CodecOption> codecOptions;
private int bitRate; private int bitRate;
private int maxFps; private int maxFps;
private int iFrameInterval;
private boolean sendFrameMeta; private boolean sendFrameMeta;
private long ptsOrigin; private long ptsOrigin;
public ScreenEncoder(boolean sendFrameMeta, int bitRate, int maxFps, int iFrameInterval) { public ScreenEncoder(boolean sendFrameMeta, int bitRate, int maxFps, List<CodecOption> codecOptions) {
this.sendFrameMeta = sendFrameMeta; this.sendFrameMeta = sendFrameMeta;
this.bitRate = bitRate; this.bitRate = bitRate;
this.maxFps = maxFps; this.maxFps = maxFps;
this.iFrameInterval = iFrameInterval; this.codecOptions = codecOptions;
}
public ScreenEncoder(boolean sendFrameMeta, int bitRate, int maxFps) {
this(sendFrameMeta, bitRate, maxFps, DEFAULT_I_FRAME_INTERVAL);
} }
@Override @Override
@ -53,21 +50,40 @@ public class ScreenEncoder implements Device.RotationListener {
public void streamScreen(Device device, FileDescriptor fd) throws IOException { public void streamScreen(Device device, FileDescriptor fd) throws IOException {
Workarounds.prepareMainLooper(); Workarounds.prepareMainLooper();
Workarounds.fillAppInfo();
MediaFormat format = createFormat(bitRate, maxFps, iFrameInterval); try {
internalStreamScreen(device, fd);
} catch (NullPointerException e) {
// Retry with workarounds enabled:
// <https://github.com/Genymobile/scrcpy/issues/365>
// <https://github.com/Genymobile/scrcpy/issues/940>
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); device.setRotationListener(this);
boolean alive; boolean alive;
try { try {
do { do {
MediaCodec codec = createCodec(); MediaCodec codec = createCodec();
IBinder display = createDisplay(); IBinder display = createDisplay();
Rect contentRect = device.getScreenInfo().getContentRect(); ScreenInfo screenInfo = device.getScreenInfo();
Rect videoRect = device.getScreenInfo().getVideoSize().toRect(); 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()); setSize(format, videoRect.width(), videoRect.height());
configure(codec, format); configure(codec, format);
Surface surface = codec.createInputSurface(); Surface surface = codec.createInputSurface();
setDisplaySurface(display, surface, contentRect, videoRect); setDisplaySurface(display, surface, videoRotation, contentRect, unlockedVideoRect, layerStack);
codec.start(); codec.start();
try { try {
alive = encode(codec, fd); alive = encode(codec, fd);
@ -135,27 +151,49 @@ public class ScreenEncoder implements Device.RotationListener {
} }
private static MediaCodec createCodec() throws IOException { private static MediaCodec createCodec() throws IOException {
return MediaCodec.createEncoderByType("video/avc"); return MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC);
} }
@SuppressWarnings("checkstyle:MagicNumber") private static void setCodecOption(MediaFormat format, CodecOption codecOption) {
private static MediaFormat createFormat(int bitRate, int maxFps, int iFrameInterval) { 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<CodecOption> codecOptions) {
MediaFormat format = new MediaFormat(); 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); 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 // 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_FRAME_RATE, 60);
format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); 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 // 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 format.setLong(MediaFormat.KEY_REPEAT_PREVIOUS_FRAME_AFTER, REPEAT_FRAME_DELAY_US); // µs
if (maxFps > 0) { if (maxFps > 0) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // The key existed privately before Android 10:
format.setFloat(MediaFormat.KEY_MAX_FPS_TO_ENCODER, maxFps); // <https://android.googlesource.com/platform/frameworks/base/+/625f0aad9f7a259b6881006ad8710adce57d1384%5E%21/>
} else { // <https://github.com/Genymobile/scrcpy/issues/488#issuecomment-567321437>
Ln.w("Max FPS is only supported since Android 10, the option has been ignored"); format.setFloat(KEY_MAX_FPS_TO_ENCODER, maxFps);
}
if (codecOptions != null) {
for (CodecOption option : codecOptions) {
setCodecOption(format, option);
} }
} }
return format; return format;
} }
@ -172,12 +210,12 @@ public class ScreenEncoder implements Device.RotationListener {
format.setInteger(MediaFormat.KEY_HEIGHT, height); 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(); SurfaceControl.openTransaction();
try { try {
SurfaceControl.setDisplaySurface(display, surface); SurfaceControl.setDisplaySurface(display, surface);
SurfaceControl.setDisplayProjection(display, 0, deviceRect, displayRect); SurfaceControl.setDisplayProjection(display, orientation, deviceRect, displayRect);
SurfaceControl.setDisplayLayerStack(display, 0); SurfaceControl.setDisplayLayerStack(display, layerStack);
} finally { } finally {
SurfaceControl.closeTransaction(); SurfaceControl.closeTransaction();
} }

View file

@ -3,29 +3,161 @@ package com.genymobile.scrcpy;
import android.graphics.Rect; import android.graphics.Rect;
public final class ScreenInfo { public final class ScreenInfo {
/**
* Device (physical) size, possibly cropped
*/
private final Rect contentRect; // device 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.
* <p>
* 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.contentRect = contentRect;
this.videoSize = videoSize; this.unlockedVideoSize = unlockedVideoSize;
this.rotated = rotated; this.deviceRotation = deviceRotation;
this.lockedVideoOrientation = lockedVideoOrientation;
} }
public Rect getContentRect() { public Rect getContentRect() {
return contentRect; return contentRect;
} }
public Size getVideoSize() { /**
return videoSize; * Return the video size as if locked video orientation was not set.
*
* @return the unlocked video size
*/
public Size getUnlockedVideoSize() {
return unlockedVideoSize;
} }
public ScreenInfo withRotation(int rotation) { /**
boolean newRotated = (rotation & 1) != 0; * Return the actual video size if locked video orientation is set.
if (rotated == newRotated) { *
* @return the actual video size
*/
public Size getVideoSize() {
if (getVideoRotation() % 2 == 0) {
return unlockedVideoSize;
}
return unlockedVideoSize.rotate();
}
public int getDeviceRotation() {
return deviceRotation;
}
public ScreenInfo withDeviceRotation(int newDeviceRotation) {
if (newDeviceRotation == deviceRotation) {
return this; return 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;
} }
} }

View file

@ -1,32 +1,74 @@
package com.genymobile.scrcpy; package com.genymobile.scrcpy;
import com.genymobile.scrcpy.wrappers.ContentProvider;
import android.graphics.Rect; import android.graphics.Rect;
import android.media.MediaCodec; import android.media.MediaCodec;
import android.os.BatteryManager;
import android.os.Build; import android.os.Build;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.util.List;
import java.util.Locale;
public final class Server { public final class Server {
private static final String SERVER_PATH = "/data/local/tmp/scrcpy-server.jar";
private Server() { private Server() {
// not instantiable // not instantiable
} }
private static void scrcpy(Options options) throws IOException { 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); final Device device = new Device(options);
List<CodecOption> 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(); boolean tunnelForward = options.isTunnelForward();
try (DesktopConnection connection = DesktopConnection.open(device, tunnelForward)) { 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()) { if (options.getControl()) {
Controller controller = new Controller(device, connection); final Controller controller = new Controller(device, connection);
// asynchronous // asynchronous
startController(controller); startController(controller);
startDeviceMessageSender(controller.getSender()); startDeviceMessageSender(controller.getSender());
device.setClipboardListener(new Device.ClipboardListener() {
@Override
public void onClipboardTextChanged(String text) {
controller.getSender().pushClipboardText(text);
}
});
} }
try { try {
@ -67,7 +109,6 @@ public final class Server {
}).start(); }).start();
} }
@SuppressWarnings("checkstyle:MagicNumber")
private static Options createOptions(String... args) { private static Options createOptions(String... args) {
if (args.length < 1) { if (args.length < 1) {
throw new IllegalArgumentException("Missing client version"); throw new IllegalArgumentException("Missing client version");
@ -76,41 +117,59 @@ public final class Server {
String clientVersion = args[0]; String clientVersion = args[0];
if (!clientVersion.equals(BuildConfig.VERSION_NAME)) { if (!clientVersion.equals(BuildConfig.VERSION_NAME)) {
throw new IllegalArgumentException( 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) { final int expectedParameters = 14;
throw new IllegalArgumentException("Expecting 8 parameters"); if (args.length != expectedParameters) {
throw new IllegalArgumentException("Expecting " + expectedParameters + " parameters");
} }
Options options = new Options(); 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); options.setMaxSize(maxSize);
int bitRate = Integer.parseInt(args[2]); int bitRate = Integer.parseInt(args[3]);
options.setBitRate(bitRate); options.setBitRate(bitRate);
int maxFps = Integer.parseInt(args[3]); int maxFps = Integer.parseInt(args[4]);
options.setMaxFps(maxFps); options.setMaxFps(maxFps);
int lockedVideoOrientation = Integer.parseInt(args[5]);
options.setLockedVideoOrientation(lockedVideoOrientation);
// use "adb forward" instead of "adb tunnel"? (so the server must listen) // 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); options.setTunnelForward(tunnelForward);
Rect crop = parseCrop(args[5]); Rect crop = parseCrop(args[7]);
options.setCrop(crop); options.setCrop(crop);
boolean sendFrameMeta = Boolean.parseBoolean(args[6]); boolean sendFrameMeta = Boolean.parseBoolean(args[8]);
options.setSendFrameMeta(sendFrameMeta); options.setSendFrameMeta(sendFrameMeta);
boolean control = Boolean.parseBoolean(args[7]); boolean control = Boolean.parseBoolean(args[9]);
options.setControl(control); 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; return options;
} }
@SuppressWarnings("checkstyle:MagicNumber")
private static Rect parseCrop(String crop) { private static Rect parseCrop(String crop) {
if ("-".equals(crop)) { if ("-".equals(crop)) {
return null; return null;
@ -127,15 +186,6 @@ public final class Server {
return new Rect(x, y, x + width, y + height); 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) { private static void suggestFix(Throwable e) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (e instanceof MediaCodec.CodecException) { 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 { public static void main(String... args) throws Exception {
@ -158,8 +218,10 @@ public final class Server {
} }
}); });
unlinkSelf();
Options options = createOptions(args); Options options = createOptions(args);
Ln.initLogLevel(options.getLogLevel());
scrcpy(options); scrcpy(options);
} }
} }

View file

@ -5,7 +5,6 @@ public final class StringUtils {
// not instantiable // not instantiable
} }
@SuppressWarnings("checkstyle:MagicNumber")
public static int getUtf8TruncationIndex(byte[] utf8, int maxLength) { public static int getUtf8TruncationIndex(byte[] utf8, int maxLength) {
int len = utf8.length; int len = utf8.length;
if (len <= maxLength) { if (len <= maxLength) {

View file

@ -28,7 +28,7 @@ public final class Workarounds {
Looper.prepareMainLooper(); Looper.prepareMainLooper();
} }
@SuppressLint("PrivateApi") @SuppressLint("PrivateApi,DiscouragedPrivateApi")
public static void fillAppInfo() { public static void fillAppInfo() {
try { try {
// ActivityThread activityThread = new ActivityThread(); // ActivityThread activityThread = new ActivityThread();
@ -73,7 +73,7 @@ public final class Workarounds {
mInitialApplicationField.set(activityThread, app); mInitialApplicationField.set(activityThread, app);
} catch (Throwable throwable) { } catch (Throwable throwable) {
// this is a workaround, so failing is not an error // 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());
} }
} }
} }

View file

@ -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());
}
}

View file

@ -3,6 +3,7 @@ package com.genymobile.scrcpy.wrappers;
import com.genymobile.scrcpy.Ln; import com.genymobile.scrcpy.Ln;
import android.content.ClipData; import android.content.ClipData;
import android.content.IOnPrimaryClipChangedListener;
import android.os.Build; import android.os.Build;
import android.os.IInterface; import android.os.IInterface;
@ -10,13 +11,10 @@ import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method; import java.lang.reflect.Method;
public class ClipboardManager { public class ClipboardManager {
private static final String PACKAGE_NAME = "com.android.shell";
private static final int USER_ID = 0;
private final IInterface manager; private final IInterface manager;
private Method getPrimaryClipMethod; private Method getPrimaryClipMethod;
private Method setPrimaryClipMethod; private Method setPrimaryClipMethod;
private Method addPrimaryClipChangedListener;
public ClipboardManager(IInterface manager) { public ClipboardManager(IInterface manager) {
this.manager = manager; this.manager = manager;
@ -46,17 +44,17 @@ public class ClipboardManager {
private static ClipData getPrimaryClip(Method method, IInterface manager) throws InvocationTargetException, IllegalAccessException { private static ClipData getPrimaryClip(Method method, IInterface manager) throws InvocationTargetException, IllegalAccessException {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { 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) private static void setPrimaryClip(Method method, IInterface manager, ClipData clipData)
throws InvocationTargetException, IllegalAccessException { throws InvocationTargetException, IllegalAccessException {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
method.invoke(manager, clipData, PACKAGE_NAME); method.invoke(manager, clipData, ServiceManager.PACKAGE_NAME);
} else { } 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 { try {
Method method = getSetPrimaryClipMethod(); Method method = getSetPrimaryClipMethod();
ClipData clipData = ClipData.newPlainText(null, text); ClipData clipData = ClipData.newPlainText(null, text);
setPrimaryClip(method, manager, clipData); setPrimaryClip(method, manager, clipData);
return true;
} catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) {
Ln.e("Could not invoke method", 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;
} }
} }
} }

View file

@ -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;
}
}

View file

@ -12,15 +12,28 @@ public final class DisplayManager {
this.manager = manager; this.manager = manager;
} }
public DisplayInfo getDisplayInfo() { public DisplayInfo getDisplayInfo(int displayId) {
try { 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(); Class<?> cls = displayInfo.getClass();
// width and height already take the rotation into account // width and height already take the rotation into account
int width = cls.getDeclaredField("logicalWidth").getInt(displayInfo); int width = cls.getDeclaredField("logicalWidth").getInt(displayInfo);
int height = cls.getDeclaredField("logicalHeight").getInt(displayInfo); int height = cls.getDeclaredField("logicalHeight").getInt(displayInfo);
int rotation = cls.getDeclaredField("rotation").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) { } catch (Exception e) {
throw new AssertionError(e); throw new AssertionError(e);
} }

View file

@ -17,6 +17,8 @@ public final class InputManager {
private final IInterface manager; private final IInterface manager;
private Method injectInputEventMethod; private Method injectInputEventMethod;
private static Method setDisplayIdMethod;
public InputManager(IInterface manager) { public InputManager(IInterface manager) {
this.manager = manager; this.manager = manager;
} }
@ -37,4 +39,22 @@ public final class InputManager {
return false; 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;
}
}
} }

View file

@ -6,8 +6,12 @@ import android.os.IInterface;
import java.lang.reflect.Method; import java.lang.reflect.Method;
@SuppressLint("PrivateApi") @SuppressLint("PrivateApi,DiscouragedPrivateApi")
public final class ServiceManager { 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 final Method getServiceMethod;
private WindowManager windowManager; private WindowManager windowManager;
@ -16,6 +20,7 @@ public final class ServiceManager {
private PowerManager powerManager; private PowerManager powerManager;
private StatusBarManager statusBarManager; private StatusBarManager statusBarManager;
private ClipboardManager clipboardManager; private ClipboardManager clipboardManager;
private ActivityManager activityManager;
public ServiceManager() { public ServiceManager() {
try { try {
@ -76,4 +81,21 @@ public final class ServiceManager {
} }
return clipboardManager; 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;
}
} }

View file

@ -121,12 +121,14 @@ public final class SurfaceControl {
return setDisplayPowerModeMethod; return setDisplayPowerModeMethod;
} }
public static void setDisplayPowerMode(IBinder displayToken, int mode) { public static boolean setDisplayPowerMode(IBinder displayToken, int mode) {
try { try {
Method method = getSetDisplayPowerModeMethod(); Method method = getSetDisplayPowerModeMethod();
method.invoke(null, displayToken, mode); method.invoke(null, displayToken, mode);
return true;
} catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) {
Ln.e("Could not invoke method", e); Ln.e("Could not invoke method", e);
return false;
} }
} }

View file

@ -93,13 +93,13 @@ public final class WindowManager {
} }
} }
public void registerRotationWatcher(IRotationWatcher rotationWatcher) { public void registerRotationWatcher(IRotationWatcher rotationWatcher, int displayId) {
try { try {
Class<?> cls = manager.getClass(); Class<?> cls = manager.getClass();
try { try {
// display parameter added since this commit: // display parameter added since this commit:
// https://android.googlesource.com/platform/frameworks/base/+/35fa3c26adcb5f6577849fd0df5228b1f67cf2c6%5E%21/#F1 // 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) { } catch (NoSuchMethodException e) {
// old version // old version
cls.getMethod("watchRotation", IRotationWatcher.class).invoke(manager, rotationWatcher); cls.getMethod("watchRotation", IRotationWatcher.class).invoke(manager, rotationWatcher);

Binary file not shown.