mirror of
https://github.com/Genymobile/scrcpy.git
synced 2025-04-21 12:05:00 +00:00
WebSocket streaming (patchset squashed)
This commit is contained in:
parent
228e2c15f4
commit
e6c92d55c1
23 changed files with 1738 additions and 326 deletions
|
@ -139,7 +139,9 @@ page at http://checkstyle.sourceforge.net/config.html -->
|
|||
<module name="FinalClass" />
|
||||
<module name="HideUtilityClassConstructor" />
|
||||
<module name="InterfaceIsType" />
|
||||
<module name="VisibilityModifier" />
|
||||
<module name="VisibilityModifier">
|
||||
<property name="protectedAllowed" value="true"/>
|
||||
</module>
|
||||
|
||||
|
||||
<!-- Miscellaneous other checks. -->
|
||||
|
|
|
@ -7,7 +7,7 @@ android {
|
|||
minSdkVersion 21
|
||||
targetSdkVersion 30
|
||||
versionCode 11900
|
||||
versionName "1.19"
|
||||
versionName "1.19-ws1"
|
||||
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
buildTypes {
|
||||
|
@ -20,6 +20,9 @@ android {
|
|||
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation 'org.java-websocket:Java-WebSocket:1.4.0'
|
||||
implementation 'org.slf4j:slf4j-api:1.7.25'
|
||||
implementation 'uk.uuid.slf4j:slf4j-android:1.7.25-1'
|
||||
testImplementation 'junit:junit:4.13'
|
||||
}
|
||||
|
||||
|
|
118
server/src/main/java/com/genymobile/scrcpy/Connection.java
Normal file
118
server/src/main/java/com/genymobile/scrcpy/Connection.java
Normal file
|
@ -0,0 +1,118 @@
|
|||
package com.genymobile.scrcpy;
|
||||
|
||||
import com.genymobile.scrcpy.wrappers.ContentProvider;
|
||||
|
||||
import android.os.BatteryManager;
|
||||
import android.os.Build;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
public abstract class Connection implements Device.RotationListener, Device.ClipboardListener {
|
||||
public interface StreamInvalidateListener {
|
||||
void onStreamInvalidate();
|
||||
}
|
||||
|
||||
protected final ControlMessageReader reader = new ControlMessageReader();
|
||||
protected static final int DEVICE_NAME_FIELD_LENGTH = 64;
|
||||
protected StreamInvalidateListener streamInvalidateListener;
|
||||
protected Device device;
|
||||
protected final VideoSettings videoSettings;
|
||||
protected final Options options;
|
||||
protected Controller controller;
|
||||
protected ScreenEncoder screenEncoder;
|
||||
|
||||
abstract void send(ByteBuffer data);
|
||||
|
||||
abstract void sendDeviceMessage(DeviceMessage msg) throws IOException;
|
||||
|
||||
abstract void close() throws Exception;
|
||||
|
||||
abstract boolean hasConnections();
|
||||
|
||||
public Connection(Options options, VideoSettings videoSettings) {
|
||||
Ln.i("Device: " + Build.MANUFACTURER + " " + Build.MODEL + " (Android " + Build.VERSION.RELEASE + ")");
|
||||
this.videoSettings = videoSettings;
|
||||
this.options = options;
|
||||
device = new Device(options, videoSettings);
|
||||
device.setRotationListener(this);
|
||||
controller = new Controller(device, this);
|
||||
startDeviceMessageSender(controller.getSender());
|
||||
device.setClipboardListener(this);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
CleanUp.configure(options.getDisplayId(), restoreStayOn, mustDisableShowTouchesOnCleanUp, true, options.getPowerOffScreenOnClose());
|
||||
} catch (IOException e) {
|
||||
Ln.w("CleanUp.configure() failed:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public boolean setVideoSettings(VideoSettings newSettings) {
|
||||
if (!videoSettings.equals(newSettings)) {
|
||||
videoSettings.merge(newSettings);
|
||||
device.applyNewVideoSetting(videoSettings);
|
||||
if (this.streamInvalidateListener != null) {
|
||||
streamInvalidateListener.onStreamInvalidate();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public void setStreamInvalidateListener(StreamInvalidateListener listener) {
|
||||
this.streamInvalidateListener = listener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRotationChanged(int rotation) {
|
||||
if (this.streamInvalidateListener != null) {
|
||||
streamInvalidateListener.onStreamInvalidate();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClipboardTextChanged(String text) {
|
||||
controller.getSender().pushClipboardText(text);
|
||||
}
|
||||
|
||||
|
||||
private static void startDeviceMessageSender(final DeviceMessageSender sender) {
|
||||
new Thread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
try {
|
||||
sender.loop();
|
||||
} catch (IOException | InterruptedException e) {
|
||||
// this is expected on close
|
||||
Ln.d("Device message sender stopped");
|
||||
}
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
}
|
|
@ -1,5 +1,8 @@
|
|||
package com.genymobile.scrcpy;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* Union of all supported event types, identified by their {@code type}.
|
||||
*/
|
||||
|
@ -17,6 +20,14 @@ public final class ControlMessage {
|
|||
public static final int TYPE_SET_CLIPBOARD = 9;
|
||||
public static final int TYPE_SET_SCREEN_POWER_MODE = 10;
|
||||
public static final int TYPE_ROTATE_DEVICE = 11;
|
||||
public static final int TYPE_CHANGE_STREAM_PARAMETERS = 101;
|
||||
public static final int TYPE_PUSH_FILE = 102;
|
||||
|
||||
public static final int PUSH_STATE_NEW = 0;
|
||||
public static final int PUSH_STATE_START = 1;
|
||||
public static final int PUSH_STATE_APPEND = 2;
|
||||
public static final int PUSH_STATE_FINISH = 3;
|
||||
public static final int PUSH_STATE_CANCEL = 4;
|
||||
|
||||
private int type;
|
||||
private String text;
|
||||
|
@ -31,6 +42,14 @@ public final class ControlMessage {
|
|||
private int vScroll;
|
||||
private boolean paste;
|
||||
private int repeat;
|
||||
private byte[] bytes;
|
||||
private short pushId;
|
||||
private int pushState;
|
||||
private byte[] pushChunk;
|
||||
private int pushChunkSize;
|
||||
private int fileSize;
|
||||
private String fileName;
|
||||
private VideoSettings videoSettings;
|
||||
|
||||
private ControlMessage() {
|
||||
}
|
||||
|
@ -97,6 +116,50 @@ public final class ControlMessage {
|
|||
return msg;
|
||||
}
|
||||
|
||||
public static ControlMessage createChangeSteamParameters(byte[] bytes) {
|
||||
ControlMessage event = new ControlMessage();
|
||||
event.type = TYPE_CHANGE_STREAM_PARAMETERS;
|
||||
event.videoSettings = VideoSettings.fromByteArray(bytes);
|
||||
return event;
|
||||
}
|
||||
|
||||
public static ControlMessage createFilePush(byte[] bytes) {
|
||||
ControlMessage event = new ControlMessage();
|
||||
event.type = TYPE_PUSH_FILE;
|
||||
ByteBuffer buffer = ByteBuffer.wrap(bytes);
|
||||
event.pushId = buffer.getShort();
|
||||
event.pushState = buffer.get();
|
||||
switch (event.pushState) {
|
||||
case PUSH_STATE_START:
|
||||
event.fileSize = buffer.getInt();
|
||||
short nameLength = buffer.getShort();
|
||||
byte[] textBuffer = new byte[nameLength];
|
||||
buffer.get(textBuffer, 0, nameLength);
|
||||
event.fileName = new String(textBuffer, 0, nameLength, StandardCharsets.UTF_8);
|
||||
break;
|
||||
case PUSH_STATE_APPEND:
|
||||
int chunkSize = buffer.getInt();
|
||||
byte[] chunk = new byte[chunkSize];
|
||||
if (buffer.remaining() >= chunkSize) {
|
||||
buffer.get(chunk, 0, chunkSize);
|
||||
event.pushChunkSize = chunkSize;
|
||||
event.pushChunk = chunk;
|
||||
} else {
|
||||
event.pushState = PUSH_STATE_CANCEL;
|
||||
}
|
||||
break;
|
||||
case PUSH_STATE_NEW:
|
||||
case PUSH_STATE_CANCEL:
|
||||
case PUSH_STATE_FINISH:
|
||||
break;
|
||||
// nothing special;
|
||||
default:
|
||||
Ln.w("Unknown push event state: " + event.pushState);
|
||||
return null;
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
public static ControlMessage createEmpty(int type) {
|
||||
ControlMessage msg = new ControlMessage();
|
||||
msg.type = type;
|
||||
|
@ -154,4 +217,36 @@ public final class ControlMessage {
|
|||
public int getRepeat() {
|
||||
return repeat;
|
||||
}
|
||||
|
||||
public byte[] getBytes() {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public short getPushId() {
|
||||
return pushId;
|
||||
}
|
||||
|
||||
public int getPushState() {
|
||||
return pushState;
|
||||
}
|
||||
|
||||
public byte[] getPushChunk() {
|
||||
return pushChunk;
|
||||
}
|
||||
|
||||
public int getPushChunkSize() {
|
||||
return pushChunkSize;
|
||||
}
|
||||
|
||||
public String getFileName() {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
public int getFileSize() {
|
||||
return fileSize;
|
||||
}
|
||||
|
||||
public VideoSettings getVideoSettings() {
|
||||
return videoSettings;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,6 +47,10 @@ public class ControlMessageReader {
|
|||
}
|
||||
|
||||
public ControlMessage next() {
|
||||
return parseEvent(buffer);
|
||||
}
|
||||
|
||||
public ControlMessage parseEvent(ByteBuffer buffer) {
|
||||
if (!buffer.hasRemaining()) {
|
||||
return null;
|
||||
}
|
||||
|
@ -56,25 +60,31 @@ public class ControlMessageReader {
|
|||
ControlMessage msg;
|
||||
switch (type) {
|
||||
case ControlMessage.TYPE_INJECT_KEYCODE:
|
||||
msg = parseInjectKeycode();
|
||||
msg = parseInjectKeycode(buffer);
|
||||
break;
|
||||
case ControlMessage.TYPE_INJECT_TEXT:
|
||||
msg = parseInjectText();
|
||||
msg = parseInjectText(buffer);
|
||||
break;
|
||||
case ControlMessage.TYPE_INJECT_TOUCH_EVENT:
|
||||
msg = parseInjectTouchEvent();
|
||||
msg = parseInjectTouchEvent(buffer);
|
||||
break;
|
||||
case ControlMessage.TYPE_INJECT_SCROLL_EVENT:
|
||||
msg = parseInjectScrollEvent();
|
||||
msg = parseInjectScrollEvent(buffer);
|
||||
break;
|
||||
case ControlMessage.TYPE_BACK_OR_SCREEN_ON:
|
||||
msg = parseBackOrScreenOnEvent();
|
||||
msg = parseBackOrScreenOnEvent(buffer);
|
||||
break;
|
||||
case ControlMessage.TYPE_SET_CLIPBOARD:
|
||||
msg = parseSetClipboard();
|
||||
msg = parseSetClipboard(buffer);
|
||||
break;
|
||||
case ControlMessage.TYPE_SET_SCREEN_POWER_MODE:
|
||||
msg = parseSetScreenPowerMode();
|
||||
msg = parseSetScreenPowerMode(buffer);
|
||||
break;
|
||||
case ControlMessage.TYPE_CHANGE_STREAM_PARAMETERS:
|
||||
msg = parseChangeStreamParameters(buffer);
|
||||
break;
|
||||
case ControlMessage.TYPE_PUSH_FILE:
|
||||
msg = parsePushFile(buffer);
|
||||
break;
|
||||
case ControlMessage.TYPE_EXPAND_NOTIFICATION_PANEL:
|
||||
case ControlMessage.TYPE_EXPAND_SETTINGS_PANEL:
|
||||
|
@ -96,7 +106,25 @@ public class ControlMessageReader {
|
|||
return msg;
|
||||
}
|
||||
|
||||
private ControlMessage parseInjectKeycode() {
|
||||
private ControlMessage parseChangeStreamParameters(ByteBuffer buffer) {
|
||||
int re = buffer.remaining();
|
||||
byte[] bytes = new byte[re];
|
||||
if (re > 0) {
|
||||
buffer.get(bytes, 0, re);
|
||||
}
|
||||
return ControlMessage.createChangeSteamParameters(bytes);
|
||||
}
|
||||
|
||||
private ControlMessage parsePushFile(ByteBuffer buffer) {
|
||||
int re = buffer.remaining();
|
||||
byte[] bytes = new byte[re];
|
||||
if (re > 0) {
|
||||
buffer.get(bytes, 0, re);
|
||||
}
|
||||
return ControlMessage.createFilePush(bytes);
|
||||
}
|
||||
|
||||
private ControlMessage parseInjectKeycode(ByteBuffer buffer) {
|
||||
if (buffer.remaining() < INJECT_KEYCODE_PAYLOAD_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
|
@ -107,7 +135,7 @@ public class ControlMessageReader {
|
|||
return ControlMessage.createInjectKeycode(action, keycode, repeat, metaState);
|
||||
}
|
||||
|
||||
private String parseString() {
|
||||
private String parseString(ByteBuffer buffer) {
|
||||
if (buffer.remaining() < 4) {
|
||||
return null;
|
||||
}
|
||||
|
@ -115,21 +143,19 @@ public class ControlMessageReader {
|
|||
if (buffer.remaining() < len) {
|
||||
return null;
|
||||
}
|
||||
int position = buffer.position();
|
||||
// Move the buffer position to consume the text
|
||||
buffer.position(position + len);
|
||||
return new String(rawBuffer, position, len, StandardCharsets.UTF_8);
|
||||
buffer.get(rawBuffer, 0, len);
|
||||
return new String(rawBuffer, 0, len, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private ControlMessage parseInjectText() {
|
||||
String text = parseString();
|
||||
private ControlMessage parseInjectText(ByteBuffer buffer) {
|
||||
String text = parseString(buffer);
|
||||
if (text == null) {
|
||||
return null;
|
||||
}
|
||||
return ControlMessage.createInjectText(text);
|
||||
}
|
||||
|
||||
private ControlMessage parseInjectTouchEvent() {
|
||||
private ControlMessage parseInjectTouchEvent(ByteBuffer buffer) {
|
||||
if (buffer.remaining() < INJECT_TOUCH_EVENT_PAYLOAD_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
|
@ -144,7 +170,7 @@ public class ControlMessageReader {
|
|||
return ControlMessage.createInjectTouchEvent(action, pointerId, position, pressure, buttons);
|
||||
}
|
||||
|
||||
private ControlMessage parseInjectScrollEvent() {
|
||||
private ControlMessage parseInjectScrollEvent(ByteBuffer buffer) {
|
||||
if (buffer.remaining() < INJECT_SCROLL_EVENT_PAYLOAD_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
|
@ -154,7 +180,7 @@ public class ControlMessageReader {
|
|||
return ControlMessage.createInjectScrollEvent(position, hScroll, vScroll);
|
||||
}
|
||||
|
||||
private ControlMessage parseBackOrScreenOnEvent() {
|
||||
private ControlMessage parseBackOrScreenOnEvent(ByteBuffer buffer) {
|
||||
if (buffer.remaining() < BACK_OR_SCREEN_ON_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
|
@ -162,19 +188,19 @@ public class ControlMessageReader {
|
|||
return ControlMessage.createBackOrScreenOn(action);
|
||||
}
|
||||
|
||||
private ControlMessage parseSetClipboard() {
|
||||
private ControlMessage parseSetClipboard(ByteBuffer buffer) {
|
||||
if (buffer.remaining() < SET_CLIPBOARD_FIXED_PAYLOAD_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
boolean paste = buffer.get() != 0;
|
||||
String text = parseString();
|
||||
String text = parseString(buffer);
|
||||
if (text == null) {
|
||||
return null;
|
||||
}
|
||||
return ControlMessage.createSetClipboard(text, paste);
|
||||
}
|
||||
|
||||
private ControlMessage parseSetScreenPowerMode() {
|
||||
private ControlMessage parseSetScreenPowerMode(ByteBuffer buffer) {
|
||||
if (buffer.remaining() < SET_SCREEN_POWER_MODE_PAYLOAD_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -19,7 +19,7 @@ public class Controller {
|
|||
private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor();
|
||||
|
||||
private final Device device;
|
||||
private final DesktopConnection connection;
|
||||
private final Connection connection;
|
||||
private final DeviceMessageSender sender;
|
||||
|
||||
private final KeyCharacterMap charMap = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD);
|
||||
|
@ -31,7 +31,7 @@ public class Controller {
|
|||
|
||||
private boolean keepPowerModeOff;
|
||||
|
||||
public Controller(Device device, DesktopConnection connection) {
|
||||
public Controller(Device device, Connection connection) {
|
||||
this.device = device;
|
||||
this.connection = connection;
|
||||
initPointers();
|
||||
|
@ -52,32 +52,11 @@ public class Controller {
|
|||
}
|
||||
}
|
||||
|
||||
public void control() throws IOException {
|
||||
// on start, power on the device
|
||||
if (!Device.isScreenOn()) {
|
||||
device.pressReleaseKeycode(KeyEvent.KEYCODE_POWER);
|
||||
|
||||
// dirty hack
|
||||
// After POWER is injected, the device is powered on asynchronously.
|
||||
// To turn the device screen off while mirroring, the client will send a message that
|
||||
// would be handled before the device is actually powered on, so its effect would
|
||||
// be "canceled" once the device is turned back on.
|
||||
// Adding this delay prevents to handle the message before the device is actually
|
||||
// powered on.
|
||||
SystemClock.sleep(500);
|
||||
}
|
||||
|
||||
while (true) {
|
||||
handleEvent();
|
||||
}
|
||||
}
|
||||
|
||||
public DeviceMessageSender getSender() {
|
||||
return sender;
|
||||
}
|
||||
|
||||
private void handleEvent() throws IOException {
|
||||
ControlMessage msg = connection.receiveControlMessage();
|
||||
public void handleEvent(ControlMessage msg) {
|
||||
switch (msg.getType()) {
|
||||
case ControlMessage.TYPE_INJECT_KEYCODE:
|
||||
if (device.supportsInputEvents()) {
|
||||
|
@ -116,7 +95,12 @@ public class Controller {
|
|||
case ControlMessage.TYPE_GET_CLIPBOARD:
|
||||
String clipboardText = Device.getClipboardText();
|
||||
if (clipboardText != null) {
|
||||
sender.pushClipboardText(clipboardText);
|
||||
DeviceMessage event = DeviceMessage.createClipboard(clipboardText);
|
||||
try {
|
||||
connection.sendDeviceMessage(event);
|
||||
} catch (IOException e) {
|
||||
Ln.w("");
|
||||
}
|
||||
}
|
||||
break;
|
||||
case ControlMessage.TYPE_SET_CLIPBOARD:
|
||||
|
@ -289,4 +273,8 @@ public class Controller {
|
|||
|
||||
return ok;
|
||||
}
|
||||
|
||||
public void turnScreenOn() {
|
||||
device.pressReleaseKeycode(KeyEvent.KEYCODE_POWER);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,17 +3,16 @@ package com.genymobile.scrcpy;
|
|||
import android.net.LocalServerSocket;
|
||||
import android.net.LocalSocket;
|
||||
import android.net.LocalSocketAddress;
|
||||
import android.os.SystemClock;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.FileDescriptor;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public final class DesktopConnection implements Closeable {
|
||||
|
||||
private static final int DEVICE_NAME_FIELD_LENGTH = 64;
|
||||
public final class DesktopConnection extends Connection {
|
||||
|
||||
private static final String SOCKET_NAME = "scrcpy";
|
||||
|
||||
|
@ -24,26 +23,17 @@ public final class DesktopConnection implements Closeable {
|
|||
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 static LocalSocket connect(String abstractName) throws IOException {
|
||||
LocalSocket localSocket = new LocalSocket();
|
||||
localSocket.connect(new LocalSocketAddress(abstractName));
|
||||
return localSocket;
|
||||
}
|
||||
|
||||
public static DesktopConnection open(Device device, boolean tunnelForward) throws IOException {
|
||||
LocalSocket videoSocket;
|
||||
LocalSocket controlSocket;
|
||||
public DesktopConnection(Options options, VideoSettings videoSettings) throws IOException {
|
||||
super(options, videoSettings);
|
||||
boolean tunnelForward = options.isTunnelForward();
|
||||
if (tunnelForward) {
|
||||
LocalServerSocket localServerSocket = new LocalServerSocket(SOCKET_NAME);
|
||||
try {
|
||||
|
@ -69,10 +59,18 @@ public final class DesktopConnection implements Closeable {
|
|||
}
|
||||
}
|
||||
|
||||
DesktopConnection connection = new DesktopConnection(videoSocket, controlSocket);
|
||||
controlInputStream = controlSocket.getInputStream();
|
||||
controlOutputStream = controlSocket.getOutputStream();
|
||||
videoFd = videoSocket.getFileDescriptor();
|
||||
if (options.getControl()) {
|
||||
startEventController();
|
||||
}
|
||||
Size videoSize = device.getScreenInfo().getVideoSize();
|
||||
connection.send(Device.getDeviceName(), videoSize.getWidth(), videoSize.getHeight());
|
||||
return connection;
|
||||
send(Device.getDeviceName(), videoSize.getWidth(), videoSize.getHeight());
|
||||
screenEncoder = new ScreenEncoder(videoSettings);
|
||||
screenEncoder.setDevice(device);
|
||||
screenEncoder.setConnection(this);
|
||||
screenEncoder.run();
|
||||
}
|
||||
|
||||
public void close() throws IOException {
|
||||
|
@ -99,10 +97,56 @@ public final class DesktopConnection implements Closeable {
|
|||
IO.writeFully(videoFd, buffer, 0, buffer.length);
|
||||
}
|
||||
|
||||
public void send(ByteBuffer data) {
|
||||
try {
|
||||
IO.writeFully(videoFd, data);
|
||||
} catch (IOException e) {
|
||||
Ln.e("Failed to send data", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
boolean hasConnections() {
|
||||
return true;
|
||||
}
|
||||
|
||||
public FileDescriptor getVideoFd() {
|
||||
return videoFd;
|
||||
}
|
||||
|
||||
private void startEventController() {
|
||||
new Thread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
try {
|
||||
// on start, power on the device
|
||||
if (!Device.isScreenOn()) {
|
||||
controller.turnScreenOn();
|
||||
|
||||
// dirty hack
|
||||
// After POWER is injected, the device is powered on asynchronously.
|
||||
// To turn the device screen off while mirroring, the client will send a message that
|
||||
// would be handled before the device is actually powered on, so its effect would
|
||||
// be "canceled" once the device is turned back on.
|
||||
// Adding this delay prevents to handle the message before the device is actually
|
||||
// powered on.
|
||||
SystemClock.sleep(500);
|
||||
}
|
||||
while (true) {
|
||||
ControlMessage controlEvent = receiveControlMessage();
|
||||
if (controlEvent != null) {
|
||||
controller.handleEvent(controlEvent);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
// this is expected on close
|
||||
Ln.d("Event controller stopped");
|
||||
}
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
public ControlMessage receiveControlMessage() throws IOException {
|
||||
ControlMessage msg = reader.next();
|
||||
while (msg == null) {
|
||||
|
|
|
@ -55,24 +55,26 @@ public final class Device {
|
|||
|
||||
private final boolean supportsInputEvents;
|
||||
|
||||
public Device(Options options) {
|
||||
displayId = options.getDisplayId();
|
||||
DisplayInfo displayInfo = SERVICE_MANAGER.getDisplayManager().getDisplayInfo(displayId);
|
||||
private IRotationWatcher rotationWatcher;
|
||||
private IOnPrimaryClipChangedListener clipChangedListener;
|
||||
|
||||
public Device(final Options options, final VideoSettings videoSettings) {
|
||||
displayId = videoSettings.getDisplayId();
|
||||
final DisplayInfo displayInfo = Device.getDisplayInfo(displayId);
|
||||
if (displayInfo == null) {
|
||||
int[] displayIds = SERVICE_MANAGER.getDisplayManager().getDisplayIds();
|
||||
throw new InvalidDisplayIdException(displayId, displayIds);
|
||||
throw new InvalidDisplayIdException(displayId, Device.getDisplayIds());
|
||||
}
|
||||
|
||||
int displayInfoFlags = displayInfo.getFlags();
|
||||
|
||||
screenInfo = ScreenInfo.computeScreenInfo(displayInfo, options.getCrop(), options.getMaxSize(), options.getLockedVideoOrientation());
|
||||
screenInfo = ScreenInfo.computeScreenInfo(displayInfo, videoSettings);
|
||||
layerStack = displayInfo.getLayerStack();
|
||||
|
||||
SERVICE_MANAGER.getWindowManager().registerRotationWatcher(new IRotationWatcher.Stub() {
|
||||
rotationWatcher = new IRotationWatcher.Stub() {
|
||||
@Override
|
||||
public void onRotationChanged(int rotation) {
|
||||
synchronized (Device.this) {
|
||||
screenInfo = screenInfo.withDeviceRotation(rotation);
|
||||
applyNewVideoSetting(videoSettings);
|
||||
|
||||
// notify
|
||||
if (rotationListener != null) {
|
||||
|
@ -80,29 +82,32 @@ public final class Device {
|
|||
}
|
||||
}
|
||||
}
|
||||
}, displayId);
|
||||
};
|
||||
|
||||
SERVICE_MANAGER.getWindowManager().registerRotationWatcher(rotationWatcher, displayId);
|
||||
|
||||
if (options.getControl()) {
|
||||
// If control is enabled, synchronize Android clipboard to the computer automatically
|
||||
ClipboardManager clipboardManager = SERVICE_MANAGER.getClipboardManager();
|
||||
if (clipboardManager != null) {
|
||||
clipboardManager.addPrimaryClipChangedListener(new IOnPrimaryClipChangedListener.Stub() {
|
||||
@Override
|
||||
public void dispatchPrimaryClipChanged() {
|
||||
if (isSettingClipboard.get()) {
|
||||
// This is a notification for the change we are currently applying, ignore it
|
||||
return;
|
||||
}
|
||||
synchronized (Device.this) {
|
||||
if (clipboardListener != null) {
|
||||
String text = getClipboardText();
|
||||
if (text != null) {
|
||||
clipboardListener.onClipboardTextChanged(text);
|
||||
}
|
||||
clipChangedListener = 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 (clipboardManager != null) {
|
||||
clipboardManager.addPrimaryClipChangedListener(clipChangedListener);
|
||||
} else {
|
||||
Ln.w("No clipboard manager, copy-paste between device and computer will not work");
|
||||
}
|
||||
|
@ -127,6 +132,14 @@ public final class Device {
|
|||
return layerStack;
|
||||
}
|
||||
|
||||
public void applyNewVideoSetting(VideoSettings videoSettings) {
|
||||
this.setScreenInfo(ScreenInfo.computeScreenInfo(Device.getDisplayInfo(displayId), videoSettings));
|
||||
}
|
||||
|
||||
public synchronized void setScreenInfo(ScreenInfo screenInfo) {
|
||||
this.screenInfo = screenInfo;
|
||||
}
|
||||
|
||||
public Point getPhysicalPoint(Position position) {
|
||||
// it hides the field on purpose, to read it with a lock
|
||||
@SuppressWarnings("checkstyle:HiddenField")
|
||||
|
@ -256,6 +269,22 @@ public final class Device {
|
|||
return ok;
|
||||
}
|
||||
|
||||
public void release() {
|
||||
if (clipChangedListener != null) {
|
||||
ClipboardManager clipboardManager = SERVICE_MANAGER.getClipboardManager();
|
||||
if (clipboardManager != null) {
|
||||
clipboardManager.removePrimaryClipChangedListener(clipChangedListener);
|
||||
}
|
||||
this.clipboardListener = null;
|
||||
}
|
||||
if (rotationWatcher != null) {
|
||||
SERVICE_MANAGER.getWindowManager().unregisterRotationWatcher(rotationWatcher);
|
||||
rotationWatcher = null;
|
||||
}
|
||||
this.rotationListener = null;
|
||||
this.clipboardListener = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mode one of the {@code POWER_MODE_*} constants
|
||||
*/
|
||||
|
@ -299,4 +328,12 @@ public final class Device {
|
|||
public static ContentProvider createSettingsProvider() {
|
||||
return SERVICE_MANAGER.getActivityManager().createSettingsProvider();
|
||||
}
|
||||
|
||||
public static int[] getDisplayIds() {
|
||||
return SERVICE_MANAGER.getDisplayManager().getDisplayIds();
|
||||
}
|
||||
|
||||
public static DisplayInfo getDisplayInfo(int displayId) {
|
||||
return SERVICE_MANAGER.getDisplayManager().getDisplayInfo(displayId);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,27 +1,83 @@
|
|||
package com.genymobile.scrcpy;
|
||||
|
||||
public final class DeviceMessage {
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public abstract class DeviceMessage {
|
||||
private static final int MESSAGE_MAX_SIZE = 1 << 18; // 256k
|
||||
public static final int MAX_EVENT_SIZE = 4096;
|
||||
public static final int TYPE_CLIPBOARD = 0;
|
||||
public static final int TYPE_PUSH_RESPONSE = 101;
|
||||
|
||||
private int type;
|
||||
private String text;
|
||||
|
||||
private DeviceMessage() {
|
||||
private DeviceMessage(int type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
private static final class ClipboardMessage extends DeviceMessage {
|
||||
public static final int CLIPBOARD_TEXT_MAX_LENGTH = MESSAGE_MAX_SIZE - 5; // type: 1 byte; length: 4 bytes
|
||||
private byte[] raw;
|
||||
private int len;
|
||||
private ClipboardMessage(String text) {
|
||||
super(TYPE_CLIPBOARD);
|
||||
this.raw = text.getBytes(StandardCharsets.UTF_8);
|
||||
this.len = StringUtils.getUtf8TruncationIndex(raw, CLIPBOARD_TEXT_MAX_LENGTH);
|
||||
}
|
||||
public void writeToByteArray(byte[] array, int offset) {
|
||||
ByteBuffer buffer = ByteBuffer.wrap(array, offset, array.length - offset);
|
||||
buffer.put((byte) this.getType());
|
||||
buffer.putInt(len);
|
||||
buffer.put(raw, 0, len);
|
||||
}
|
||||
public int getLen() {
|
||||
return 5 + len;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class FilePushResponseMessage extends DeviceMessage {
|
||||
private short id;
|
||||
private int result;
|
||||
|
||||
private FilePushResponseMessage(short id, int result) {
|
||||
super(TYPE_PUSH_RESPONSE);
|
||||
this.id = id;
|
||||
this.result = result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToByteArray(byte[] array, int offset) {
|
||||
ByteBuffer buffer = ByteBuffer.wrap(array, offset, array.length - offset);
|
||||
buffer.put((byte) this.getType());
|
||||
buffer.putShort(id);
|
||||
buffer.put((byte) result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getLen() {
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
|
||||
public static DeviceMessage createClipboard(String text) {
|
||||
DeviceMessage event = new DeviceMessage();
|
||||
event.type = TYPE_CLIPBOARD;
|
||||
event.text = text;
|
||||
return event;
|
||||
return new ClipboardMessage(text);
|
||||
}
|
||||
|
||||
public static DeviceMessage createPushResponse(short id, int result) {
|
||||
return new FilePushResponseMessage(id, result);
|
||||
}
|
||||
|
||||
public int getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public String getText() {
|
||||
return text;
|
||||
public void writeToByteArray(byte[] array) {
|
||||
writeToByteArray(array, 0);
|
||||
}
|
||||
public byte[] writeToByteArray(int offset) {
|
||||
byte[] temp = new byte[offset + this.getLen()];
|
||||
writeToByteArray(temp, offset);
|
||||
return temp;
|
||||
}
|
||||
public abstract void writeToByteArray(byte[] array, int offset);
|
||||
public abstract int getLen();
|
||||
}
|
||||
|
|
|
@ -4,11 +4,11 @@ import java.io.IOException;
|
|||
|
||||
public final class DeviceMessageSender {
|
||||
|
||||
private final DesktopConnection connection;
|
||||
private final Connection connection;
|
||||
|
||||
private String clipboardText;
|
||||
|
||||
public DeviceMessageSender(DesktopConnection connection) {
|
||||
public DeviceMessageSender(Connection connection) {
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
|
|
|
@ -2,32 +2,13 @@ package com.genymobile.scrcpy;
|
|||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public class DeviceMessageWriter {
|
||||
|
||||
private static final int MESSAGE_MAX_SIZE = 1 << 18; // 256k
|
||||
public static final int CLIPBOARD_TEXT_MAX_LENGTH = MESSAGE_MAX_SIZE - 5; // type: 1 byte; length: 4 bytes
|
||||
|
||||
private final byte[] rawBuffer = new byte[MESSAGE_MAX_SIZE];
|
||||
private final ByteBuffer buffer = ByteBuffer.wrap(rawBuffer);
|
||||
private final byte[] rawBuffer = new byte[DeviceMessage.MAX_EVENT_SIZE];
|
||||
|
||||
public void writeTo(DeviceMessage msg, OutputStream output) throws IOException {
|
||||
buffer.clear();
|
||||
buffer.put((byte) DeviceMessage.TYPE_CLIPBOARD);
|
||||
switch (msg.getType()) {
|
||||
case DeviceMessage.TYPE_CLIPBOARD:
|
||||
String text = msg.getText();
|
||||
byte[] raw = text.getBytes(StandardCharsets.UTF_8);
|
||||
int len = StringUtils.getUtf8TruncationIndex(raw, CLIPBOARD_TEXT_MAX_LENGTH);
|
||||
buffer.putInt(len);
|
||||
buffer.put(raw, 0, len);
|
||||
output.write(rawBuffer, 0, buffer.position());
|
||||
break;
|
||||
default:
|
||||
Ln.w("Unknown device message: " + msg.getType());
|
||||
break;
|
||||
}
|
||||
msg.writeToByteArray(rawBuffer);
|
||||
output.write(rawBuffer, 0, msg.getLen());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package com.genymobile.scrcpy;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
public final class DisplayInfo {
|
||||
private final int displayId;
|
||||
private final Size size;
|
||||
|
@ -36,5 +38,16 @@ public final class DisplayInfo {
|
|||
public int getFlags() {
|
||||
return flags;
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] toByteArray() {
|
||||
ByteBuffer temp = ByteBuffer.allocate(24);
|
||||
temp.putInt(displayId);
|
||||
temp.putInt(size.getWidth());
|
||||
temp.putInt(size.getHeight());
|
||||
temp.putInt(rotation);
|
||||
temp.putInt(layerStack);
|
||||
temp.putInt(flags);
|
||||
temp.rewind();
|
||||
return temp.array();
|
||||
}
|
||||
}
|
||||
|
|
247
server/src/main/java/com/genymobile/scrcpy/FilePushHandler.java
Normal file
247
server/src/main/java/com/genymobile/scrcpy/FilePushHandler.java
Normal file
|
@ -0,0 +1,247 @@
|
|||
package com.genymobile.scrcpy;
|
||||
|
||||
import org.java_websocket.WebSocket;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
|
||||
public final class FilePushHandler {
|
||||
private static final int NEW_PUSH_ID = 1;
|
||||
private static final int NO_ERROR = 0;
|
||||
private static final int ERROR_INVALID_NAME = -1;
|
||||
private static final int ERROR_NO_SPACE = -2;
|
||||
private static final int ERROR_FAILED_TO_DELETE = -3;
|
||||
private static final int ERROR_FAILED_TO_CREATE = -4;
|
||||
private static final int ERROR_FILE_NOT_FOUND = -5;
|
||||
private static final int ERROR_FAILED_TO_WRITE = -6;
|
||||
private static final int ERROR_FILE_IS_BUSY = -7;
|
||||
private static final int ERROR_INVALID_STATE = -8;
|
||||
private static final int ERROR_UNKNOWN_ID = -9;
|
||||
private static final int ERROR_NO_FREE_ID = -10;
|
||||
private static final int ERROR_INCORRECT_SIZE = -11;
|
||||
|
||||
private static final String PUSH_PATH = "/data/local/tmp";
|
||||
|
||||
private FilePushHandler() {
|
||||
}
|
||||
|
||||
private static final class FilePush {
|
||||
private static final HashMap<String, FilePush> INSTANCES_BY_NAME = new HashMap<>();
|
||||
private static final HashMap<Short, FilePush> INSTANCES_BY_ID = new HashMap<>();
|
||||
private static short nextPushId = 0;
|
||||
|
||||
private final FileOutputStream stream;
|
||||
private final WebSocket conn;
|
||||
private final short pushId;
|
||||
private final String fileName;
|
||||
private final long fileSize;
|
||||
private long processedBytes = 0;
|
||||
|
||||
|
||||
FilePush(short pushId, long fileSize, String fileName, FileOutputStream stream, WebSocket conn) {
|
||||
this.pushId = pushId;
|
||||
this.fileSize = fileSize;
|
||||
this.fileName = fileName;
|
||||
this.stream = stream;
|
||||
this.conn = conn;
|
||||
INSTANCES_BY_ID.put(pushId, this);
|
||||
INSTANCES_BY_NAME.put(fileName, this);
|
||||
}
|
||||
public static FilePush getInstance(String fileName) {
|
||||
return INSTANCES_BY_NAME.get(fileName);
|
||||
}
|
||||
public static FilePush getInstance(short id) {
|
||||
return INSTANCES_BY_ID.get(id);
|
||||
}
|
||||
public static short getNextPushId() {
|
||||
short current = nextPushId;
|
||||
while (INSTANCES_BY_ID.containsKey(++nextPushId)) {
|
||||
if (nextPushId == Short.MAX_VALUE) {
|
||||
nextPushId = 0;
|
||||
}
|
||||
if (nextPushId == current) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
return nextPushId;
|
||||
}
|
||||
public static void releaseByConnection(WebSocket conn) {
|
||||
ArrayList<FilePush> related = new ArrayList<>();
|
||||
for (FilePush filePush: INSTANCES_BY_ID.values()) {
|
||||
if (filePush.conn.equals(conn)) {
|
||||
related.add(filePush);
|
||||
}
|
||||
}
|
||||
for (FilePush filePush: related) {
|
||||
try {
|
||||
filePush.release();
|
||||
} catch (IOException e) {
|
||||
Ln.w("Failed to release stream for file: \"" + filePush.getFileName() + "\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
public void write(byte[] chunk, int len) throws IOException {
|
||||
processedBytes += len;
|
||||
stream.write(chunk, 0, len);
|
||||
}
|
||||
public String getFileName() {
|
||||
return fileName;
|
||||
}
|
||||
public boolean isComplete() {
|
||||
return processedBytes == fileSize;
|
||||
}
|
||||
public void release() throws IOException {
|
||||
INSTANCES_BY_ID.remove(pushId);
|
||||
INSTANCES_BY_NAME.remove(fileName);
|
||||
this.stream.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static ByteBuffer pushFilePushResponse(short id, int result) {
|
||||
DeviceMessage msg = DeviceMessage.createPushResponse(id, result);
|
||||
return WebSocketConnection.deviceMessageToByteBuffer(msg);
|
||||
}
|
||||
|
||||
private static FilePush checkPushId(WebSocket conn, ControlMessage msg) {
|
||||
short pushId = msg.getPushId();
|
||||
FilePush filePush = FilePush.getInstance(pushId);
|
||||
if (filePush == null) {
|
||||
conn.send(pushFilePushResponse(pushId, ERROR_UNKNOWN_ID));
|
||||
}
|
||||
return filePush;
|
||||
}
|
||||
|
||||
private static void handleNew(WebSocket conn) {
|
||||
short newPushId = FilePush.getNextPushId();
|
||||
if (newPushId == -1) {
|
||||
conn.send(FilePushHandler.pushFilePushResponse(newPushId, ERROR_NO_FREE_ID));
|
||||
} else {
|
||||
conn.send(FilePushHandler.pushFilePushResponse(newPushId, NEW_PUSH_ID));
|
||||
}
|
||||
}
|
||||
private static void handleStart(WebSocket conn, ControlMessage msg) {
|
||||
short pushId = msg.getPushId();
|
||||
String fileName = msg.getFileName();
|
||||
int fileSize = msg.getFileSize();
|
||||
if (FilePush.getInstance(fileName) != null) {
|
||||
conn.send(pushFilePushResponse(pushId, ERROR_FILE_IS_BUSY));
|
||||
return;
|
||||
}
|
||||
if (fileName.contains("/")) {
|
||||
conn.send(pushFilePushResponse(pushId, ERROR_INVALID_NAME));
|
||||
return;
|
||||
}
|
||||
File file = new File(PUSH_PATH, fileName);
|
||||
|
||||
// long freeSpace = file.getFreeSpace();
|
||||
// if (freeSpace < fileSize) {
|
||||
// sender.send(pushFilePushResponse(pushId, ERROR_NO_SPACE));
|
||||
// return;
|
||||
// }
|
||||
|
||||
try {
|
||||
if (!file.createNewFile()) {
|
||||
if (!file.delete()) {
|
||||
conn.send(pushFilePushResponse(pushId, ERROR_FAILED_TO_DELETE));
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
conn.send(pushFilePushResponse(pushId, ERROR_FAILED_TO_CREATE));
|
||||
return;
|
||||
}
|
||||
|
||||
FileOutputStream stream;
|
||||
try {
|
||||
stream = new FileOutputStream(file);
|
||||
} catch (FileNotFoundException e) {
|
||||
conn.send(pushFilePushResponse(pushId, ERROR_FILE_NOT_FOUND));
|
||||
return;
|
||||
}
|
||||
|
||||
new FilePush(pushId, fileSize, fileName, stream, conn);
|
||||
conn.send(pushFilePushResponse(pushId, NO_ERROR));
|
||||
|
||||
}
|
||||
private static void handleAppend(WebSocket conn, ControlMessage msg) {
|
||||
FilePush filePush = checkPushId(conn, msg);
|
||||
if (filePush == null) {
|
||||
return;
|
||||
}
|
||||
short pushId = msg.getPushId();
|
||||
try {
|
||||
filePush.write(msg.getPushChunk(), msg.getPushChunkSize());
|
||||
conn.send(pushFilePushResponse(pushId, NO_ERROR));
|
||||
} catch (IOException e) {
|
||||
conn.send(pushFilePushResponse(pushId, ERROR_FAILED_TO_WRITE));
|
||||
try {
|
||||
filePush.release();
|
||||
} catch (IOException ex) {
|
||||
Ln.w("Failed to release stream for file: \"" + filePush.getFileName() + "\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
private static void handleFinish(WebSocket conn, ControlMessage msg) {
|
||||
FilePush filePush = checkPushId(conn, msg);
|
||||
if (filePush == null) {
|
||||
return;
|
||||
}
|
||||
short pushId = msg.getPushId();
|
||||
if (filePush.isComplete()) {
|
||||
try {
|
||||
filePush.release();
|
||||
conn.send(pushFilePushResponse(pushId, NO_ERROR));
|
||||
} catch (IOException e) {
|
||||
conn.send(pushFilePushResponse(pushId, ERROR_FAILED_TO_WRITE));
|
||||
}
|
||||
} else {
|
||||
conn.send(pushFilePushResponse(pushId, ERROR_INCORRECT_SIZE));
|
||||
}
|
||||
}
|
||||
private static void handleCancel(WebSocket conn, ControlMessage msg) {
|
||||
FilePush filePush = checkPushId(conn, msg);
|
||||
if (filePush == null) {
|
||||
return;
|
||||
}
|
||||
short pushId = msg.getPushId();
|
||||
try {
|
||||
filePush.release();
|
||||
conn.send(pushFilePushResponse(pushId, NO_ERROR));
|
||||
} catch (IOException e) {
|
||||
conn.send(pushFilePushResponse(pushId, ERROR_FAILED_TO_WRITE));
|
||||
}
|
||||
}
|
||||
|
||||
public static void cancelAllForConnection(WebSocket conn) {
|
||||
FilePush.releaseByConnection(conn);
|
||||
}
|
||||
|
||||
public static void handlePush(WebSocket conn, ControlMessage msg) {
|
||||
int state = msg.getPushState();
|
||||
switch (state) {
|
||||
case ControlMessage.PUSH_STATE_NEW:
|
||||
handleNew(conn);
|
||||
break;
|
||||
case ControlMessage.PUSH_STATE_APPEND:
|
||||
handleAppend(conn, msg);
|
||||
break;
|
||||
case ControlMessage.PUSH_STATE_START:
|
||||
handleStart(conn, msg);
|
||||
break;
|
||||
case ControlMessage.PUSH_STATE_FINISH:
|
||||
handleFinish(conn, msg);
|
||||
break;
|
||||
case ControlMessage.PUSH_STATE_CANCEL:
|
||||
handleCancel(conn, msg);
|
||||
break;
|
||||
default:
|
||||
conn.send(pushFilePushResponse(msg.getPushId(), ERROR_INVALID_STATE));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -3,21 +3,27 @@ package com.genymobile.scrcpy;
|
|||
import android.graphics.Rect;
|
||||
|
||||
public class Options {
|
||||
private Ln.Level logLevel;
|
||||
public static final int TYPE_LOCAL_SOCKET = 1;
|
||||
public static final int TYPE_WEB_SOCKET = 2;
|
||||
|
||||
private Ln.Level logLevel = Ln.Level.ERROR;
|
||||
private int maxSize;
|
||||
private int bitRate;
|
||||
private int maxFps;
|
||||
private int lockedVideoOrientation;
|
||||
private boolean tunnelForward;
|
||||
private boolean tunnelForward = false;
|
||||
private Rect crop;
|
||||
private boolean sendFrameMeta; // send PTS so that the client may record properly
|
||||
private boolean control;
|
||||
private boolean control = true;
|
||||
private int displayId;
|
||||
private boolean showTouches;
|
||||
private boolean stayAwake;
|
||||
private boolean showTouches = false;
|
||||
private boolean stayAwake = false;
|
||||
private String codecOptions;
|
||||
private String encoderName;
|
||||
private boolean powerOffScreenOnClose;
|
||||
private int serverType = TYPE_LOCAL_SOCKET;
|
||||
private int portNumber = 8886;
|
||||
private boolean listenOnAllInterfaces = true;
|
||||
|
||||
public Ln.Level getLogLevel() {
|
||||
return logLevel;
|
||||
|
@ -32,7 +38,7 @@ public class Options {
|
|||
}
|
||||
|
||||
public void setMaxSize(int maxSize) {
|
||||
this.maxSize = maxSize;
|
||||
this.maxSize = (maxSize / 8) * 8;
|
||||
}
|
||||
|
||||
public int getBitRate() {
|
||||
|
@ -138,4 +144,44 @@ public class Options {
|
|||
public boolean getPowerOffScreenOnClose() {
|
||||
return this.powerOffScreenOnClose;
|
||||
}
|
||||
|
||||
public int getServerType() {
|
||||
return serverType;
|
||||
}
|
||||
|
||||
public void setServerType(int type) {
|
||||
if (type == TYPE_LOCAL_SOCKET || type == TYPE_WEB_SOCKET) {
|
||||
this.serverType = type;
|
||||
}
|
||||
}
|
||||
|
||||
public void setPortNumber(int portNumber) {
|
||||
this.portNumber = portNumber;
|
||||
}
|
||||
|
||||
public int getPortNumber() {
|
||||
return this.portNumber;
|
||||
}
|
||||
|
||||
public boolean getListenOnAllInterfaces() {
|
||||
return this.listenOnAllInterfaces;
|
||||
}
|
||||
|
||||
public void setListenOnAllInterfaces(boolean value) {
|
||||
this.listenOnAllInterfaces = value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Options{"
|
||||
+ "maxSize=" + maxSize
|
||||
+ ", bitRate=" + bitRate
|
||||
+ ", maxFps=" + maxFps
|
||||
+ ", tunnelForward=" + tunnelForward
|
||||
+ ", crop=" + crop
|
||||
+ ", sendFrameMeta=" + sendFrameMeta
|
||||
+ ", serverType=" + (serverType == TYPE_LOCAL_SOCKET ? "local" : "web")
|
||||
+ ", listenOnAllInterfaces=" + (this.listenOnAllInterfaces ? "true" : "false")
|
||||
+ '}';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,7 +11,6 @@ import android.os.Build;
|
|||
import android.os.IBinder;
|
||||
import android.view.Surface;
|
||||
|
||||
import java.io.FileDescriptor;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayList;
|
||||
|
@ -19,7 +18,7 @@ import java.util.Arrays;
|
|||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public class ScreenEncoder implements Device.RotationListener {
|
||||
public class ScreenEncoder implements Connection.StreamInvalidateListener, Runnable {
|
||||
|
||||
private static final int DEFAULT_I_FRAME_INTERVAL = 10; // seconds
|
||||
private static final int REPEAT_FRAME_DELAY_US = 100_000; // repeat after 100ms
|
||||
|
@ -27,55 +26,77 @@ public class ScreenEncoder implements Device.RotationListener {
|
|||
|
||||
private static final int NO_PTS = -1;
|
||||
|
||||
private final AtomicBoolean rotationChanged = new AtomicBoolean();
|
||||
private final AtomicBoolean streamIsInvalide = new AtomicBoolean();
|
||||
private final ByteBuffer headerBuffer = ByteBuffer.allocate(12);
|
||||
private Thread selectorThread;
|
||||
|
||||
private String encoderName;
|
||||
private List<CodecOption> codecOptions;
|
||||
private int bitRate;
|
||||
private int maxFps;
|
||||
private boolean sendFrameMeta;
|
||||
private long ptsOrigin;
|
||||
private Device device;
|
||||
private Connection connection;
|
||||
private VideoSettings videoSettings;
|
||||
private MediaFormat format;
|
||||
private int timeout = -1;
|
||||
|
||||
public ScreenEncoder(boolean sendFrameMeta, int bitRate, int maxFps, List<CodecOption> codecOptions, String encoderName) {
|
||||
this.sendFrameMeta = sendFrameMeta;
|
||||
this.bitRate = bitRate;
|
||||
this.maxFps = maxFps;
|
||||
this.codecOptions = codecOptions;
|
||||
this.encoderName = encoderName;
|
||||
public ScreenEncoder(VideoSettings videoSettings) {
|
||||
this.videoSettings = videoSettings;
|
||||
updateFormat();
|
||||
}
|
||||
|
||||
private void updateFormat() {
|
||||
format = createFormat(videoSettings);
|
||||
int maxFps = videoSettings.getMaxFps();
|
||||
if (maxFps > 0) {
|
||||
timeout = 1_000_000 / maxFps;
|
||||
} else {
|
||||
timeout = -1;
|
||||
}
|
||||
}
|
||||
|
||||
public void setConnection(Connection connection) {
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
public void setDevice(Device device) {
|
||||
this.device = device;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRotationChanged(int rotation) {
|
||||
rotationChanged.set(true);
|
||||
public void onStreamInvalidate() {
|
||||
Ln.d("invalidate stream");
|
||||
streamIsInvalide.set(true);
|
||||
updateFormat();
|
||||
}
|
||||
|
||||
public boolean consumeRotationChange() {
|
||||
return rotationChanged.getAndSet(false);
|
||||
public boolean consumeStreamInvalidation() {
|
||||
return streamIsInvalide.getAndSet(false);
|
||||
}
|
||||
|
||||
public void streamScreen(Device device, FileDescriptor fd) throws IOException {
|
||||
public boolean isAlive() {
|
||||
return selectorThread != null && selectorThread.isAlive();
|
||||
}
|
||||
|
||||
public void streamScreen() throws IOException {
|
||||
Workarounds.prepareMainLooper();
|
||||
|
||||
try {
|
||||
internalStreamScreen(device, fd);
|
||||
internalStreamScreen();
|
||||
} 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);
|
||||
internalStreamScreen();
|
||||
}
|
||||
}
|
||||
|
||||
private void internalStreamScreen(Device device, FileDescriptor fd) throws IOException {
|
||||
MediaFormat format = createFormat(bitRate, maxFps, codecOptions);
|
||||
device.setRotationListener(this);
|
||||
private void internalStreamScreen() throws IOException {
|
||||
updateFormat();
|
||||
connection.setStreamInvalidateListener(this);
|
||||
boolean alive;
|
||||
try {
|
||||
do {
|
||||
MediaCodec codec = createCodec(encoderName);
|
||||
MediaCodec codec = createCodec(videoSettings.getEncoderName());
|
||||
IBinder display = createDisplay();
|
||||
ScreenInfo screenInfo = device.getScreenInfo();
|
||||
Rect contentRect = screenInfo.getContentRect();
|
||||
|
@ -92,7 +113,7 @@ public class ScreenEncoder implements Device.RotationListener {
|
|||
setDisplaySurface(display, surface, videoRotation, contentRect, unlockedVideoRect, layerStack);
|
||||
codec.start();
|
||||
try {
|
||||
alive = encode(codec, fd);
|
||||
alive = encode(codec);
|
||||
// do not call stop() on exception, it would trigger an IllegalStateException
|
||||
codec.stop();
|
||||
} finally {
|
||||
|
@ -102,30 +123,30 @@ public class ScreenEncoder implements Device.RotationListener {
|
|||
}
|
||||
} while (alive);
|
||||
} finally {
|
||||
device.setRotationListener(null);
|
||||
connection.setStreamInvalidateListener(null);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean encode(MediaCodec codec, FileDescriptor fd) throws IOException {
|
||||
private boolean encode(MediaCodec codec) throws IOException {
|
||||
boolean eof = false;
|
||||
MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
|
||||
|
||||
while (!consumeRotationChange() && !eof) {
|
||||
int outputBufferId = codec.dequeueOutputBuffer(bufferInfo, -1);
|
||||
while (!consumeStreamInvalidation() && !eof && connection.hasConnections()) {
|
||||
int outputBufferId = codec.dequeueOutputBuffer(bufferInfo, timeout);
|
||||
eof = (bufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0;
|
||||
try {
|
||||
if (consumeRotationChange()) {
|
||||
if (consumeStreamInvalidation()) {
|
||||
// must restart encoding with new size
|
||||
break;
|
||||
}
|
||||
if (outputBufferId >= 0) {
|
||||
ByteBuffer codecBuffer = codec.getOutputBuffer(outputBufferId);
|
||||
|
||||
if (sendFrameMeta) {
|
||||
writeFrameMeta(fd, bufferInfo, codecBuffer.remaining());
|
||||
if (videoSettings.getSendFrameMeta()) {
|
||||
writeFrameMeta(bufferInfo, codecBuffer.remaining());
|
||||
}
|
||||
|
||||
IO.writeFully(fd, codecBuffer);
|
||||
connection.send(codecBuffer);
|
||||
}
|
||||
} finally {
|
||||
if (outputBufferId >= 0) {
|
||||
|
@ -134,10 +155,10 @@ public class ScreenEncoder implements Device.RotationListener {
|
|||
}
|
||||
}
|
||||
|
||||
return !eof;
|
||||
return !eof && connection.hasConnections();
|
||||
}
|
||||
|
||||
private void writeFrameMeta(FileDescriptor fd, MediaCodec.BufferInfo bufferInfo, int packetSize) throws IOException {
|
||||
private void writeFrameMeta(MediaCodec.BufferInfo bufferInfo, int packetSize) throws IOException {
|
||||
headerBuffer.clear();
|
||||
|
||||
long pts;
|
||||
|
@ -153,10 +174,10 @@ public class ScreenEncoder implements Device.RotationListener {
|
|||
headerBuffer.putLong(pts);
|
||||
headerBuffer.putInt(packetSize);
|
||||
headerBuffer.flip();
|
||||
IO.writeFully(fd, headerBuffer);
|
||||
connection.send(headerBuffer);
|
||||
}
|
||||
|
||||
private static MediaCodecInfo[] listEncoders() {
|
||||
public static MediaCodecInfo[] listEncoders() {
|
||||
List<MediaCodecInfo> result = new ArrayList<>();
|
||||
MediaCodecList list = new MediaCodecList(MediaCodecList.REGULAR_CODECS);
|
||||
for (MediaCodecInfo codecInfo : list.getCodecInfos()) {
|
||||
|
@ -199,14 +220,18 @@ public class ScreenEncoder implements Device.RotationListener {
|
|||
Ln.d("Codec option set: " + key + " (" + value.getClass().getSimpleName() + ") = " + value);
|
||||
}
|
||||
|
||||
private static MediaFormat createFormat(int bitRate, int maxFps, List<CodecOption> codecOptions) {
|
||||
private static MediaFormat createFormat(VideoSettings videoSettings) {
|
||||
int bitRate = videoSettings.getBitRate();
|
||||
int maxFps = videoSettings.getMaxFps();
|
||||
int iFrameInterval = videoSettings.getIFrameInterval();
|
||||
List<CodecOption> codecOptions = videoSettings.getCodecOptions();
|
||||
MediaFormat format = new MediaFormat();
|
||||
format.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_VIDEO_AVC);
|
||||
format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate);
|
||||
// must be present to configure the encoder, but does not impact the actual frame rate, which is variable
|
||||
format.setInteger(MediaFormat.KEY_FRAME_RATE, 60);
|
||||
format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
|
||||
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, DEFAULT_I_FRAME_INTERVAL);
|
||||
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) {
|
||||
|
@ -256,4 +281,28 @@ public class ScreenEncoder implements Device.RotationListener {
|
|||
private static void destroyDisplay(IBinder display) {
|
||||
SurfaceControl.destroyDisplay(display);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
synchronized (this) {
|
||||
if (selectorThread != null && selectorThread.isAlive()) {
|
||||
throw new IllegalStateException(getClass().getName() + " can only be started once.");
|
||||
}
|
||||
selectorThread = Thread.currentThread();
|
||||
}
|
||||
try {
|
||||
this.streamScreen();
|
||||
} catch (IOException e) {
|
||||
Ln.e("Failed to start screen recorder", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void start(Device device, Connection connection) {
|
||||
this.device = device;
|
||||
this.connection = connection;
|
||||
if (selectorThread != null && selectorThread.isAlive()) {
|
||||
throw new IllegalStateException(getClass().getName() + " can only be started once.");
|
||||
}
|
||||
new Thread(this).start();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,8 @@ package com.genymobile.scrcpy;
|
|||
|
||||
import android.graphics.Rect;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
public final class ScreenInfo {
|
||||
/**
|
||||
* Device (physical) size, possibly cropped
|
||||
|
@ -80,7 +82,9 @@ public final class ScreenInfo {
|
|||
return new ScreenInfo(newContentRect, newUnlockedVideoSize, newDeviceRotation, lockedVideoOrientation);
|
||||
}
|
||||
|
||||
public static ScreenInfo computeScreenInfo(DisplayInfo displayInfo, Rect crop, int maxSize, int lockedVideoOrientation) {
|
||||
public static ScreenInfo computeScreenInfo(DisplayInfo displayInfo, VideoSettings videoSettings) {
|
||||
int lockedVideoOrientation = videoSettings.getLockedVideoOrientation();
|
||||
Rect crop = videoSettings.getCrop();
|
||||
int rotation = displayInfo.getRotation();
|
||||
|
||||
if (lockedVideoOrientation == Device.LOCK_VIDEO_ORIENTATION_INITIAL) {
|
||||
|
@ -102,7 +106,8 @@ public final class ScreenInfo {
|
|||
}
|
||||
}
|
||||
|
||||
Size videoSize = computeVideoSize(contentRect.width(), contentRect.height(), maxSize);
|
||||
Size bounds = videoSettings.getBounds();
|
||||
Size videoSize = computeVideoSize(contentRect.width(), contentRect.height(), bounds);
|
||||
return new ScreenInfo(contentRect, videoSize, rotation, lockedVideoOrientation);
|
||||
}
|
||||
|
||||
|
@ -110,31 +115,35 @@ public final class ScreenInfo {
|
|||
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;
|
||||
private static Size computeVideoSize(int w, int h, Size bounds) {
|
||||
if (bounds == null) {
|
||||
w &= ~15; // in case it's not a multiple of 16
|
||||
h &= ~15;
|
||||
return new Size(w, h);
|
||||
}
|
||||
return new Size(w, h);
|
||||
int boundsWidth = bounds.getWidth();
|
||||
int boundsHeight = bounds.getHeight();
|
||||
int scaledHeight;
|
||||
int scaledWidth;
|
||||
if (boundsWidth > w) {
|
||||
scaledHeight = h;
|
||||
} else {
|
||||
scaledHeight = boundsWidth * h / w;
|
||||
}
|
||||
if (boundsHeight > scaledHeight) {
|
||||
boundsHeight = scaledHeight;
|
||||
}
|
||||
if (boundsHeight == h) {
|
||||
scaledWidth = w;
|
||||
} else {
|
||||
scaledWidth = boundsHeight * w / h;
|
||||
}
|
||||
if (boundsWidth > scaledWidth) {
|
||||
boundsWidth = scaledWidth;
|
||||
}
|
||||
boundsWidth &= ~15;
|
||||
boundsHeight &= ~15;
|
||||
return new Size(boundsWidth, boundsHeight);
|
||||
}
|
||||
|
||||
private static Rect flipRect(Rect crop) {
|
||||
|
@ -166,4 +175,16 @@ public final class ScreenInfo {
|
|||
}
|
||||
return (lockedVideoOrientation + 4 - deviceRotation) % 4;
|
||||
}
|
||||
|
||||
public byte[] toByteArray() {
|
||||
ByteBuffer temp = ByteBuffer.allocate(6 * 4 + 1);
|
||||
temp.putInt(contentRect.left);
|
||||
temp.putInt(contentRect.top);
|
||||
temp.putInt(contentRect.right);
|
||||
temp.putInt(contentRect.bottom);
|
||||
temp.putInt(unlockedVideoSize.getWidth());
|
||||
temp.putInt(unlockedVideoSize.getHeight());
|
||||
temp.put((byte) getVideoRotation());
|
||||
return temp.array();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,15 +1,10 @@
|
|||
package com.genymobile.scrcpy;
|
||||
|
||||
import com.genymobile.scrcpy.wrappers.ContentProvider;
|
||||
|
||||
import android.graphics.Rect;
|
||||
import android.media.MediaCodec;
|
||||
import android.media.MediaCodecInfo;
|
||||
import android.os.BatteryManager;
|
||||
import android.os.Build;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
public final class Server {
|
||||
|
@ -19,112 +14,7 @@ public final class Server {
|
|||
// not instantiable
|
||||
}
|
||||
|
||||
private static void scrcpy(Options options) throws IOException {
|
||||
Ln.i("Device: " + Build.MANUFACTURER + " " + Build.MODEL + " (Android " + Build.VERSION.RELEASE + ")");
|
||||
final Device device = new Device(options);
|
||||
List<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(options.getDisplayId(), restoreStayOn, mustDisableShowTouchesOnCleanUp, true, options.getPowerOffScreenOnClose());
|
||||
|
||||
boolean tunnelForward = options.isTunnelForward();
|
||||
|
||||
try (DesktopConnection connection = DesktopConnection.open(device, tunnelForward)) {
|
||||
ScreenEncoder screenEncoder = new ScreenEncoder(options.getSendFrameMeta(), options.getBitRate(), options.getMaxFps(), codecOptions,
|
||||
options.getEncoderName());
|
||||
|
||||
Thread controllerThread = null;
|
||||
Thread deviceMessageSenderThread = null;
|
||||
if (options.getControl()) {
|
||||
final Controller controller = new Controller(device, connection);
|
||||
|
||||
// asynchronous
|
||||
controllerThread = startController(controller);
|
||||
deviceMessageSenderThread = startDeviceMessageSender(controller.getSender());
|
||||
|
||||
device.setClipboardListener(new Device.ClipboardListener() {
|
||||
@Override
|
||||
public void onClipboardTextChanged(String text) {
|
||||
controller.getSender().pushClipboardText(text);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// synchronous
|
||||
screenEncoder.streamScreen(device, connection.getVideoFd());
|
||||
} catch (IOException e) {
|
||||
// this is expected on close
|
||||
Ln.d("Screen streaming stopped");
|
||||
} finally {
|
||||
if (controllerThread != null) {
|
||||
controllerThread.interrupt();
|
||||
}
|
||||
if (deviceMessageSenderThread != null) {
|
||||
deviceMessageSenderThread.interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Thread startController(final Controller controller) {
|
||||
Thread thread = new Thread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
try {
|
||||
controller.control();
|
||||
} catch (IOException e) {
|
||||
// this is expected on close
|
||||
Ln.d("Controller stopped");
|
||||
}
|
||||
}
|
||||
});
|
||||
thread.start();
|
||||
return thread;
|
||||
}
|
||||
|
||||
private static Thread startDeviceMessageSender(final DeviceMessageSender sender) {
|
||||
Thread thread = new Thread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
try {
|
||||
sender.loop();
|
||||
} catch (IOException | InterruptedException e) {
|
||||
// this is expected on close
|
||||
Ln.d("Device message sender stopped");
|
||||
}
|
||||
}
|
||||
});
|
||||
thread.start();
|
||||
return thread;
|
||||
}
|
||||
|
||||
private static Options createOptions(String... args) {
|
||||
private static void parseArguments(Options options, VideoSettings videoSettings, String... args) {
|
||||
if (args.length < 1) {
|
||||
throw new IllegalArgumentException("Missing client version");
|
||||
}
|
||||
|
@ -135,43 +25,60 @@ public final class Server {
|
|||
"The server version (" + BuildConfig.VERSION_NAME + ") does not match the client " + "(" + clientVersion + ")");
|
||||
}
|
||||
|
||||
if (args[1].toLowerCase().equals("web")) {
|
||||
options.setServerType(Options.TYPE_WEB_SOCKET);
|
||||
if (args.length > 2) {
|
||||
Ln.Level level = Ln.Level.valueOf(args[2].toUpperCase(Locale.ENGLISH));
|
||||
options.setLogLevel(level);
|
||||
}
|
||||
if (args.length > 3) {
|
||||
int portNumber = Integer.parseInt(args[3]);
|
||||
options.setPortNumber(portNumber);
|
||||
}
|
||||
if (args.length > 4) {
|
||||
boolean listenOnAllInterfaces = Boolean.parseBoolean(args[4]);
|
||||
options.setListenOnAllInterfaces(listenOnAllInterfaces);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final int expectedParameters = 16;
|
||||
if (args.length != expectedParameters) {
|
||||
throw new IllegalArgumentException("Expecting " + expectedParameters + " parameters");
|
||||
}
|
||||
|
||||
Options options = new Options();
|
||||
|
||||
Ln.Level level = Ln.Level.valueOf(args[1].toUpperCase(Locale.ENGLISH));
|
||||
options.setLogLevel(level);
|
||||
|
||||
int maxSize = Integer.parseInt(args[2]) & ~7; // multiple of 8
|
||||
options.setMaxSize(maxSize);
|
||||
int maxSize = Integer.parseInt(args[2]);
|
||||
if (maxSize != 0) {
|
||||
videoSettings.setBounds(maxSize, maxSize);
|
||||
}
|
||||
|
||||
int bitRate = Integer.parseInt(args[3]);
|
||||
options.setBitRate(bitRate);
|
||||
videoSettings.setBitRate(bitRate);
|
||||
|
||||
int maxFps = Integer.parseInt(args[4]);
|
||||
options.setMaxFps(maxFps);
|
||||
videoSettings.setMaxFps(maxFps);
|
||||
|
||||
int lockedVideoOrientation = Integer.parseInt(args[5]);
|
||||
options.setLockedVideoOrientation(lockedVideoOrientation);
|
||||
videoSettings.setLockedVideoOrientation(lockedVideoOrientation);
|
||||
|
||||
// use "adb forward" instead of "adb tunnel"? (so the server must listen)
|
||||
boolean tunnelForward = Boolean.parseBoolean(args[6]);
|
||||
options.setTunnelForward(tunnelForward);
|
||||
|
||||
Rect crop = parseCrop(args[7]);
|
||||
options.setCrop(crop);
|
||||
videoSettings.setCrop(crop);
|
||||
|
||||
boolean sendFrameMeta = Boolean.parseBoolean(args[8]);
|
||||
options.setSendFrameMeta(sendFrameMeta);
|
||||
videoSettings.setSendFrameMeta(sendFrameMeta);
|
||||
|
||||
boolean control = Boolean.parseBoolean(args[9]);
|
||||
options.setControl(control);
|
||||
|
||||
int displayId = Integer.parseInt(args[10]);
|
||||
options.setDisplayId(displayId);
|
||||
videoSettings.setDisplayId(displayId);
|
||||
|
||||
boolean showTouches = Boolean.parseBoolean(args[11]);
|
||||
options.setShowTouches(showTouches);
|
||||
|
@ -181,14 +88,13 @@ public final class Server {
|
|||
|
||||
String codecOptions = args[13];
|
||||
options.setCodecOptions(codecOptions);
|
||||
videoSettings.setCodecOptions(codecOptions);
|
||||
|
||||
String encoderName = "-".equals(args[14]) ? null : args[14];
|
||||
options.setEncoderName(encoderName);
|
||||
videoSettings.setEncoderName(encoderName);
|
||||
|
||||
boolean powerOffScreenOnClose = Boolean.parseBoolean(args[15]);
|
||||
options.setPowerOffScreenOnClose(powerOffScreenOnClose);
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
private static Rect parseCrop(String crop) {
|
||||
|
@ -248,10 +154,16 @@ public final class Server {
|
|||
}
|
||||
});
|
||||
|
||||
Options options = createOptions(args);
|
||||
|
||||
Options options = new Options();
|
||||
VideoSettings videoSettings = new VideoSettings();
|
||||
parseArguments(options, videoSettings, args);
|
||||
Ln.initLogLevel(options.getLogLevel());
|
||||
|
||||
scrcpy(options);
|
||||
if (options.getServerType() == Options.TYPE_LOCAL_SOCKET) {
|
||||
new DesktopConnection(options, videoSettings);
|
||||
} else if (options.getServerType() == Options.TYPE_WEB_SOCKET) {
|
||||
WSServer wsServer = new WSServer(options);
|
||||
wsServer.setReuseAddr(true);
|
||||
wsServer.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
290
server/src/main/java/com/genymobile/scrcpy/VideoSettings.java
Normal file
290
server/src/main/java/com/genymobile/scrcpy/VideoSettings.java
Normal file
|
@ -0,0 +1,290 @@
|
|||
package com.genymobile.scrcpy;
|
||||
|
||||
import android.graphics.Rect;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public class VideoSettings {
|
||||
private static final int DEFAULT_BIT_RATE = 8000000;
|
||||
private static final byte DEFAULT_MAX_FPS = 60;
|
||||
private static final byte DEFAULT_I_FRAME_INTERVAL = 10; // seconds
|
||||
|
||||
private Size bounds;
|
||||
private int bitRate = DEFAULT_BIT_RATE;
|
||||
private int maxFps;
|
||||
private int lockedVideoOrientation;
|
||||
private byte iFrameInterval = DEFAULT_I_FRAME_INTERVAL;
|
||||
private Rect crop;
|
||||
private boolean sendFrameMeta; // send PTS so that the client may record properly
|
||||
private int displayId;
|
||||
private String codecOptionsString;
|
||||
private List<CodecOption> codecOptions;
|
||||
private String encoderName;
|
||||
|
||||
public int getBitRate() {
|
||||
return bitRate;
|
||||
}
|
||||
|
||||
public void setBitRate(int bitRate) {
|
||||
this.bitRate = bitRate;
|
||||
}
|
||||
|
||||
public int getIFrameInterval() {
|
||||
return iFrameInterval;
|
||||
}
|
||||
|
||||
public void setIFrameInterval(byte iFrameInterval) {
|
||||
this.iFrameInterval = iFrameInterval;
|
||||
};
|
||||
|
||||
public Rect getCrop() {
|
||||
return crop;
|
||||
}
|
||||
|
||||
public void setCrop(Rect crop) {
|
||||
this.crop = crop;
|
||||
}
|
||||
|
||||
public boolean getSendFrameMeta() {
|
||||
return sendFrameMeta;
|
||||
}
|
||||
|
||||
public void setSendFrameMeta(boolean sendFrameMeta) {
|
||||
this.sendFrameMeta = sendFrameMeta;
|
||||
}
|
||||
|
||||
public int getDisplayId() {
|
||||
return displayId;
|
||||
}
|
||||
|
||||
public void setDisplayId(int displayId) {
|
||||
this.displayId = displayId;
|
||||
}
|
||||
|
||||
public int getMaxFps() {
|
||||
return maxFps;
|
||||
}
|
||||
|
||||
public void setMaxFps(int maxFps) {
|
||||
this.maxFps = maxFps;
|
||||
}
|
||||
|
||||
public int getLockedVideoOrientation() {
|
||||
return lockedVideoOrientation;
|
||||
}
|
||||
|
||||
public void setLockedVideoOrientation(int lockedVideoOrientation) {
|
||||
this.lockedVideoOrientation = lockedVideoOrientation;
|
||||
}
|
||||
|
||||
public Size getBounds() {
|
||||
return bounds;
|
||||
}
|
||||
|
||||
public void setBounds(Size bounds) {
|
||||
this.bounds = bounds;
|
||||
}
|
||||
|
||||
public void setBounds(int width, int height) {
|
||||
this.bounds = new Size(width & ~15, height & ~15); // multiple of 16
|
||||
}
|
||||
|
||||
public List<CodecOption> getCodecOptions() {
|
||||
return codecOptions;
|
||||
}
|
||||
|
||||
public void setCodecOptions(String codecOptionsString) {
|
||||
this.codecOptions = CodecOption.parse(codecOptionsString);
|
||||
if (codecOptionsString.equals("-")) {
|
||||
this.codecOptionsString = null;
|
||||
} else {
|
||||
this.codecOptionsString = codecOptionsString;
|
||||
}
|
||||
}
|
||||
|
||||
public String getEncoderName() {
|
||||
return this.encoderName;
|
||||
}
|
||||
|
||||
public void setEncoderName(String encoderName) {
|
||||
if (encoderName != null && encoderName.equals("-")) {
|
||||
this.encoderName = null;
|
||||
} else {
|
||||
this.encoderName = encoderName;
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] toByteArray() {
|
||||
// 35 bytes without codec options and encoder name
|
||||
int baseLength = 35;
|
||||
int additionalLength = 0;
|
||||
byte[] codeOptionsBytes = new byte[]{};
|
||||
if (this.codecOptionsString != null) {
|
||||
codeOptionsBytes = this.codecOptionsString.getBytes(StandardCharsets.UTF_8);
|
||||
additionalLength += codeOptionsBytes.length;
|
||||
}
|
||||
byte[] encoderNameBytes = new byte[]{};
|
||||
if (this.encoderName != null) {
|
||||
encoderNameBytes = this.encoderName.getBytes(StandardCharsets.UTF_8);
|
||||
additionalLength += encoderNameBytes.length;
|
||||
}
|
||||
ByteBuffer temp = ByteBuffer.allocate(baseLength + additionalLength);
|
||||
temp.putInt(bitRate);
|
||||
temp.putInt(maxFps);
|
||||
temp.put(iFrameInterval);
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
if (bounds != null) {
|
||||
width = bounds.getWidth();
|
||||
height = bounds.getHeight();
|
||||
}
|
||||
temp.putShort((short) width);
|
||||
temp.putShort((short) height);
|
||||
int left = 0;
|
||||
int top = 0;
|
||||
int right = 0;
|
||||
int bottom = 0;
|
||||
if (crop != null) {
|
||||
left = crop.left;
|
||||
top = crop.top;
|
||||
right = crop.right;
|
||||
bottom = crop.bottom;
|
||||
}
|
||||
temp.putShort((short) left);
|
||||
temp.putShort((short) top);
|
||||
temp.putShort((short) right);
|
||||
temp.putShort((short) bottom);
|
||||
temp.put((byte) (sendFrameMeta ? 1 : 0));
|
||||
temp.put((byte) lockedVideoOrientation);
|
||||
temp.putInt(displayId);
|
||||
temp.putInt(codeOptionsBytes.length);
|
||||
if (codeOptionsBytes.length != 0) {
|
||||
temp.put(codeOptionsBytes);
|
||||
}
|
||||
temp.putInt(encoderNameBytes.length);
|
||||
if (encoderNameBytes.length != 0) {
|
||||
temp.put(encoderNameBytes);
|
||||
}
|
||||
return temp.array();
|
||||
}
|
||||
|
||||
public void merge(VideoSettings source) {
|
||||
codecOptions = source.codecOptions;
|
||||
codecOptionsString = source.codecOptionsString;
|
||||
encoderName = source.encoderName;
|
||||
bitRate = source.bitRate;
|
||||
maxFps = source.maxFps;
|
||||
iFrameInterval = source.iFrameInterval;
|
||||
bounds = source.bounds;
|
||||
crop = source.crop;
|
||||
sendFrameMeta = source.sendFrameMeta;
|
||||
lockedVideoOrientation = source.lockedVideoOrientation;
|
||||
displayId = source.displayId;
|
||||
}
|
||||
|
||||
public static VideoSettings fromByteArray(byte[] bytes) {
|
||||
VideoSettings videoSettings = new VideoSettings();
|
||||
mergeFromByteArray(videoSettings, bytes);
|
||||
return videoSettings;
|
||||
}
|
||||
|
||||
public static void mergeFromByteArray(VideoSettings videoSettings, byte[] bytes) {
|
||||
ByteBuffer data = ByteBuffer.wrap(bytes);
|
||||
int bitRate = data.getInt();
|
||||
int maxFps = data.getInt();
|
||||
byte iFrameInterval = data.get();
|
||||
int width = data.getShort();
|
||||
int height = data.getShort();
|
||||
int left = data.getShort();
|
||||
int top = data.getShort();
|
||||
int right = data.getShort();
|
||||
int bottom = data.getShort();
|
||||
boolean sendMetaFrame = data.get() != 0;
|
||||
int lockedVideoOrientation = data.get();
|
||||
int displayId = data.getInt();
|
||||
if (data.remaining() > 0) {
|
||||
int codecOptionsLength = data.getInt();
|
||||
if (codecOptionsLength > 0) {
|
||||
byte[] textBuffer = new byte[codecOptionsLength];
|
||||
data.get(textBuffer, 0, codecOptionsLength);
|
||||
String codecOptions = new String(textBuffer, 0, codecOptionsLength, StandardCharsets.UTF_8);
|
||||
if (!codecOptions.isEmpty()) {
|
||||
videoSettings.setCodecOptions(codecOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data.remaining() > 0) {
|
||||
int encoderNameLength = data.getInt();
|
||||
if (encoderNameLength > 0) {
|
||||
byte[] textBuffer = new byte[encoderNameLength];
|
||||
data.get(textBuffer, 0, encoderNameLength);
|
||||
String encoderName = new String(textBuffer, 0, encoderNameLength, StandardCharsets.UTF_8);
|
||||
if (!encoderName.isEmpty()) {
|
||||
videoSettings.setEncoderName(encoderName);
|
||||
}
|
||||
}
|
||||
}
|
||||
videoSettings.setBitRate(bitRate);
|
||||
videoSettings.setMaxFps(maxFps);
|
||||
videoSettings.setIFrameInterval(iFrameInterval);
|
||||
videoSettings.setBounds(width, height);
|
||||
if (left == 0 && right == 0 && top == 0 && bottom == 0) {
|
||||
videoSettings.setCrop(null);
|
||||
} else {
|
||||
videoSettings.setCrop(new Rect(left, top, right, bottom));
|
||||
}
|
||||
videoSettings.setSendFrameMeta(sendMetaFrame);
|
||||
videoSettings.setLockedVideoOrientation(lockedVideoOrientation);
|
||||
if (displayId > 0) {
|
||||
videoSettings.setDisplayId(displayId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (o == null || getClass() != o.getClass()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
VideoSettings s = (VideoSettings) o;
|
||||
if (bitRate != s.bitRate || maxFps != s.maxFps || lockedVideoOrientation != s.lockedVideoOrientation || iFrameInterval != s.iFrameInterval
|
||||
|| sendFrameMeta != s.sendFrameMeta || displayId != s.displayId) {
|
||||
return false;
|
||||
}
|
||||
if (!Objects.equals(codecOptionsString, s.codecOptionsString) || !Objects.equals(encoderName, s.encoderName)
|
||||
|| !Objects.equals(bounds, s.bounds) || !Objects.equals(crop, s.crop)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(bitRate, maxFps, lockedVideoOrientation, iFrameInterval, sendFrameMeta,
|
||||
displayId, Objects.hashCode(codecOptionsString), Objects.hashCode(encoderName),
|
||||
Objects.hashCode(bounds), Objects.hashCode(crop));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "VideoSettings{"
|
||||
+ "bitRate=" + bitRate
|
||||
+ ", maxFps=" + maxFps
|
||||
+ ", iFrameInterval=" + iFrameInterval
|
||||
+ ", bounds=" + bounds
|
||||
+ ", crop=" + crop
|
||||
+ ", metaFrame=" + sendFrameMeta
|
||||
+ ", lockedVideoOrientation=" + lockedVideoOrientation
|
||||
+ ", displayId=" + displayId
|
||||
+ ", codecOptions=" + (this.codecOptionsString == null ? "-" : this.codecOptionsString)
|
||||
+ ", encoderName=" + (this.encoderName == null ? "-" : this.encoderName)
|
||||
+ "}";
|
||||
}
|
||||
|
||||
}
|
217
server/src/main/java/com/genymobile/scrcpy/WSServer.java
Normal file
217
server/src/main/java/com/genymobile/scrcpy/WSServer.java
Normal file
|
@ -0,0 +1,217 @@
|
|||
package com.genymobile.scrcpy;
|
||||
|
||||
import org.java_websocket.WebSocket;
|
||||
import org.java_websocket.framing.CloseFrame;
|
||||
import org.java_websocket.handshake.ClientHandshake;
|
||||
import org.java_websocket.server.WebSocketServer;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.BindException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
|
||||
public class WSServer extends WebSocketServer {
|
||||
private static final String PID_FILE_PATH = "/data/local/tmp/ws_scrcpy.pid";
|
||||
public static final class SocketInfo {
|
||||
private static final HashSet<Short> INSTANCES_BY_ID = new HashSet<>();
|
||||
private final short id;
|
||||
private WebSocketConnection connection;
|
||||
|
||||
SocketInfo(short id) {
|
||||
this.id = id;
|
||||
INSTANCES_BY_ID.add(id);
|
||||
}
|
||||
|
||||
public static short getNextClientId() {
|
||||
short nextClientId = 0;
|
||||
while (INSTANCES_BY_ID.contains(++nextClientId)) {
|
||||
if (nextClientId == Short.MAX_VALUE) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
return nextClientId;
|
||||
}
|
||||
|
||||
public short getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public WebSocketConnection getConnection() {
|
||||
return this.connection;
|
||||
}
|
||||
|
||||
public void setConnection(WebSocketConnection connection) {
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
public void release() {
|
||||
INSTANCES_BY_ID.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
protected final ControlMessageReader reader = new ControlMessageReader();
|
||||
private final Options options;
|
||||
private static final HashMap<Integer, WebSocketConnection> STREAM_BY_DISPLAY_ID = new HashMap<>();
|
||||
|
||||
public WSServer(Options options) {
|
||||
super(new InetSocketAddress(options.getListenOnAllInterfaces() ? "0.0.0.0" : "127.0.0.1", options.getPortNumber()));
|
||||
this.options = options;
|
||||
unlinkPidFile();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onOpen(WebSocket webSocket, ClientHandshake handshake) {
|
||||
if (webSocket.isOpen()) {
|
||||
short clientId = SocketInfo.getNextClientId();
|
||||
if (clientId == -1) {
|
||||
webSocket.close(CloseFrame.TRY_AGAIN_LATER);
|
||||
return;
|
||||
}
|
||||
SocketInfo info = new SocketInfo(clientId);
|
||||
webSocket.setAttachment(info);
|
||||
WebSocketConnection.sendInitialInfo(WebSocketConnection.getInitialInfo(), webSocket, clientId);
|
||||
Ln.d("Client entered the room!");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClose(WebSocket webSocket, int code, String reason, boolean remote) {
|
||||
Ln.d("Client has left the room!");
|
||||
FilePushHandler.cancelAllForConnection(webSocket);
|
||||
SocketInfo socketInfo = webSocket.getAttachment();
|
||||
if (socketInfo != null) {
|
||||
WebSocketConnection connection = socketInfo.getConnection();
|
||||
if (connection != null) {
|
||||
connection.leave(webSocket);
|
||||
}
|
||||
socketInfo.release();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(WebSocket webSocket, String message) {
|
||||
String address = webSocket.getRemoteSocketAddress().getAddress().getHostAddress();
|
||||
Ln.w("? Client from " + address + " says: \"" + message + "\"");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(WebSocket webSocket, ByteBuffer message) {
|
||||
SocketInfo socketInfo = webSocket.getAttachment();
|
||||
if (socketInfo == null) {
|
||||
Ln.e("No info attached to connection");
|
||||
return;
|
||||
}
|
||||
WebSocketConnection connection = socketInfo.getConnection();
|
||||
String address = webSocket.getRemoteSocketAddress().getAddress().getHostAddress();
|
||||
ControlMessage controlMessage = reader.parseEvent(message);
|
||||
if (controlMessage != null) {
|
||||
if (controlMessage.getType() == ControlMessage.TYPE_PUSH_FILE) {
|
||||
FilePushHandler.handlePush(webSocket, controlMessage);
|
||||
return;
|
||||
}
|
||||
if (controlMessage.getType() == ControlMessage.TYPE_CHANGE_STREAM_PARAMETERS) {
|
||||
VideoSettings videoSettings = controlMessage.getVideoSettings();
|
||||
int displayId = videoSettings.getDisplayId();
|
||||
if (connection != null) {
|
||||
if (connection.getVideoSettings().getDisplayId() != displayId) {
|
||||
connection.leave(webSocket);
|
||||
}
|
||||
}
|
||||
joinStreamForDisplayId(webSocket, videoSettings, options, displayId, this);
|
||||
return;
|
||||
}
|
||||
if (connection != null) {
|
||||
Controller controller = connection.getController();
|
||||
controller.handleEvent(controlMessage);
|
||||
}
|
||||
} else {
|
||||
Ln.w("? Client from " + address + " sends bytes: " + message);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(WebSocket webSocket, Exception ex) {
|
||||
Ln.e("WebSocket error", ex);
|
||||
if (webSocket != null) {
|
||||
// some errors like port binding failed may not be assignable to a specific websocket
|
||||
FilePushHandler.cancelAllForConnection(webSocket);
|
||||
}
|
||||
if (ex instanceof BindException) {
|
||||
System.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStart() {
|
||||
Ln.d("Server started! " + this.getAddress().toString());
|
||||
this.setConnectionLostTimeout(0);
|
||||
this.setConnectionLostTimeout(100);
|
||||
writePidFile();
|
||||
}
|
||||
|
||||
private static void joinStreamForDisplayId(
|
||||
WebSocket webSocket, VideoSettings videoSettings, Options options, int displayId, WSServer wsServer) {
|
||||
SocketInfo socketInfo = webSocket.getAttachment();
|
||||
WebSocketConnection connection = STREAM_BY_DISPLAY_ID.get(displayId);
|
||||
if (connection == null) {
|
||||
connection = new WebSocketConnection(options, videoSettings, wsServer);
|
||||
STREAM_BY_DISPLAY_ID.put(displayId, connection);
|
||||
}
|
||||
socketInfo.setConnection(connection);
|
||||
connection.join(webSocket, videoSettings);
|
||||
}
|
||||
|
||||
private static void unlinkPidFile() {
|
||||
try {
|
||||
File pidFile = new File(PID_FILE_PATH);
|
||||
if (pidFile.exists()) {
|
||||
if (!pidFile.delete()) {
|
||||
Ln.e("Failed to delete PID file");
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Ln.e("Failed to delete PID file:", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void writePidFile() {
|
||||
File file = new File(PID_FILE_PATH);
|
||||
FileOutputStream stream;
|
||||
try {
|
||||
stream = new FileOutputStream(file, false);
|
||||
stream.write(Integer.toString(android.os.Process.myPid()).getBytes(StandardCharsets.UTF_8));
|
||||
stream.close();
|
||||
} catch (IOException e) {
|
||||
Ln.e(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public static WebSocketConnection getConnectionForDisplay(int displayId) {
|
||||
return STREAM_BY_DISPLAY_ID.get(displayId);
|
||||
}
|
||||
|
||||
public static void releaseConnectionForDisplay(int displayId) {
|
||||
STREAM_BY_DISPLAY_ID.remove(displayId);
|
||||
}
|
||||
|
||||
public void sendInitialInfoToAll() {
|
||||
Collection<WebSocket> webSockets = this.getConnections();
|
||||
if (webSockets.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
ByteBuffer initialInfo = WebSocketConnection.getInitialInfo();
|
||||
for (WebSocket webSocket : webSockets) {
|
||||
SocketInfo socketInfo = webSocket.getAttachment();
|
||||
if (socketInfo == null) {
|
||||
continue;
|
||||
}
|
||||
WebSocketConnection.sendInitialInfo(initialInfo, webSocket, socketInfo.getId());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,209 @@
|
|||
package com.genymobile.scrcpy;
|
||||
|
||||
import android.media.MediaCodecInfo;
|
||||
|
||||
import org.java_websocket.WebSocket;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
||||
public class WebSocketConnection extends Connection {
|
||||
private static final byte[] MAGIC_BYTES_INITIAL = "scrcpy_initial".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] MAGIC_BYTES_MESSAGE = "scrcpy_message".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] DEVICE_NAME_BYTES = Device.getDeviceName().getBytes(StandardCharsets.UTF_8);
|
||||
private final WSServer wsServer;
|
||||
private final HashSet<WebSocket> sockets = new HashSet<>();
|
||||
private ScreenEncoder screenEncoder;
|
||||
|
||||
public WebSocketConnection(Options options, VideoSettings videoSettings, WSServer wsServer) {
|
||||
super(options, videoSettings);
|
||||
this.wsServer = wsServer;
|
||||
}
|
||||
|
||||
public void join(WebSocket webSocket, VideoSettings videoSettings) {
|
||||
sockets.add(webSocket);
|
||||
boolean changed = setVideoSettings(videoSettings);
|
||||
wsServer.sendInitialInfoToAll();
|
||||
if (!Device.isScreenOn()) {
|
||||
controller.turnScreenOn();
|
||||
}
|
||||
if (screenEncoder == null || !screenEncoder.isAlive()) {
|
||||
Ln.d("First connection. Start new encoder.");
|
||||
device.setRotationListener(this);
|
||||
screenEncoder = new ScreenEncoder(videoSettings);
|
||||
screenEncoder.start(device, this);
|
||||
} else {
|
||||
if (!changed) {
|
||||
if (this.streamInvalidateListener != null) {
|
||||
streamInvalidateListener.onStreamInvalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void leave(WebSocket webSocket) {
|
||||
sockets.remove(webSocket);
|
||||
if (sockets.isEmpty()) {
|
||||
Ln.d("Last client has left");
|
||||
this.release();
|
||||
}
|
||||
wsServer.sendInitialInfoToAll();
|
||||
}
|
||||
|
||||
public static ByteBuffer deviceMessageToByteBuffer(DeviceMessage msg) {
|
||||
ByteBuffer buffer = ByteBuffer.wrap(msg.writeToByteArray(MAGIC_BYTES_MESSAGE.length));
|
||||
buffer.put(MAGIC_BYTES_MESSAGE);
|
||||
buffer.rewind();
|
||||
return buffer;
|
||||
}
|
||||
|
||||
@Override
|
||||
void send(ByteBuffer data) {
|
||||
if (sockets.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
synchronized (sockets) {
|
||||
for (WebSocket webSocket : sockets) {
|
||||
WSServer.SocketInfo info = webSocket.getAttachment();
|
||||
if (!webSocket.isOpen() || info == null) {
|
||||
continue;
|
||||
}
|
||||
webSocket.send(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void sendInitialInfo(ByteBuffer initialInfo, WebSocket webSocket, int clientId) {
|
||||
initialInfo.position(initialInfo.capacity() - 4);
|
||||
initialInfo.putInt(clientId);
|
||||
initialInfo.rewind();
|
||||
webSocket.send(initialInfo);
|
||||
}
|
||||
|
||||
public void sendDeviceMessage(DeviceMessage msg) {
|
||||
ByteBuffer buffer = deviceMessageToByteBuffer(msg);
|
||||
send(buffer);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasConnections() {
|
||||
return sockets.size() > 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
// wsServer.stop();
|
||||
}
|
||||
|
||||
public VideoSettings getVideoSettings() {
|
||||
return videoSettings;
|
||||
}
|
||||
|
||||
public Controller getController() {
|
||||
return controller;
|
||||
}
|
||||
|
||||
public Device getDevice() {
|
||||
return device;
|
||||
}
|
||||
|
||||
@SuppressWarnings("checkstyle:MagicNumber")
|
||||
public static ByteBuffer getInitialInfo() {
|
||||
int baseLength = MAGIC_BYTES_INITIAL.length
|
||||
+ DEVICE_NAME_FIELD_LENGTH
|
||||
+ 4 // displays count
|
||||
+ 4; // client id
|
||||
int additionalLength = 0;
|
||||
int[] displayIds = Device.getDisplayIds();
|
||||
List<DisplayInfo> displayInfoList = new ArrayList<>();
|
||||
HashMap<Integer, Integer> connectionsCount = new HashMap<>();
|
||||
HashMap<Integer, byte[]> displayInfoMap = new HashMap<>();
|
||||
HashMap<Integer, byte[]> videoSettingsBytesMap = new HashMap<>();
|
||||
HashMap<Integer, byte[]> screenInfoBytesMap = new HashMap<>();
|
||||
|
||||
for (int displayId : displayIds) {
|
||||
DisplayInfo displayInfo = Device.getDisplayInfo(displayId);
|
||||
displayInfoList.add(displayId, displayInfo);
|
||||
byte[] displayInfoBytes = displayInfo.toByteArray();
|
||||
additionalLength += displayInfoBytes.length;
|
||||
displayInfoMap.put(displayId, displayInfoBytes);
|
||||
WebSocketConnection connection = WSServer.getConnectionForDisplay(displayId);
|
||||
additionalLength += 4; // for connection.connections.size()
|
||||
additionalLength += 4; // for screenInfoBytes.length
|
||||
additionalLength += 4; // for videoSettingsBytes.length
|
||||
if (connection != null) {
|
||||
connectionsCount.put(displayId, connection.sockets.size());
|
||||
byte[] screenInfoBytes = connection.getDevice().getScreenInfo().toByteArray();
|
||||
additionalLength += screenInfoBytes.length;
|
||||
screenInfoBytesMap.put(displayId, screenInfoBytes);
|
||||
byte[] videoSettingsBytes = connection.getVideoSettings().toByteArray();
|
||||
additionalLength += videoSettingsBytes.length;
|
||||
videoSettingsBytesMap.put(displayId, videoSettingsBytes);
|
||||
}
|
||||
}
|
||||
|
||||
MediaCodecInfo[] encoders = ScreenEncoder.listEncoders();
|
||||
List<byte[]> encodersNames = new ArrayList<>();
|
||||
if (encoders != null && encoders.length > 0) {
|
||||
additionalLength += 4;
|
||||
for (MediaCodecInfo encoder : encoders) {
|
||||
byte[] nameBytes = encoder.getName().getBytes(StandardCharsets.UTF_8);
|
||||
additionalLength += 4 + nameBytes.length;
|
||||
encodersNames.add(nameBytes);
|
||||
}
|
||||
}
|
||||
|
||||
byte[] fullBytes = new byte[baseLength + additionalLength];
|
||||
ByteBuffer initialInfo = ByteBuffer.wrap(fullBytes);
|
||||
initialInfo.put(MAGIC_BYTES_INITIAL);
|
||||
initialInfo.put(DEVICE_NAME_BYTES, 0, Math.min(DEVICE_NAME_FIELD_LENGTH - 1, DEVICE_NAME_BYTES.length));
|
||||
initialInfo.position(MAGIC_BYTES_INITIAL.length + DEVICE_NAME_FIELD_LENGTH);
|
||||
initialInfo.putInt(displayIds.length);
|
||||
for (DisplayInfo displayInfo : displayInfoList) {
|
||||
int displayId = displayInfo.getDisplayId();
|
||||
if (displayInfoMap.containsKey(displayId)) {
|
||||
initialInfo.put(displayInfoMap.get(displayId));
|
||||
}
|
||||
int count = 0;
|
||||
if (connectionsCount.containsKey(displayId)) {
|
||||
count = connectionsCount.get(displayId);
|
||||
}
|
||||
initialInfo.putInt(count);
|
||||
if (screenInfoBytesMap.containsKey(displayId)) {
|
||||
byte[] screenInfo = screenInfoBytesMap.get(displayId);
|
||||
initialInfo.putInt(screenInfo.length);
|
||||
initialInfo.put(screenInfo);
|
||||
} else {
|
||||
initialInfo.putInt(0);
|
||||
}
|
||||
if (videoSettingsBytesMap.containsKey(displayId)) {
|
||||
byte[] videoSettings = videoSettingsBytesMap.get(displayId);
|
||||
initialInfo.putInt(videoSettings.length);
|
||||
initialInfo.put(videoSettings);
|
||||
} else {
|
||||
initialInfo.putInt(0);
|
||||
}
|
||||
}
|
||||
initialInfo.putInt(encodersNames.size());
|
||||
for (byte[] encoderNameBytes : encodersNames) {
|
||||
initialInfo.putInt(encoderNameBytes.length);
|
||||
initialInfo.put(encoderNameBytes);
|
||||
}
|
||||
|
||||
return initialInfo;
|
||||
}
|
||||
|
||||
public void onRotationChanged(int rotation) {
|
||||
super.onRotationChanged(rotation);
|
||||
wsServer.sendInitialInfoToAll();
|
||||
}
|
||||
|
||||
private void release() {
|
||||
WSServer.releaseConnectionForDisplay(this.videoSettings.getDisplayId());
|
||||
// encoder will stop itself after checking .hasConnections()
|
||||
}
|
||||
}
|
|
@ -12,6 +12,7 @@ import java.lang.reflect.Field;
|
|||
import java.lang.reflect.Method;
|
||||
|
||||
public final class Workarounds {
|
||||
private static boolean looperPrepared = false;
|
||||
private Workarounds() {
|
||||
// not instantiable
|
||||
}
|
||||
|
@ -26,6 +27,10 @@ public final class Workarounds {
|
|||
// "Attempt to read from field 'android.os.MessageQueue android.os.Looper.mQueue'
|
||||
// on a null object reference"
|
||||
// <https://github.com/Genymobile/scrcpy/issues/921>
|
||||
if (looperPrepared) {
|
||||
return;
|
||||
}
|
||||
looperPrepared = true;
|
||||
Looper.prepareMainLooper();
|
||||
}
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@ public class ClipboardManager {
|
|||
private Method getPrimaryClipMethod;
|
||||
private Method setPrimaryClipMethod;
|
||||
private Method addPrimaryClipChangedListener;
|
||||
private Method removePrimaryClipChangedListener;
|
||||
|
||||
public ClipboardManager(IInterface manager) {
|
||||
this.manager = manager;
|
||||
|
@ -116,4 +117,39 @@ public class ClipboardManager {
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static void removePrimaryClipChangedListener(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 getRemovePrimaryClipChangedListener() throws NoSuchMethodException {
|
||||
if (removePrimaryClipChangedListener == null) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
removePrimaryClipChangedListener = manager.getClass()
|
||||
.getMethod("removePrimaryClipChangedListener", IOnPrimaryClipChangedListener.class, String.class);
|
||||
} else {
|
||||
removePrimaryClipChangedListener = manager.getClass()
|
||||
.getMethod("removePrimaryClipChangedListener", IOnPrimaryClipChangedListener.class, String.class, int.class);
|
||||
}
|
||||
}
|
||||
return removePrimaryClipChangedListener;
|
||||
}
|
||||
|
||||
public boolean removePrimaryClipChangedListener(IOnPrimaryClipChangedListener listener) {
|
||||
try {
|
||||
Method method = getRemovePrimaryClipChangedListener();
|
||||
removePrimaryClipChangedListener(method, manager, listener);
|
||||
return true;
|
||||
} catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) {
|
||||
Ln.e("Could not invoke method", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ public final class WindowManager {
|
|||
private Method freezeRotationMethod;
|
||||
private Method isRotationFrozenMethod;
|
||||
private Method thawRotationMethod;
|
||||
private Method removeRotationWatcherMethod;
|
||||
|
||||
public WindowManager(IInterface manager) {
|
||||
this.manager = manager;
|
||||
|
@ -55,6 +56,13 @@ public final class WindowManager {
|
|||
return thawRotationMethod;
|
||||
}
|
||||
|
||||
private Method getRemoveRotationWatcherMethod() throws NoSuchMethodException {
|
||||
if (removeRotationWatcherMethod == null) {
|
||||
removeRotationWatcherMethod = manager.getClass().getMethod("removeRotationWatcher");
|
||||
}
|
||||
return removeRotationWatcherMethod;
|
||||
}
|
||||
|
||||
public int getRotation() {
|
||||
try {
|
||||
Method method = getGetRotationMethod();
|
||||
|
@ -108,4 +116,13 @@ public final class WindowManager {
|
|||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public void unregisterRotationWatcher(IRotationWatcher rotationWatcher) {
|
||||
try {
|
||||
Method method = getRemoveRotationWatcherMethod();
|
||||
method.invoke(manager, rotationWatcher);
|
||||
} catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) {
|
||||
Ln.e("Could not invoke method", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue