mirror of
https://github.com/Genymobile/scrcpy.git
synced 2025-04-20 19:45:00 +00:00
Add Android 4.4 support and dump hierarchy
This commit is contained in:
parent
acc9bcbc0a
commit
46d1c009d8
14 changed files with 856 additions and 251 deletions
Binary file not shown.
|
@ -4,7 +4,7 @@ android {
|
|||
compileSdkVersion 29
|
||||
defaultConfig {
|
||||
applicationId "com.genymobile.scrcpy"
|
||||
minSdkVersion 21
|
||||
minSdkVersion 19
|
||||
targetSdkVersion 29
|
||||
versionCode 14
|
||||
versionName "1.12.1"
|
||||
|
|
|
@ -0,0 +1,360 @@
|
|||
package com.genymobile.scrcpy;
|
||||
|
||||
import android.util.Xml;
|
||||
import android.view.accessibility.AccessibilityNodeInfo;
|
||||
|
||||
import org.xmlpull.v1.XmlSerializer;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import android.os.Build;
|
||||
|
||||
import android.app.UiAutomation;
|
||||
import android.os.Looper;
|
||||
import android.os.HandlerThread;
|
||||
import android.view.accessibility.AccessibilityEvent;
|
||||
import android.view.accessibility.AccessibilityNodeInfo;
|
||||
import android.view.accessibility.AccessibilityWindowInfo;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.FileDescriptor;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
|
||||
import android.os.Handler;
|
||||
|
||||
public class AccessibilityNodeInfoDumper {
|
||||
private UiAutomation mUiAutomation;
|
||||
private HandlerThread mHandlerThread;
|
||||
|
||||
private Handler handler;
|
||||
private final Device device;
|
||||
private final DesktopConnection connection;
|
||||
private AccessibilityEventListenerImpl accessibilityEventListenerImpl;
|
||||
|
||||
private static final String[] NAF_EXCLUDED_CLASSES = new String[]{
|
||||
android.widget.GridView.class.getName(), android.widget.GridLayout.class.getName(),
|
||||
android.widget.ListView.class.getName(), android.widget.TableLayout.class.getName()
|
||||
};
|
||||
|
||||
public AccessibilityNodeInfoDumper(Handler handler, Device device, DesktopConnection connection) {
|
||||
this.handler = handler;
|
||||
this.device = device;
|
||||
this.connection = connection;
|
||||
this.accessibilityEventListenerImpl = new AccessibilityEventListenerImpl();
|
||||
}
|
||||
|
||||
private class AccessibilityEventListenerImpl implements UiAutomation.OnAccessibilityEventListener {
|
||||
@Override
|
||||
public void onAccessibilityEvent(AccessibilityEvent event) {
|
||||
// Ln.i("EventType: " + AccessibilityEvent.eventTypeToString(event.getEventType()));
|
||||
switch (event.getEventType()) {
|
||||
case AccessibilityEvent.TYPE_ANNOUNCEMENT:
|
||||
case AccessibilityEvent.TYPE_ASSIST_READING_CONTEXT:
|
||||
case AccessibilityEvent.TYPE_GESTURE_DETECTION_END:
|
||||
case AccessibilityEvent.TYPE_GESTURE_DETECTION_START:
|
||||
case AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED:
|
||||
case AccessibilityEvent.TYPE_TOUCH_EXPLORATION_GESTURE_END:
|
||||
case AccessibilityEvent.TYPE_TOUCH_EXPLORATION_GESTURE_START:
|
||||
case AccessibilityEvent.TYPE_TOUCH_INTERACTION_END:
|
||||
case AccessibilityEvent.TYPE_TOUCH_INTERACTION_START:
|
||||
case AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED:
|
||||
case AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED:
|
||||
case AccessibilityEvent.TYPE_VIEW_CLICKED:
|
||||
case AccessibilityEvent.TYPE_VIEW_CONTEXT_CLICKED:
|
||||
case AccessibilityEvent.TYPE_VIEW_FOCUSED:
|
||||
case AccessibilityEvent.TYPE_VIEW_HOVER_ENTER:
|
||||
case AccessibilityEvent.TYPE_VIEW_HOVER_EXIT:
|
||||
case AccessibilityEvent.TYPE_VIEW_LONG_CLICKED:
|
||||
case AccessibilityEvent.TYPE_VIEW_SCROLLED:
|
||||
case AccessibilityEvent.TYPE_VIEW_SELECTED:
|
||||
case AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED:
|
||||
case AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED:
|
||||
case AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY:
|
||||
case AccessibilityEvent.TYPE_WINDOWS_CHANGED:
|
||||
// case AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED:
|
||||
case AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED:
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
try {
|
||||
dumpWindowHierarchy(device, bos);
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
Ln.e("onAccessibilityEvent error: " + e.getMessage());
|
||||
} finally {
|
||||
if (bos != null) {
|
||||
try {
|
||||
bos.close();
|
||||
} catch (IOException e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ln.i("dumpHierarchy size: " + bos.toByteArray().length);
|
||||
ByteBuffer b = ByteBuffer.allocate(4 + bos.toByteArray().length);
|
||||
b.order(ByteOrder.LITTLE_ENDIAN);
|
||||
b.putInt(bos.toByteArray().length);
|
||||
b.put(bos.toByteArray());
|
||||
byte[] hierarchy = b.array();
|
||||
try {
|
||||
IO.writeFully(connection.getVideoChannel(), hierarchy, 0, hierarchy.length);// IOException
|
||||
} catch (IOException e) {
|
||||
Common.stopScrcpy(handler, "hierarchy");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void start() {
|
||||
Object connection = null;
|
||||
mHandlerThread = new HandlerThread("ScrcpyUiAutomationHandlerThread");
|
||||
mHandlerThread.start();
|
||||
try {
|
||||
Class<?> UiAutomationConnection = Class.forName("android.app.UiAutomationConnection");
|
||||
Constructor<?> newInstance = UiAutomationConnection.getDeclaredConstructor();
|
||||
newInstance.setAccessible(true);
|
||||
connection = newInstance.newInstance();
|
||||
Class<?> IUiAutomationConnection = Class.forName("android.app.IUiAutomationConnection");
|
||||
Constructor<?> newUiAutomation = UiAutomation.class.getDeclaredConstructor(Looper.class, IUiAutomationConnection);
|
||||
mUiAutomation = (UiAutomation) newUiAutomation.newInstance(mHandlerThread.getLooper(), connection);
|
||||
Method connect = UiAutomation.class.getDeclaredMethod("connect");
|
||||
connect.invoke(mUiAutomation);
|
||||
Ln.i("mUiAutomation: " + mUiAutomation);
|
||||
if (mUiAutomation != null) {
|
||||
mUiAutomation.setOnAccessibilityEventListener(accessibilityEventListenerImpl);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
Ln.e("AccessibilityNodeInfoDumper start: " + e.getMessage());
|
||||
stop();
|
||||
}
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
if (mUiAutomation != null) {
|
||||
mUiAutomation.setOnAccessibilityEventListener(null);
|
||||
try {
|
||||
Method disconnect = UiAutomation.class.getDeclaredMethod("disconnect");
|
||||
disconnect.invoke(mUiAutomation);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
Ln.e("disconnect: " + e.getMessage());
|
||||
}
|
||||
mUiAutomation = null;
|
||||
}
|
||||
if (mHandlerThread != null) {
|
||||
mHandlerThread.quit();
|
||||
}
|
||||
}
|
||||
|
||||
private void restart() {
|
||||
stop();
|
||||
start();
|
||||
}
|
||||
|
||||
public void dumpWindowHierarchy(Device device, OutputStream out) throws IOException {
|
||||
XmlSerializer serializer = Xml.newSerializer();
|
||||
// serializer.setFeature("http://xmlpull.org/v1/doc/features.html#indent-output", true);
|
||||
serializer.setOutput(out, "UTF-8");
|
||||
|
||||
serializer.startDocument("UTF-8", true);
|
||||
serializer.startTag("", "hierarchy"); // TODO(allenhair): Should we use a namespace?
|
||||
serializer.attribute("", "rotation", Integer.toString(device.getRotation()));
|
||||
|
||||
int width = device.getScreenInfo().getContentRect().width();
|
||||
int height = device.getScreenInfo().getContentRect().height();
|
||||
|
||||
if (mUiAutomation == null) {
|
||||
restart();
|
||||
}
|
||||
|
||||
if (mUiAutomation != null) {
|
||||
Set<AccessibilityNodeInfo> roots = new HashSet();
|
||||
// Start with the active window, which seems to sometimes be missing from the list returned
|
||||
// by the UiAutomation.
|
||||
AccessibilityNodeInfo activeRoot = mUiAutomation.getRootInActiveWindow();
|
||||
if (activeRoot != null) {
|
||||
roots.add(activeRoot);
|
||||
}
|
||||
// Support multi-window searches for API level 21 and up.
|
||||
if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
for (AccessibilityWindowInfo window : mUiAutomation.getWindows()) {
|
||||
AccessibilityNodeInfo root = window.getRoot();
|
||||
if (root == null) {
|
||||
Ln.w(String.format("Skipping null root node for window: %s", window.toString()));
|
||||
continue;
|
||||
}
|
||||
roots.add(root);
|
||||
}
|
||||
}
|
||||
AccessibilityNodeInfo[] nodeInfos = roots.toArray(new AccessibilityNodeInfo[roots.size()]);
|
||||
for (int i = 0; i < nodeInfos.length; i++) {
|
||||
dumpNodeRec(nodeInfos[i], serializer, 0, width, height, nodeInfos.length - 1 - i);
|
||||
}
|
||||
}
|
||||
|
||||
serializer.endTag("", "hierarchy");
|
||||
serializer.endDocument();
|
||||
}
|
||||
|
||||
private static void dumpNodeRec(AccessibilityNodeInfo node, XmlSerializer serializer, int index,
|
||||
int width, int height, int zIndex) throws IOException {
|
||||
serializer.startTag("", "node");
|
||||
if (!nafExcludedClass(node) && !nafCheck(node))
|
||||
serializer.attribute("", "NAF", Boolean.toString(true));
|
||||
serializer.attribute("", "index", Integer.toString(index));
|
||||
serializer.attribute("", "text", safeCharSeqToString(node.getText()));
|
||||
serializer.attribute("", "resource-id", safeCharSeqToString(node.getViewIdResourceName()));
|
||||
serializer.attribute("", "class", safeCharSeqToString(node.getClassName()));
|
||||
serializer.attribute("", "package", safeCharSeqToString(node.getPackageName()));
|
||||
serializer.attribute("", "content-desc", safeCharSeqToString(node.getContentDescription()));
|
||||
serializer.attribute("", "checkable", Boolean.toString(node.isCheckable()));
|
||||
serializer.attribute("", "checked", Boolean.toString(node.isChecked()));
|
||||
serializer.attribute("", "clickable", Boolean.toString(node.isClickable()));
|
||||
serializer.attribute("", "enabled", Boolean.toString(node.isEnabled()));
|
||||
serializer.attribute("", "focusable", Boolean.toString(node.isFocusable()));
|
||||
serializer.attribute("", "focused", Boolean.toString(node.isFocused()));
|
||||
serializer.attribute("", "scrollable", Boolean.toString(node.isScrollable()));
|
||||
serializer.attribute("", "long-clickable", Boolean.toString(node.isLongClickable()));
|
||||
serializer.attribute("", "password", Boolean.toString(node.isPassword()));
|
||||
serializer.attribute("", "selected", Boolean.toString(node.isSelected()));
|
||||
serializer.attribute("", "visible-to-user", Boolean.toString(node.isVisibleToUser()));
|
||||
serializer.attribute("", "bounds", AccessibilityNodeInfoHelper.getVisibleBoundsInScreen(
|
||||
node, width, height).toShortString());
|
||||
serializer.attribute("", "z-index", Integer.toString(zIndex));
|
||||
int count = node.getChildCount();
|
||||
for (int i = 0; i < count; i++) {
|
||||
AccessibilityNodeInfo child = node.getChild(i);
|
||||
if (child != null) {
|
||||
if (child.isVisibleToUser()) {
|
||||
dumpNodeRec(child, serializer, i, width, height, zIndex);
|
||||
child.recycle();
|
||||
}
|
||||
// else {
|
||||
// Ln.i(String.format("Skipping invisible child: %s", child.toString()));
|
||||
// }
|
||||
} else {
|
||||
Ln.i(String.format("Null child %d/%d, parent: %s", i, count, node.toString()));
|
||||
}
|
||||
}
|
||||
serializer.endTag("", "node");
|
||||
}
|
||||
|
||||
/**
|
||||
* The list of classes to exclude my not be complete. We're attempting to
|
||||
* only reduce noise from standard layout classes that may be falsely
|
||||
* configured to accept clicks and are also enabled.
|
||||
*
|
||||
* @param node
|
||||
* @return true if node is excluded.
|
||||
*/
|
||||
private static boolean nafExcludedClass(AccessibilityNodeInfo node) {
|
||||
String className = safeCharSeqToString(node.getClassName());
|
||||
for (String excludedClassName : NAF_EXCLUDED_CLASSES) {
|
||||
if (className.endsWith(excludedClassName))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* We're looking for UI controls that are enabled, clickable but have no
|
||||
* text nor content-description. Such controls configuration indicate an
|
||||
* interactive control is present in the UI and is most likely not
|
||||
* accessibility friendly. We refer to such controls here as NAF controls
|
||||
* (Not Accessibility Friendly)
|
||||
*
|
||||
* @param node
|
||||
* @return false if a node fails the check, true if all is OK
|
||||
*/
|
||||
private static boolean nafCheck(AccessibilityNodeInfo node) {
|
||||
boolean isNaf = node.isClickable() && node.isEnabled()
|
||||
&& safeCharSeqToString(node.getContentDescription()).isEmpty()
|
||||
&& safeCharSeqToString(node.getText()).isEmpty();
|
||||
|
||||
if (!isNaf)
|
||||
return true;
|
||||
|
||||
// check children since sometimes the containing element is clickable
|
||||
// and NAF but a child's text or description is available. Will assume
|
||||
// such layout as fine.
|
||||
return childNafCheck(node);
|
||||
}
|
||||
|
||||
/**
|
||||
* This should be used when it's already determined that the node is NAF and
|
||||
* a further check of its children is in order. A node maybe a container
|
||||
* such as LinerLayout and may be set to be clickable but have no text or
|
||||
* content description but it is counting on one of its children to fulfill
|
||||
* the requirement for being accessibility friendly by having one or more of
|
||||
* its children fill the text or content-description. Such a combination is
|
||||
* considered by this dumper as acceptable for accessibility.
|
||||
*
|
||||
* @param node
|
||||
* @return false if node fails the check.
|
||||
*/
|
||||
private static boolean childNafCheck(AccessibilityNodeInfo node) {
|
||||
int childCount = node.getChildCount();
|
||||
for (int x = 0; x < childCount; x++) {
|
||||
AccessibilityNodeInfo childNode = node.getChild(x);
|
||||
if (childNode != null) {//wen add childNode null
|
||||
if (!safeCharSeqToString(childNode.getContentDescription()).isEmpty()
|
||||
|| !safeCharSeqToString(childNode.getText()).isEmpty())
|
||||
return true;
|
||||
|
||||
if (childNafCheck(childNode))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String safeCharSeqToString(CharSequence cs) {
|
||||
if (cs == null)
|
||||
return "";
|
||||
else {
|
||||
return stripInvalidXMLChars(cs);
|
||||
}
|
||||
}
|
||||
|
||||
private static String stripInvalidXMLChars(CharSequence cs) {
|
||||
StringBuffer ret = new StringBuffer();
|
||||
char ch;
|
||||
/* http://www.w3.org/TR/xml11/#charsets
|
||||
[#x1-#x8], [#xB-#xC], [#xE-#x1F], [#x7F-#x84], [#x86-#x9F], [#xFDD0-#xFDDF],
|
||||
[#x1FFFE-#x1FFFF], [#x2FFFE-#x2FFFF], [#x3FFFE-#x3FFFF],
|
||||
[#x4FFFE-#x4FFFF], [#x5FFFE-#x5FFFF], [#x6FFFE-#x6FFFF],
|
||||
[#x7FFFE-#x7FFFF], [#x8FFFE-#x8FFFF], [#x9FFFE-#x9FFFF],
|
||||
[#xAFFFE-#xAFFFF], [#xBFFFE-#xBFFFF], [#xCFFFE-#xCFFFF],
|
||||
[#xDFFFE-#xDFFFF], [#xEFFFE-#xEFFFF], [#xFFFFE-#xFFFFF],
|
||||
[#x10FFFE-#x10FFFF].
|
||||
*/
|
||||
for (int i = 0; i < cs.length(); i++) {
|
||||
ch = cs.charAt(i);
|
||||
|
||||
if ((ch >= 0x1 && ch <= 0x8) || (ch >= 0xB && ch <= 0xC) || (ch >= 0xE && ch <= 0x1F) ||
|
||||
(ch >= 0x7F && ch <= 0x84) || (ch >= 0x86 && ch <= 0x9f) ||
|
||||
(ch >= 0xFDD0 && ch <= 0xFDDF) || (ch >= 0x1FFFE && ch <= 0x1FFFF) ||
|
||||
(ch >= 0x2FFFE && ch <= 0x2FFFF) || (ch >= 0x3FFFE && ch <= 0x3FFFF) ||
|
||||
(ch >= 0x4FFFE && ch <= 0x4FFFF) || (ch >= 0x5FFFE && ch <= 0x5FFFF) ||
|
||||
(ch >= 0x6FFFE && ch <= 0x6FFFF) || (ch >= 0x7FFFE && ch <= 0x7FFFF) ||
|
||||
(ch >= 0x8FFFE && ch <= 0x8FFFF) || (ch >= 0x9FFFE && ch <= 0x9FFFF) ||
|
||||
(ch >= 0xAFFFE && ch <= 0xAFFFF) || (ch >= 0xBFFFE && ch <= 0xBFFFF) ||
|
||||
(ch >= 0xCFFFE && ch <= 0xCFFFF) || (ch >= 0xDFFFE && ch <= 0xDFFFF) ||
|
||||
(ch >= 0xEFFFE && ch <= 0xEFFFF) || (ch >= 0xFFFFE && ch <= 0xFFFFF) ||
|
||||
(ch >= 0x10FFFE && ch <= 0x10FFFF))
|
||||
ret.append(".");
|
||||
else
|
||||
ret.append(ch);
|
||||
}
|
||||
return ret.toString();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
package com.genymobile.scrcpy;
|
||||
|
||||
import android.graphics.Rect;
|
||||
import android.os.Build;
|
||||
import android.view.accessibility.AccessibilityNodeInfo;
|
||||
|
||||
public class AccessibilityNodeInfoHelper {
|
||||
/**
|
||||
* Returns the node's bounds clipped to the size of the display
|
||||
*
|
||||
* @param node
|
||||
* @param width pixel width of the display
|
||||
* @param height pixel height of the display
|
||||
* @return null if node is null, else a Rect containing visible bounds
|
||||
*/
|
||||
static Rect getVisibleBoundsInScreen(AccessibilityNodeInfo node, int width, int height) {
|
||||
if (node == null) {
|
||||
return null;
|
||||
}
|
||||
// targeted node's bounds
|
||||
Rect nodeRect = new Rect();
|
||||
node.getBoundsInScreen(nodeRect);
|
||||
|
||||
Rect displayRect = new Rect();
|
||||
displayRect.top = 0;
|
||||
displayRect.left = 0;
|
||||
displayRect.right = width;
|
||||
displayRect.bottom = height;
|
||||
|
||||
nodeRect.intersect(displayRect);
|
||||
|
||||
// On platforms that give us access to the node's window
|
||||
if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
// Trim any portion of the bounds that are outside the window
|
||||
Rect window = new Rect();
|
||||
if (node.getWindow() != null) {
|
||||
node.getWindow().getBoundsInScreen(window);
|
||||
nodeRect.intersect(window);
|
||||
}
|
||||
}
|
||||
|
||||
return nodeRect;
|
||||
}
|
||||
}
|
18
server/src/main/java/com/genymobile/scrcpy/Common.java
Normal file
18
server/src/main/java/com/genymobile/scrcpy/Common.java
Normal file
|
@ -0,0 +1,18 @@
|
|||
package com.genymobile.scrcpy;
|
||||
|
||||
import android.os.Message;
|
||||
import android.os.Handler;
|
||||
|
||||
public class Common {
|
||||
|
||||
public static void stopScrcpy(Handler handler, String obj) {
|
||||
Message msg = Message.obtain();
|
||||
msg.what = 1;
|
||||
msg.obj = obj;
|
||||
try {
|
||||
handler.sendMessage(msg);
|
||||
} catch (java.lang.IllegalStateException e) {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
|
@ -40,11 +40,11 @@ public class ControlMessageReader {
|
|||
int head = buffer.position();
|
||||
int r = input.read(rawBuffer, head, rawBuffer.length - head);
|
||||
if (r == -1) {
|
||||
Ln.i("=========================================>>>");
|
||||
Ln.i("head: " + head);
|
||||
Ln.i("rawBuffer.length: " + rawBuffer.length);
|
||||
Ln.i("rawBuffer: " + Arrays.toString(rawBuffer));
|
||||
Ln.i("=========================================<<<");
|
||||
// Ln.i("=========================================>>>");
|
||||
// Ln.i("head: " + head);
|
||||
// Ln.i("rawBuffer.length: " + rawBuffer.length);
|
||||
// Ln.i("rawBuffer: " + Arrays.toString(rawBuffer));
|
||||
// Ln.i("=========================================<<<");
|
||||
throw new EOFException("Controller socket closed");
|
||||
}
|
||||
buffer.position(head + r);
|
||||
|
|
|
@ -11,28 +11,39 @@ import java.io.InputStream;
|
|||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import java.net.Socket;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.channels.SocketChannel;
|
||||
import java.nio.channels.ServerSocketChannel;
|
||||
|
||||
public final class DesktopConnection implements Closeable {
|
||||
|
||||
private static final int DEVICE_NAME_FIELD_LENGTH = 64;
|
||||
|
||||
private static final String SOCKET_NAME = "scrcpy";
|
||||
private static final int SOCKET_PORT = 6612;
|
||||
|
||||
private final LocalSocket videoSocket;
|
||||
private final Socket videoSocket;
|
||||
private final FileDescriptor videoFd;
|
||||
private final SocketChannel videoChannel;
|
||||
|
||||
private final LocalSocket controlSocket;
|
||||
private final Socket controlSocket;
|
||||
private final InputStream controlInputStream;
|
||||
private final OutputStream controlOutputStream;
|
||||
|
||||
private final ControlMessageReader reader = new ControlMessageReader();
|
||||
private final DeviceMessageWriter writer = new DeviceMessageWriter();
|
||||
|
||||
private DesktopConnection(LocalSocket videoSocket, LocalSocket controlSocket) throws IOException {
|
||||
this.videoSocket = videoSocket;
|
||||
this.controlSocket = controlSocket;
|
||||
controlInputStream = controlSocket.getInputStream();
|
||||
controlOutputStream = controlSocket.getOutputStream();
|
||||
videoFd = videoSocket.getFileDescriptor();
|
||||
private DesktopConnection(SocketChannel videoSocket, SocketChannel controlSocket) throws IOException {
|
||||
this.videoSocket = videoSocket.socket();
|
||||
this.controlSocket = controlSocket.socket();
|
||||
controlInputStream = controlSocket.socket().getInputStream();
|
||||
controlOutputStream = controlSocket.socket().getOutputStream();
|
||||
// videoFd = videoSocket.getFileDescriptor();
|
||||
videoFd = null;//no use
|
||||
videoChannel = videoSocket.socket().getChannel();
|
||||
Ln.i("videoChannel: " + videoChannel);
|
||||
}
|
||||
|
||||
private static LocalSocket connect(String abstractName) throws IOException {
|
||||
|
@ -42,10 +53,11 @@ public final class DesktopConnection implements Closeable {
|
|||
}
|
||||
|
||||
public static DesktopConnection open(Device device, boolean tunnelForward) throws IOException {
|
||||
LocalSocket videoSocket;
|
||||
LocalSocket controlSocket;
|
||||
SocketChannel videoSocket = null;
|
||||
SocketChannel controlSocket = null;
|
||||
if (tunnelForward) {
|
||||
LocalServerSocket localServerSocket = new LocalServerSocket(SOCKET_NAME);
|
||||
ServerSocketChannel localServerSocket = ServerSocketChannel.open();
|
||||
localServerSocket.socket().bind(new InetSocketAddress(SOCKET_PORT));
|
||||
try {
|
||||
videoSocket = localServerSocket.accept();
|
||||
// send one byte so the client may read() to detect a connection error
|
||||
|
@ -60,13 +72,13 @@ public final class DesktopConnection implements Closeable {
|
|||
localServerSocket.close();
|
||||
}
|
||||
} else {
|
||||
videoSocket = connect(SOCKET_NAME);
|
||||
try {
|
||||
controlSocket = connect(SOCKET_NAME);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
videoSocket.close();
|
||||
throw e;
|
||||
}
|
||||
// videoSocket = connect(SOCKET_NAME);
|
||||
// try {
|
||||
// controlSocket = connect(SOCKET_NAME);
|
||||
// } catch (IOException | RuntimeException e) {
|
||||
// videoSocket.close();
|
||||
// throw e;
|
||||
// }
|
||||
}
|
||||
|
||||
DesktopConnection connection = new DesktopConnection(videoSocket, controlSocket);
|
||||
|
@ -84,26 +96,30 @@ public final class DesktopConnection implements Closeable {
|
|||
controlSocket.close();
|
||||
}
|
||||
|
||||
@SuppressWarnings("checkstyle:MagicNumber")
|
||||
private void send(String deviceName, int width, int height) throws IOException {
|
||||
byte[] buffer = new byte[DEVICE_NAME_FIELD_LENGTH + 4];
|
||||
|
||||
byte[] deviceNameBytes = deviceName.getBytes(StandardCharsets.UTF_8);
|
||||
int len = StringUtils.getUtf8TruncationIndex(deviceNameBytes, DEVICE_NAME_FIELD_LENGTH - 1);
|
||||
System.arraycopy(deviceNameBytes, 0, buffer, 0, len);
|
||||
// byte[] are always 0-initialized in java, no need to set '\0' explicitly
|
||||
|
||||
buffer[DEVICE_NAME_FIELD_LENGTH] = (byte) (width >> 8);
|
||||
buffer[DEVICE_NAME_FIELD_LENGTH + 1] = (byte) width;
|
||||
buffer[DEVICE_NAME_FIELD_LENGTH + 2] = (byte) (height >> 8);
|
||||
buffer[DEVICE_NAME_FIELD_LENGTH + 3] = (byte) height;
|
||||
IO.writeFully(videoFd, buffer, 0, buffer.length);
|
||||
}
|
||||
// @SuppressWarnings("checkstyle:MagicNumber")
|
||||
// private void send(String deviceName, int width, int height) throws IOException {
|
||||
// byte[] buffer = new byte[DEVICE_NAME_FIELD_LENGTH + 4];
|
||||
//
|
||||
// byte[] deviceNameBytes = deviceName.getBytes(StandardCharsets.UTF_8);
|
||||
// int len = StringUtils.getUtf8TruncationIndex(deviceNameBytes, DEVICE_NAME_FIELD_LENGTH - 1);
|
||||
// System.arraycopy(deviceNameBytes, 0, buffer, 0, len);
|
||||
// // byte[] are always 0-initialized in java, no need to set '\0' explicitly
|
||||
//
|
||||
// buffer[DEVICE_NAME_FIELD_LENGTH] = (byte) (width >> 8);
|
||||
// buffer[DEVICE_NAME_FIELD_LENGTH + 1] = (byte) width;
|
||||
// buffer[DEVICE_NAME_FIELD_LENGTH + 2] = (byte) (height >> 8);
|
||||
// buffer[DEVICE_NAME_FIELD_LENGTH + 3] = (byte) height;
|
||||
// IO.writeFully(videoFd, buffer, 0, buffer.length);
|
||||
// }
|
||||
|
||||
public FileDescriptor getVideoFd() {
|
||||
return videoFd;
|
||||
}
|
||||
|
||||
public SocketChannel getVideoChannel() {
|
||||
return videoChannel;
|
||||
}
|
||||
|
||||
public ControlMessage receiveControlMessage() throws IOException {
|
||||
ControlMessage msg = reader.next();
|
||||
while (msg == null) {
|
||||
|
|
|
@ -106,6 +106,8 @@ public final class Device {
|
|||
Size videoSize = screenInfo.getVideoSize();
|
||||
Size clientVideoSize = position.getScreenSize();
|
||||
if (!videoSize.equals(clientVideoSize)) {
|
||||
Ln.i("video width: " + videoSize.getWidth() + ", video height: " + videoSize.getHeight());
|
||||
Ln.i("client width: " + clientVideoSize.getWidth() + ", client height: " + clientVideoSize.getHeight());
|
||||
// The client sends a click relative to a video with wrong dimensions,
|
||||
// the device may have been rotated since the event was generated, so ignore the event
|
||||
return null;
|
||||
|
@ -195,4 +197,8 @@ public final class Device {
|
|||
static Rect flipRect(Rect crop) {
|
||||
return new Rect(crop.top, crop.left, crop.bottom, crop.right);
|
||||
}
|
||||
|
||||
public int getRotation() {
|
||||
return serviceManager.getWindowManager().getRotation();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,40 +1,36 @@
|
|||
package com.genymobile.scrcpy;
|
||||
|
||||
import android.system.ErrnoException;
|
||||
import android.system.Os;
|
||||
import android.system.OsConstants;
|
||||
|
||||
import java.io.FileDescriptor;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.SocketChannel;
|
||||
|
||||
public final class IO {
|
||||
private IO() {
|
||||
// not instantiable
|
||||
}
|
||||
|
||||
public static void writeFully(FileDescriptor fd, ByteBuffer from) throws IOException {
|
||||
public synchronized static void writeFully(SocketChannel channel, ByteBuffer from) throws IOException {
|
||||
// ByteBuffer position is not updated as expected by Os.write() on old Android versions, so
|
||||
// count the remaining bytes manually.
|
||||
// See <https://github.com/Genymobile/scrcpy/issues/291>.
|
||||
int remaining = from.remaining();
|
||||
while (remaining > 0) {
|
||||
try {
|
||||
int w = Os.write(fd, from);
|
||||
int w = channel.write(from);
|
||||
if (BuildConfig.DEBUG && w < 0) {
|
||||
// w should not be negative, since an exception is thrown on error
|
||||
throw new AssertionError("Os.write() returned a negative value (" + w + ")");
|
||||
throw new AssertionError("write() returned a negative value (" + w + ")");
|
||||
}
|
||||
remaining -= w;
|
||||
} catch (ErrnoException e) {
|
||||
if (e.errno != OsConstants.EINTR) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void writeFully(FileDescriptor fd, byte[] buffer, int offset, int len) throws IOException {
|
||||
writeFully(fd, ByteBuffer.wrap(buffer, offset, len));
|
||||
public synchronized static void writeFully(SocketChannel channel, byte[] buffer, int offset, int len) throws IOException {
|
||||
writeFully(channel, ByteBuffer.wrap(buffer, offset, len));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -6,9 +6,20 @@ import java.nio.ByteBuffer;
|
|||
public class JpegEncoder {
|
||||
|
||||
static {
|
||||
System.loadLibrary("compress");
|
||||
try {
|
||||
System.loadLibrary("compress");
|
||||
} catch (UnsatisfiedLinkError e1) {
|
||||
try {
|
||||
System.load("/data/local/tmp/libturbojpeg.so");
|
||||
System.load("/data/local/tmp/libcompress.so");
|
||||
} catch (UnsatisfiedLinkError e2) {
|
||||
Ln.e("UnsatisfiedLinkError: " + e2.getMessage());
|
||||
System.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static native byte[] compress(ByteBuffer buffer, int width, int pitch, int height, int quality);
|
||||
|
||||
public static native void test();
|
||||
}
|
||||
|
|
|
@ -14,6 +14,9 @@ public class Options {
|
|||
//wen add
|
||||
private int quality;
|
||||
private int scale;
|
||||
private boolean controlOnly;
|
||||
private boolean nalu;
|
||||
private boolean dumpHierarchy;
|
||||
|
||||
public int getMaxSize() {
|
||||
return maxSize;
|
||||
|
@ -87,4 +90,27 @@ public class Options {
|
|||
this.scale = scale;
|
||||
}
|
||||
|
||||
public boolean getControlOnly() {
|
||||
return controlOnly;
|
||||
}
|
||||
|
||||
public void setControlOnly(boolean controlOnly) {
|
||||
this.controlOnly = controlOnly;
|
||||
}
|
||||
|
||||
public boolean getNALU() {
|
||||
return nalu;
|
||||
}
|
||||
|
||||
public void setNALU(boolean nalu) {
|
||||
this.nalu = nalu;
|
||||
}
|
||||
|
||||
public boolean getDumpHierarchy() {
|
||||
return dumpHierarchy;
|
||||
}
|
||||
|
||||
public void setDumpHierarchy(boolean dumpHierarchy) {
|
||||
this.dumpHierarchy = dumpHierarchy;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,9 +3,6 @@ package com.genymobile.scrcpy;
|
|||
import com.genymobile.scrcpy.wrappers.SurfaceControl;
|
||||
|
||||
import android.graphics.Rect;
|
||||
import android.media.MediaCodec;
|
||||
import android.media.MediaCodecInfo;
|
||||
import android.media.MediaFormat;
|
||||
import android.os.Build;
|
||||
import android.os.IBinder;
|
||||
import android.view.Surface;
|
||||
|
@ -14,11 +11,11 @@ import java.io.FileDescriptor;
|
|||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import android.graphics.ImageFormat;
|
||||
import android.graphics.YuvImage;
|
||||
import android.media.Image;
|
||||
import android.media.MediaExtractor;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.File;
|
||||
|
@ -36,12 +33,12 @@ import android.os.Handler;
|
|||
import android.os.Message;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import android.os.Looper;
|
||||
|
||||
import java.nio.ByteOrder;
|
||||
|
||||
import android.os.Process;
|
||||
import android.os.HandlerThread;
|
||||
|
||||
import java.nio.channels.SocketChannel;
|
||||
|
||||
public class ScreenEncoder implements Device.RotationListener {
|
||||
|
||||
|
@ -51,6 +48,7 @@ public class ScreenEncoder implements Device.RotationListener {
|
|||
private static final int NO_PTS = -1;
|
||||
|
||||
private final AtomicBoolean rotationChanged = new AtomicBoolean();
|
||||
private final AtomicInteger mRotation = new AtomicInteger(0);
|
||||
private final ByteBuffer headerBuffer = ByteBuffer.allocate(12);
|
||||
|
||||
private int bitRate;
|
||||
|
@ -61,9 +59,18 @@ public class ScreenEncoder implements Device.RotationListener {
|
|||
|
||||
private int quality;
|
||||
private int scale;
|
||||
private boolean controlOnly;
|
||||
private Handler mHandler;
|
||||
private ImageReader mImageReader;
|
||||
private ImageListener mImageListener;
|
||||
private HandlerThread mHandlerThread;
|
||||
private ImageReader.OnImageAvailableListener imageAvailableListenerImpl;
|
||||
|
||||
private Device device;
|
||||
private final Object rotationLock = new Object();
|
||||
private final Object imageReaderLock = new Object();
|
||||
private boolean bImageReaderDisable = true;//Segmentation fault
|
||||
|
||||
private boolean alive = true;
|
||||
|
||||
public ScreenEncoder(boolean sendFrameMeta, int bitRate, int maxFps, int iFrameInterval) {
|
||||
this.sendFrameMeta = sendFrameMeta;
|
||||
|
@ -76,219 +83,285 @@ public class ScreenEncoder implements Device.RotationListener {
|
|||
this(sendFrameMeta, bitRate, maxFps, DEFAULT_I_FRAME_INTERVAL);
|
||||
}
|
||||
|
||||
public ScreenEncoder(int quality, int maxFps, int scale) {
|
||||
this.quality = quality;
|
||||
this.maxFps = maxFps;
|
||||
this.scale = scale;
|
||||
public ScreenEncoder(Options options, Device device/*int rotation*/) {
|
||||
this.quality = options.getQuality();
|
||||
this.maxFps = options.getMaxFps();
|
||||
this.scale = options.getScale();
|
||||
this.controlOnly = options.getControlOnly();
|
||||
this.bitRate = options.getBitRate();
|
||||
this.device = device;
|
||||
mRotation.set(device.getRotation());
|
||||
|
||||
mHandlerThread = new HandlerThread("ScrcpyImageReaderHandlerThread");
|
||||
mHandlerThread.start();
|
||||
mHandler = new Handler(mHandlerThread.getLooper()) {
|
||||
@Override
|
||||
public void handleMessage(Message msg) {
|
||||
Ln.i("hander message: " + msg);
|
||||
if (msg.what == 1) {//exit
|
||||
setAlive(false);
|
||||
synchronized (rotationLock) {
|
||||
rotationLock.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRotationChanged(int rotation) {
|
||||
Ln.i("rotation: " + rotation);
|
||||
mRotation.set(rotation);
|
||||
rotationChanged.set(true);
|
||||
synchronized (rotationLock) {
|
||||
rotationLock.notify();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean consumeRotationChange() {
|
||||
return rotationChanged.getAndSet(false);
|
||||
}
|
||||
|
||||
private final class ImageListener implements ImageReader.OnImageAvailableListener {
|
||||
private class ImageAvailableListenerImpl implements ImageReader.OnImageAvailableListener {
|
||||
Handler handler;
|
||||
SocketChannel fd;
|
||||
Device device;
|
||||
int type = 0;// 0:libjpeg-turbo 1:bitmap
|
||||
int quality;
|
||||
int framePeriodMs;
|
||||
|
||||
int count = 0;
|
||||
long lastTime = System.currentTimeMillis();
|
||||
long timeA = lastTime;
|
||||
|
||||
public ImageAvailableListenerImpl(Handler handler, Device device, SocketChannel fd, int frameRate, int quality) {
|
||||
this.handler = handler;
|
||||
this.fd = fd;
|
||||
this.device = device;
|
||||
this.quality = quality;
|
||||
this.framePeriodMs = (int) (1000 / frameRate);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onImageAvailable(ImageReader reader) {
|
||||
Ln.i("onImageAvailable !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
|
||||
Ln.i("onImageAvailable !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
|
||||
Ln.i("onImageAvailable !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
|
||||
Ln.i("onImageAvailable !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
|
||||
Ln.i("onImageAvailable !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
|
||||
public void onImageAvailable(ImageReader imageReader) {
|
||||
byte[] jpegData = null;
|
||||
byte[] jpegSize = null;
|
||||
Image image = null;
|
||||
|
||||
synchronized (imageReaderLock) {
|
||||
try {
|
||||
if (bImageReaderDisable) {
|
||||
Ln.i("bImageReaderDisable !!!!!!!!!");
|
||||
return;
|
||||
}
|
||||
image = imageReader.acquireLatestImage();
|
||||
if (image == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
long currentTime = System.currentTimeMillis();
|
||||
if (framePeriodMs > currentTime - lastTime) {
|
||||
return;
|
||||
}
|
||||
lastTime = currentTime;
|
||||
|
||||
int width = image.getWidth();
|
||||
int height = image.getHeight();
|
||||
int format = image.getFormat();//RGBA_8888 0x00000001
|
||||
final Image.Plane[] planes = image.getPlanes();
|
||||
final ByteBuffer buffer = planes[0].getBuffer();
|
||||
int pixelStride = planes[0].getPixelStride();
|
||||
int rowStride = planes[0].getRowStride();
|
||||
int rowPadding = rowStride - pixelStride * width;
|
||||
int pitch = width + rowPadding / pixelStride;
|
||||
// Ln.i("pitch: " + pitch + ", pixelStride: " + pixelStride + ", rowStride: " + rowStride + ", rowPadding: " + rowPadding);
|
||||
if (type == 0) {
|
||||
jpegData = JpegEncoder.compress(buffer, width, pitch, height, quality);
|
||||
} else if (type == 1) {
|
||||
ByteArrayOutputStream stream = new ByteArrayOutputStream();
|
||||
Bitmap bitmap = Bitmap.createBitmap(pitch, height, Bitmap.Config.ARGB_8888);
|
||||
bitmap.copyPixelsFromBuffer(buffer);
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, stream);
|
||||
jpegData = stream.toByteArray();
|
||||
bitmap.recycle();
|
||||
}
|
||||
if (jpegData == null) {
|
||||
Ln.e("jpegData is null");
|
||||
return;
|
||||
}
|
||||
ByteBuffer b = ByteBuffer.allocate(4 + jpegData.length);
|
||||
b.order(ByteOrder.LITTLE_ENDIAN);
|
||||
b.putInt(jpegData.length);
|
||||
b.put(jpegData);
|
||||
jpegSize = b.array();
|
||||
try {
|
||||
IO.writeFully(fd, jpegSize, 0, jpegSize.length);// IOException
|
||||
} catch (IOException e) {
|
||||
Common.stopScrcpy(handler, "image");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Ln.e("onImageAvailable: " + e.getMessage());
|
||||
} finally {
|
||||
if (image != null) {
|
||||
image.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
count++;
|
||||
long timeB = System.currentTimeMillis();
|
||||
if (timeB - timeA >= 1000) {
|
||||
timeA = timeB;
|
||||
Ln.i("frame rate: " + count + ", jpeg size: " + jpegSize.length);
|
||||
count = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void streamScreen(Device device, FileDescriptor fd) throws IOException {
|
||||
public Handler getHandler() {
|
||||
return mHandler;
|
||||
}
|
||||
|
||||
public void streamScreen(Device device, SocketChannel fd) throws IOException {
|
||||
Workarounds.prepareMainLooper();
|
||||
Workarounds.fillAppInfo();
|
||||
|
||||
device.setRotationListener(this);
|
||||
boolean alive;
|
||||
try {
|
||||
banner(device, fd);
|
||||
writeMinicapBanner(device, fd, scale);
|
||||
do {
|
||||
// mHandler = new Handler(Looper.getMainLooper());
|
||||
IBinder display = createDisplay();
|
||||
Rect contentRect = device.getScreenInfo().getContentRect();
|
||||
// Rect videoRect = device.getScreenInfo().getVideoSize().toRect();
|
||||
Rect videoRect = new Rect(0, 0, contentRect.width() / scale, contentRect.height() / scale);
|
||||
mImageReader = ImageReader.newInstance(videoRect.width(), videoRect.height(), PixelFormat.RGBA_8888, 2);
|
||||
mImageListener = new ImageListener();
|
||||
mImageReader.setOnImageAvailableListener(mImageListener, mHandler);
|
||||
Surface surface = mImageReader.getSurface();
|
||||
setDisplaySurface(display, surface, contentRect, videoRect);
|
||||
try {
|
||||
alive = encode(mImageReader, fd);
|
||||
} finally {
|
||||
writeRotation(fd);
|
||||
if (controlOnly) {
|
||||
synchronized (rotationLock) {
|
||||
try {
|
||||
rotationLock.wait();
|
||||
} catch (InterruptedException e) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
IBinder display = createDisplay();
|
||||
Rect contentRect = device.getScreenInfo().getContentRect();
|
||||
// Rect videoRect = device.getScreenInfo().getVideoSize().toRect();
|
||||
Rect videoRect = getDesiredSize(contentRect, scale);
|
||||
synchronized (imageReaderLock) {
|
||||
mImageReader = ImageReader.newInstance(videoRect.width(), videoRect.height(), PixelFormat.RGBA_8888, 2);
|
||||
bImageReaderDisable = false;
|
||||
}
|
||||
if (imageAvailableListenerImpl == null) {
|
||||
imageAvailableListenerImpl = new ImageAvailableListenerImpl(mHandler, device, fd, maxFps, quality);
|
||||
}
|
||||
mImageReader.setOnImageAvailableListener(imageAvailableListenerImpl, mHandler);
|
||||
Surface surface = mImageReader.getSurface();
|
||||
setDisplaySurface(display, surface, contentRect, videoRect);
|
||||
synchronized (rotationLock) {
|
||||
try {
|
||||
rotationLock.wait();
|
||||
} catch (InterruptedException e) {
|
||||
}
|
||||
}
|
||||
synchronized (imageReaderLock) {
|
||||
if (mImageReader != null) {
|
||||
bImageReaderDisable = true;
|
||||
mImageReader.close();
|
||||
}
|
||||
}
|
||||
destroyDisplay(display);
|
||||
surface.release();
|
||||
}
|
||||
alive = getAlive();
|
||||
Ln.i("alive: " + alive);
|
||||
} while (alive);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
Ln.e("streamScreen: " + e.getMessage());
|
||||
} finally {
|
||||
if (mHandlerThread != null) {
|
||||
mHandlerThread.quit();
|
||||
}
|
||||
device.setRotationListener(null);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean encode(ImageReader imageReader, FileDescriptor fd) throws IOException {
|
||||
int count = 0;
|
||||
long current = System.currentTimeMillis();
|
||||
int type = 0;// 0:libjpeg-turbo 1:bitmap
|
||||
int frameRate = this.maxFps;
|
||||
int quality = this.quality;
|
||||
int framePeriodMs = (int) (1000 / frameRate);
|
||||
while (!consumeRotationChange()) {
|
||||
long timeA = System.currentTimeMillis();
|
||||
Image image = null;
|
||||
int loop = 0;
|
||||
int wait = 1;
|
||||
// TODO onImageAvailable这个方法不回调,未找到原因,暂时写成while
|
||||
while ((image = imageReader.acquireNextImage()) == null && ++loop < 10) {
|
||||
try {
|
||||
Thread.sleep(wait++);
|
||||
} catch (InterruptedException e) {
|
||||
}
|
||||
}
|
||||
if (image == null) {
|
||||
continue;
|
||||
}
|
||||
int width = image.getWidth();
|
||||
int height = image.getHeight();
|
||||
int format = image.getFormat();//RGBA_8888 0x00000001
|
||||
final Image.Plane[] planes = image.getPlanes();
|
||||
final ByteBuffer buffer = planes[0].getBuffer();
|
||||
int pixelStride = planes[0].getPixelStride();
|
||||
int rowStride = planes[0].getRowStride();
|
||||
int rowPadding = rowStride - pixelStride * width;
|
||||
int pitch = width + rowPadding / pixelStride;
|
||||
byte[] jpegData = null;
|
||||
byte[] jpegSize = null;
|
||||
if (type == 0) {
|
||||
jpegData = JpegEncoder.compress(buffer, width, pitch, height, quality);
|
||||
} else if (type == 1) {
|
||||
ByteArrayOutputStream stream = new ByteArrayOutputStream();
|
||||
Bitmap bitmap = Bitmap.createBitmap(pitch, height, Bitmap.Config.ARGB_8888);
|
||||
bitmap.copyPixelsFromBuffer(buffer);
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, stream);
|
||||
jpegData = stream.toByteArray();
|
||||
bitmap.recycle();
|
||||
}
|
||||
image.close();
|
||||
if (jpegData == null) {
|
||||
Ln.e("jpegData is null");
|
||||
continue;
|
||||
}
|
||||
ByteBuffer b = ByteBuffer.allocate(4);
|
||||
b.order(ByteOrder.LITTLE_ENDIAN);
|
||||
b.putInt(jpegData.length);
|
||||
jpegSize = b.array();
|
||||
IO.writeFully(fd, jpegSize, 0, jpegSize.length);
|
||||
IO.writeFully(fd, jpegData, 0, jpegData.length);
|
||||
|
||||
count++;
|
||||
long timeB = System.currentTimeMillis();
|
||||
if (timeB - current >= 1000) {
|
||||
current = timeB;
|
||||
Ln.i("frame rate: " + count + ", jpeg size: " + jpegData.length);
|
||||
count = 0;
|
||||
}
|
||||
|
||||
if (framePeriodMs > 0) {
|
||||
long ms = framePeriodMs - timeB + timeA;
|
||||
if (ms > 0) {
|
||||
try {
|
||||
Thread.sleep(ms);
|
||||
} catch (InterruptedException e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
private Rect getDesiredSize(Rect contentRect, int resolution) {
|
||||
int realWidth = contentRect.width();
|
||||
int realHeight = contentRect.height();
|
||||
int desiredWidth = realWidth;
|
||||
int desiredHeight = realHeight;
|
||||
int h = realHeight;
|
||||
if (realWidth < realHeight) {
|
||||
h = realWidth;
|
||||
}
|
||||
return true;
|
||||
if (h > resolution) {
|
||||
desiredWidth = contentRect.width() * resolution / h;
|
||||
desiredHeight = contentRect.height() * resolution / h;
|
||||
desiredWidth = (desiredWidth + 4) & ~7;
|
||||
desiredHeight = (desiredHeight + 4) & ~7;
|
||||
} else {
|
||||
desiredWidth &= ~7;
|
||||
desiredHeight &= ~7;
|
||||
}
|
||||
Ln.i("realWidth: " + realWidth + ", realHeight: " + realHeight + ", desiredWidth: " + desiredWidth + ", desiredHeight: " + desiredHeight);
|
||||
return new Rect(0, 0, desiredWidth, desiredHeight);
|
||||
}
|
||||
|
||||
// minicap banner
|
||||
private void banner(Device device, FileDescriptor fd) throws IOException {
|
||||
private void writeRotation(SocketChannel fd) {
|
||||
ByteBuffer r = ByteBuffer.allocate(8);
|
||||
r.order(ByteOrder.LITTLE_ENDIAN);
|
||||
r.putInt(4);
|
||||
r.putInt(mRotation.get());
|
||||
byte[] rArray = r.array();
|
||||
try {
|
||||
IO.writeFully(fd, rArray, 0, rArray.length);// IOException
|
||||
} catch (IOException e) {
|
||||
Common.stopScrcpy(getHandler(), "rotation");
|
||||
}
|
||||
}
|
||||
|
||||
private void writeMinicapBanner(Device device, SocketChannel fd, int scale) throws IOException {
|
||||
final byte BANNER_SIZE = 24;
|
||||
Rect videoRect = device.getScreenInfo().getVideoSize().toRect();
|
||||
int width = videoRect.width();
|
||||
int height = videoRect.height();
|
||||
final byte version = 1;
|
||||
final byte quirks = 2;
|
||||
int pid = Process.myPid();
|
||||
Rect contentRect = device.getScreenInfo().getContentRect();
|
||||
Rect videoRect = getDesiredSize(contentRect, scale);
|
||||
int realWidth = contentRect.width();
|
||||
int realHeight = contentRect.height();
|
||||
int desiredWidth = videoRect.width();
|
||||
int desiredHeight = videoRect.height();
|
||||
byte orientation = (byte) device.getRotation();
|
||||
|
||||
ByteBuffer b = ByteBuffer.allocate(BANNER_SIZE);
|
||||
b.order(ByteOrder.LITTLE_ENDIAN);
|
||||
b.put((byte) 1);//version
|
||||
b.put((byte) version);//version
|
||||
b.put(BANNER_SIZE);//banner size
|
||||
b.putInt(pid);//pid
|
||||
b.putInt(width);//real width
|
||||
b.putInt(height);//real height
|
||||
b.putInt(width);//desired width
|
||||
b.putInt(height);//desired height
|
||||
b.put((byte) 0);//orientation
|
||||
b.put((byte) 2);//quirks
|
||||
b.putInt(realWidth);//real width
|
||||
b.putInt(realHeight);//real height
|
||||
b.putInt(desiredWidth);//desired width
|
||||
b.putInt(desiredHeight);//desired height
|
||||
b.put((byte) orientation);//orientation
|
||||
b.put((byte) quirks);//quirks
|
||||
byte[] array = b.array();
|
||||
IO.writeFully(fd, array, 0, array.length);
|
||||
}
|
||||
|
||||
private void writeFrameMeta(FileDescriptor fd, MediaCodec.BufferInfo bufferInfo, int packetSize) throws IOException {
|
||||
headerBuffer.clear();
|
||||
|
||||
long pts;
|
||||
if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) {
|
||||
pts = NO_PTS; // non-media data packet
|
||||
} else {
|
||||
if (ptsOrigin == 0) {
|
||||
ptsOrigin = bufferInfo.presentationTimeUs;
|
||||
}
|
||||
pts = bufferInfo.presentationTimeUs - ptsOrigin;
|
||||
}
|
||||
|
||||
headerBuffer.putLong(pts);
|
||||
headerBuffer.putInt(packetSize);
|
||||
headerBuffer.flip();
|
||||
IO.writeFully(fd, headerBuffer);
|
||||
}
|
||||
|
||||
private static MediaCodec createCodec() throws IOException {
|
||||
return MediaCodec.createEncoderByType("video/avc");
|
||||
}
|
||||
|
||||
@SuppressWarnings("checkstyle:MagicNumber")
|
||||
private static MediaFormat createFormat(int bitRate, int maxFps, int iFrameInterval) {
|
||||
MediaFormat format = new MediaFormat();
|
||||
format.setString(MediaFormat.KEY_MIME, "video/avc");
|
||||
format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate);
|
||||
// must be present to configure the encoder, but does not impact the actual frame rate, which is variable
|
||||
format.setInteger(MediaFormat.KEY_FRAME_RATE, 60);
|
||||
format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
|
||||
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iFrameInterval);
|
||||
// display the very first frame, and recover from bad quality when no new frames
|
||||
format.setLong(MediaFormat.KEY_REPEAT_PREVIOUS_FRAME_AFTER, REPEAT_FRAME_DELAY_US); // µs
|
||||
if (maxFps > 0) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
format.setFloat(MediaFormat.KEY_MAX_FPS_TO_ENCODER, maxFps);
|
||||
} else {
|
||||
Ln.w("Max FPS is only supported since Android 10, the option has been ignored");
|
||||
}
|
||||
}
|
||||
return format;
|
||||
IO.writeFully(fd, array, 0, array.length);// IOException
|
||||
Ln.i("banner\n"
|
||||
+ "{\n"
|
||||
+ " version: " + version + "\n"
|
||||
+ " size: " + BANNER_SIZE + "\n"
|
||||
+ " real width: " + realWidth + "\n"
|
||||
+ " real height: " + realHeight + "\n"
|
||||
+ " desired width: " + desiredWidth + "\n"
|
||||
+ " desired height: " + desiredHeight + "\n"
|
||||
+ " orientation: " + orientation + "\n"
|
||||
+ " quirks: " + quirks + "\n"
|
||||
+ "}\n"
|
||||
);
|
||||
}
|
||||
|
||||
private static IBinder createDisplay() {
|
||||
return SurfaceControl.createDisplay("scrcpy", true);
|
||||
}
|
||||
|
||||
private static void configure(MediaCodec codec, MediaFormat format) {
|
||||
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
|
||||
}
|
||||
|
||||
private static void setSize(MediaFormat format, int width, int height) {
|
||||
format.setInteger(MediaFormat.KEY_WIDTH, width);
|
||||
format.setInteger(MediaFormat.KEY_HEIGHT, height);
|
||||
}
|
||||
|
||||
private static void setDisplaySurface(IBinder display, Surface surface, Rect deviceRect, Rect displayRect) {
|
||||
SurfaceControl.openTransaction();
|
||||
try {
|
||||
|
@ -303,4 +376,12 @@ public class ScreenEncoder implements Device.RotationListener {
|
|||
private static void destroyDisplay(IBinder display) {
|
||||
SurfaceControl.destroyDisplay(display);
|
||||
}
|
||||
|
||||
private synchronized boolean getAlive() {
|
||||
return alive;
|
||||
}
|
||||
|
||||
private synchronized void setAlive(boolean b) {
|
||||
alive = b;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,21 +6,30 @@ import android.os.Build;
|
|||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.lang.System;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.FileDescriptor;
|
||||
|
||||
import android.os.Handler;
|
||||
|
||||
public final class Server {
|
||||
|
||||
private static final String SERVER_PATH = "/data/local/tmp/scrcpy-server.jar";
|
||||
private static Handler handler;
|
||||
|
||||
private Server() {
|
||||
// not instantiable
|
||||
}
|
||||
|
||||
private static void scrcpy(Options options) throws IOException {
|
||||
AccessibilityNodeInfoDumper dumper = null;
|
||||
final Device device = new Device(options);
|
||||
boolean tunnelForward = options.isTunnelForward();
|
||||
try (DesktopConnection connection = DesktopConnection.open(device, tunnelForward)) {
|
||||
ScreenEncoder screenEncoder = new ScreenEncoder(options.getQuality(), options.getMaxFps(), options.getScale());
|
||||
|
||||
try (DesktopConnection connection = DesktopConnection.open(device, tunnelForward)) {
|
||||
ScreenEncoder screenEncoder = new ScreenEncoder(options, device);
|
||||
handler = screenEncoder.getHandler();
|
||||
if (options.getControl()) {
|
||||
Controller controller = new Controller(device, connection);
|
||||
|
||||
|
@ -29,13 +38,26 @@ public final class Server {
|
|||
startDeviceMessageSender(controller.getSender());
|
||||
}
|
||||
|
||||
if (options.getDumpHierarchy()) {
|
||||
dumper = new AccessibilityNodeInfoDumper(handler, device, connection);
|
||||
dumper.start();
|
||||
}
|
||||
|
||||
try {
|
||||
// synchronous
|
||||
screenEncoder.streamScreen(device, connection.getVideoFd());
|
||||
screenEncoder.streamScreen(device, connection.getVideoChannel());
|
||||
} catch (IOException e) {
|
||||
Ln.i("exit: " + e.getMessage());
|
||||
//do exit(0)
|
||||
} finally {
|
||||
if (options.getDumpHierarchy() && dumper != null) {
|
||||
dumper.stop();
|
||||
}
|
||||
// this is expected on close
|
||||
Ln.d("Screen streaming stopped");
|
||||
System.exit(0);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -48,7 +70,7 @@ public final class Server {
|
|||
} catch (IOException e) {
|
||||
// this is expected on close
|
||||
Ln.d("Controller stopped");
|
||||
Ln.d("E:" + e.getMessage());
|
||||
Common.stopScrcpy(handler, "control");
|
||||
}
|
||||
}
|
||||
}).start();
|
||||
|
@ -118,7 +140,10 @@ public final class Server {
|
|||
org.apache.commons.cli.Options options = new org.apache.commons.cli.Options();
|
||||
options.addOption("Q", true, "JPEG quality (0-100)");
|
||||
options.addOption("r", true, "Frame rate (frames/s)");
|
||||
options.addOption("P", true, "Display projection (scale 1,2,4...)");
|
||||
options.addOption("P", true, "Display projection (1080, 720, 480...).");
|
||||
options.addOption("c", false, "Control only");
|
||||
options.addOption("L", false, "Library path");
|
||||
options.addOption("D", false, "Dump window hierarchy");
|
||||
options.addOption("h", false, "Show help");
|
||||
try {
|
||||
commandLine = parser.parse(options, args);
|
||||
|
@ -129,25 +154,39 @@ public final class Server {
|
|||
|
||||
if (commandLine.hasOption('h')) {
|
||||
System.out.println(
|
||||
"Usage: %s [-h]\n"
|
||||
"Usage: [-h]\n\n"
|
||||
+ "jpeg:\n"
|
||||
+ " -r <value>: Frame rate (frames/sec).\n"
|
||||
+ " -P <value>: Display projection (1080, 720, 480, 360...).\n"
|
||||
+ " -Q <value>: JPEG quality (0-100).\n"
|
||||
+ " -r <value>: Frame rate (frames/s).\n"
|
||||
+ " -P <value>: Display projection (scale 1,2,4...).\n"
|
||||
+ "\n"
|
||||
+ " -c: Control only.\n"
|
||||
+ " -L: Library path.\n"
|
||||
+ " -D: Dump window hierarchy.\n"
|
||||
+ " -h: Show help.\n"
|
||||
);
|
||||
System.exit(0);
|
||||
}
|
||||
|
||||
if (commandLine.hasOption('L')) {
|
||||
System.out.println(System.getProperty("java.library.path"));
|
||||
System.exit(0);
|
||||
}
|
||||
Options o = new Options();
|
||||
o.setMaxSize(0);
|
||||
o.setBitRate(1000000);
|
||||
o.setTunnelForward(true);
|
||||
o.setCrop(null);
|
||||
o.setSendFrameMeta(true);
|
||||
o.setControl(true);
|
||||
// global
|
||||
o.setMaxFps(24);
|
||||
o.setQuality(50);
|
||||
o.setScale(2);
|
||||
o.setScale(480);
|
||||
// jpeg
|
||||
o.setQuality(60);
|
||||
o.setBitRate(1000000);
|
||||
o.setSendFrameMeta(true);
|
||||
// control
|
||||
o.setControlOnly(false);
|
||||
// dump
|
||||
o.setDumpHierarchy(false);
|
||||
if (commandLine.hasOption('Q')) {
|
||||
int i = 0;
|
||||
try {
|
||||
|
@ -178,6 +217,12 @@ public final class Server {
|
|||
o.setScale(i);
|
||||
}
|
||||
}
|
||||
if (commandLine.hasOption('c')) {
|
||||
o.setControlOnly(true);
|
||||
}
|
||||
if (commandLine.hasOption('D')) {
|
||||
o.setDumpHierarchy(true);
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
|
@ -209,14 +254,14 @@ public final class Server {
|
|||
@SuppressWarnings("checkstyle:MagicNumber")
|
||||
private static void suggestFix(Throwable e) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
if (e instanceof MediaCodec.CodecException) {
|
||||
MediaCodec.CodecException mce = (MediaCodec.CodecException) e;
|
||||
if (mce.getErrorCode() == 0xfffffc0e) {
|
||||
Ln.e("The hardware encoder is not able to encode at the given definition.");
|
||||
Ln.e("Try with a lower definition:");
|
||||
Ln.e(" scrcpy -m 1024");
|
||||
}
|
||||
}
|
||||
// if (e instanceof MediaCodec.CodecException) {//api level 21
|
||||
// MediaCodec.CodecException mce = (MediaCodec.CodecException) e;
|
||||
// if (mce.getErrorCode() == 0xfffffc0e) {
|
||||
// Ln.e("The hardware encoder is not able to encode at the given definition.");
|
||||
// Ln.e("Try with a lower definition:");
|
||||
// Ln.e(" scrcpy -m 1024");
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -231,10 +276,11 @@ public final class Server {
|
|||
|
||||
// unlinkSelf();
|
||||
// Options options = createOptions(args);
|
||||
Options options = customOptions(args);
|
||||
Ln.i("Options frame rate: " + options.getMaxFps() + " (1 ~ 100)");
|
||||
final Options options = customOptions(args);
|
||||
Ln.i("Options frame rate: " + options.getMaxFps() + " (1 ~ 60)");
|
||||
Ln.i("Options quality: " + options.getQuality() + " (1 ~ 100)");
|
||||
Ln.i("Options scale: " + options.getScale() + " (1,2,4...)");
|
||||
Ln.i("Options projection: " + options.getScale() + " (1080, 720, 480, 360...)");
|
||||
Ln.i("Options control only: " + options.getControlOnly() + " (true / false)");
|
||||
scrcpy(options);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -38,7 +38,8 @@ public final class Size {
|
|||
return false;
|
||||
}
|
||||
Size size = (Size) o;
|
||||
return width == size.width && height == size.height;
|
||||
// return width == size.width && height == size.height;
|
||||
return width == size.width || height == size.height;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
Loading…
Add table
Reference in a new issue