This commit is contained in:
netwdev 2025-04-19 11:41:24 +02:00 committed by GitHub
commit e3b42ba622
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 311 additions and 222 deletions

View file

@ -25,6 +25,7 @@ android {
dependencies {
testImplementation 'junit:junit:4.13.2'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.19.0-rc2'
}
apply from: "$project.rootDir/config/android-checkstyle.gradle"

View file

@ -79,6 +79,7 @@ public class Options {
private boolean sendFrameMeta = true; // send PTS so that the client may record properly
private boolean sendDummyByte = true; // write a byte on start to detect connection issues
private boolean sendCodecMeta = true; // write the codec metadata before the stream
private boolean netArgs = false;
public Ln.Level getLogLevel() {
return logLevel;
@ -288,6 +289,223 @@ public class Options {
return sendCodecMeta;
}
public boolean getEnableNetworkArgs() {
return netArgs;
}
private void parseKeyValue(String key, String value) {
switch (key) {
case "scid":
int scid = Integer.parseInt(value, 0x10);
if (scid < -1) {
throw new IllegalArgumentException("scid may not be negative (except -1 for 'none'): " + scid);
}
this.scid = scid;
break;
case "log_level":
this.logLevel = Ln.Level.valueOf(value.toUpperCase(Locale.ENGLISH));
break;
case "video":
this.video = Boolean.parseBoolean(value);
break;
case "audio":
this.audio = Boolean.parseBoolean(value);
break;
case "video_codec":
VideoCodec videoCodec = VideoCodec.findByName(value);
if (videoCodec == null) {
throw new IllegalArgumentException("Video codec " + value + " not supported");
}
this.videoCodec = videoCodec;
break;
case "audio_codec":
AudioCodec audioCodec = AudioCodec.findByName(value);
if (audioCodec == null) {
throw new IllegalArgumentException("Audio codec " + value + " not supported");
}
this.audioCodec = audioCodec;
break;
case "video_source":
VideoSource videoSource = VideoSource.findByName(value);
if (videoSource == null) {
throw new IllegalArgumentException("Video source " + value + " not supported");
}
this.videoSource = videoSource;
break;
case "audio_source":
AudioSource audioSource = AudioSource.findByName(value);
if (audioSource == null) {
throw new IllegalArgumentException("Audio source " + value + " not supported");
}
this.audioSource = audioSource;
break;
case "audio_dup":
this.audioDup = Boolean.parseBoolean(value);
break;
case "max_size":
this.maxSize = Integer.parseInt(value) & ~7; // multiple of 8
break;
case "video_bit_rate":
this.videoBitRate = Integer.parseInt(value);
break;
case "audio_bit_rate":
this.audioBitRate = Integer.parseInt(value);
break;
case "max_fps":
this.maxFps = parseFloat("max_fps", value);
break;
case "angle":
this.angle = parseFloat("angle", value);
break;
case "tunnel_forward":
this.tunnelForward = Boolean.parseBoolean(value);
break;
case "crop":
if (!value.isEmpty()) {
this.crop = parseCrop(value);
}
break;
case "control":
this.control = Boolean.parseBoolean(value);
break;
case "display_id":
this.displayId = Integer.parseInt(value);
break;
case "show_touches":
this.showTouches = Boolean.parseBoolean(value);
break;
case "stay_awake":
this.stayAwake = Boolean.parseBoolean(value);
break;
case "screen_off_timeout":
this.screenOffTimeout = Integer.parseInt(value);
if (this.screenOffTimeout < -1) {
throw new IllegalArgumentException("Invalid screen off timeout: " + this.screenOffTimeout);
}
break;
case "video_codec_options":
this.videoCodecOptions = CodecOption.parse(value);
break;
case "audio_codec_options":
this.audioCodecOptions = CodecOption.parse(value);
break;
case "video_encoder":
if (!value.isEmpty()) {
this.videoEncoder = value;
}
break;
case "audio_encoder":
if (!value.isEmpty()) {
this.audioEncoder = value;
}
break;
case "power_off_on_close":
this.powerOffScreenOnClose = Boolean.parseBoolean(value);
break;
case "clipboard_autosync":
this.clipboardAutosync = Boolean.parseBoolean(value);
break;
case "downsize_on_error":
this.downsizeOnError = Boolean.parseBoolean(value);
break;
case "cleanup":
this.cleanup = Boolean.parseBoolean(value);
break;
case "power_on":
this.powerOn = Boolean.parseBoolean(value);
break;
case "list_encoders":
this.listEncoders = Boolean.parseBoolean(value);
break;
case "list_displays":
this.listDisplays = Boolean.parseBoolean(value);
break;
case "list_cameras":
this.listCameras = Boolean.parseBoolean(value);
break;
case "list_camera_sizes":
this.listCameraSizes = Boolean.parseBoolean(value);
break;
case "list_apps":
this.listApps = Boolean.parseBoolean(value);
break;
case "camera_id":
if (!value.isEmpty()) {
this.cameraId = value;
}
break;
case "camera_size":
if (!value.isEmpty()) {
this.cameraSize = parseSize(value);
}
break;
case "camera_facing":
if (!value.isEmpty()) {
CameraFacing facing = CameraFacing.findByName(value);
if (facing == null) {
throw new IllegalArgumentException("Camera facing " + value + " not supported");
}
this.cameraFacing = facing;
}
break;
case "camera_ar":
if (!value.isEmpty()) {
this.cameraAspectRatio = parseCameraAspectRatio(value);
}
break;
case "camera_fps":
this.cameraFps = Integer.parseInt(value);
break;
case "camera_high_speed":
this.cameraHighSpeed = Boolean.parseBoolean(value);
break;
case "new_display":
this.newDisplay = parseNewDisplay(value);
break;
case "vd_destroy_content":
this.vdDestroyContent = Boolean.parseBoolean(value);
break;
case "vd_system_decorations":
this.vdSystemDecorations = Boolean.parseBoolean(value);
break;
case "capture_orientation":
Pair<Orientation.Lock, Orientation> pair = parseCaptureOrientation(value);
this.captureOrientationLock = pair.first;
this.captureOrientation = pair.second;
break;
case "display_ime_policy":
this.displayImePolicy = parseDisplayImePolicy(value);
break;
case "send_device_meta":
this.sendDeviceMeta = Boolean.parseBoolean(value);
break;
case "send_frame_meta":
this.sendFrameMeta = Boolean.parseBoolean(value);
break;
case "send_dummy_byte":
this.sendDummyByte = Boolean.parseBoolean(value);
break;
case "send_codec_meta":
this.sendCodecMeta = Boolean.parseBoolean(value);
break;
case "raw_stream":
boolean rawStream = Boolean.parseBoolean(value);
if (rawStream) {
this.sendDeviceMeta = false;
this.sendFrameMeta = false;
this.sendDummyByte = false;
this.sendCodecMeta = false;
}
break;
case "net_args":
this.netArgs = Boolean.parseBoolean(value);
break;
default:
Ln.w("Unknown server option: " + key);
break;
}
}
@SuppressWarnings("MethodLength")
public static Options parse(String... args) {
if (args.length < 1) {
@ -310,212 +528,7 @@ public class Options {
}
String key = arg.substring(0, equalIndex);
String value = arg.substring(equalIndex + 1);
switch (key) {
case "scid":
int scid = Integer.parseInt(value, 0x10);
if (scid < -1) {
throw new IllegalArgumentException("scid may not be negative (except -1 for 'none'): " + scid);
}
options.scid = scid;
break;
case "log_level":
options.logLevel = Ln.Level.valueOf(value.toUpperCase(Locale.ENGLISH));
break;
case "video":
options.video = Boolean.parseBoolean(value);
break;
case "audio":
options.audio = Boolean.parseBoolean(value);
break;
case "video_codec":
VideoCodec videoCodec = VideoCodec.findByName(value);
if (videoCodec == null) {
throw new IllegalArgumentException("Video codec " + value + " not supported");
}
options.videoCodec = videoCodec;
break;
case "audio_codec":
AudioCodec audioCodec = AudioCodec.findByName(value);
if (audioCodec == null) {
throw new IllegalArgumentException("Audio codec " + value + " not supported");
}
options.audioCodec = audioCodec;
break;
case "video_source":
VideoSource videoSource = VideoSource.findByName(value);
if (videoSource == null) {
throw new IllegalArgumentException("Video source " + value + " not supported");
}
options.videoSource = videoSource;
break;
case "audio_source":
AudioSource audioSource = AudioSource.findByName(value);
if (audioSource == null) {
throw new IllegalArgumentException("Audio source " + value + " not supported");
}
options.audioSource = audioSource;
break;
case "audio_dup":
options.audioDup = Boolean.parseBoolean(value);
break;
case "max_size":
options.maxSize = Integer.parseInt(value) & ~7; // multiple of 8
break;
case "video_bit_rate":
options.videoBitRate = Integer.parseInt(value);
break;
case "audio_bit_rate":
options.audioBitRate = Integer.parseInt(value);
break;
case "max_fps":
options.maxFps = parseFloat("max_fps", value);
break;
case "angle":
options.angle = parseFloat("angle", value);
break;
case "tunnel_forward":
options.tunnelForward = Boolean.parseBoolean(value);
break;
case "crop":
if (!value.isEmpty()) {
options.crop = parseCrop(value);
}
break;
case "control":
options.control = Boolean.parseBoolean(value);
break;
case "display_id":
options.displayId = Integer.parseInt(value);
break;
case "show_touches":
options.showTouches = Boolean.parseBoolean(value);
break;
case "stay_awake":
options.stayAwake = Boolean.parseBoolean(value);
break;
case "screen_off_timeout":
options.screenOffTimeout = Integer.parseInt(value);
if (options.screenOffTimeout < -1) {
throw new IllegalArgumentException("Invalid screen off timeout: " + options.screenOffTimeout);
}
break;
case "video_codec_options":
options.videoCodecOptions = CodecOption.parse(value);
break;
case "audio_codec_options":
options.audioCodecOptions = CodecOption.parse(value);
break;
case "video_encoder":
if (!value.isEmpty()) {
options.videoEncoder = value;
}
break;
case "audio_encoder":
if (!value.isEmpty()) {
options.audioEncoder = value;
}
case "power_off_on_close":
options.powerOffScreenOnClose = Boolean.parseBoolean(value);
break;
case "clipboard_autosync":
options.clipboardAutosync = Boolean.parseBoolean(value);
break;
case "downsize_on_error":
options.downsizeOnError = Boolean.parseBoolean(value);
break;
case "cleanup":
options.cleanup = Boolean.parseBoolean(value);
break;
case "power_on":
options.powerOn = Boolean.parseBoolean(value);
break;
case "list_encoders":
options.listEncoders = Boolean.parseBoolean(value);
break;
case "list_displays":
options.listDisplays = Boolean.parseBoolean(value);
break;
case "list_cameras":
options.listCameras = Boolean.parseBoolean(value);
break;
case "list_camera_sizes":
options.listCameraSizes = Boolean.parseBoolean(value);
break;
case "list_apps":
options.listApps = Boolean.parseBoolean(value);
break;
case "camera_id":
if (!value.isEmpty()) {
options.cameraId = value;
}
break;
case "camera_size":
if (!value.isEmpty()) {
options.cameraSize = parseSize(value);
}
break;
case "camera_facing":
if (!value.isEmpty()) {
CameraFacing facing = CameraFacing.findByName(value);
if (facing == null) {
throw new IllegalArgumentException("Camera facing " + value + " not supported");
}
options.cameraFacing = facing;
}
break;
case "camera_ar":
if (!value.isEmpty()) {
options.cameraAspectRatio = parseCameraAspectRatio(value);
}
break;
case "camera_fps":
options.cameraFps = Integer.parseInt(value);
break;
case "camera_high_speed":
options.cameraHighSpeed = Boolean.parseBoolean(value);
break;
case "new_display":
options.newDisplay = parseNewDisplay(value);
break;
case "vd_destroy_content":
options.vdDestroyContent = Boolean.parseBoolean(value);
break;
case "vd_system_decorations":
options.vdSystemDecorations = Boolean.parseBoolean(value);
break;
case "capture_orientation":
Pair<Orientation.Lock, Orientation> pair = parseCaptureOrientation(value);
options.captureOrientationLock = pair.first;
options.captureOrientation = pair.second;
break;
case "display_ime_policy":
options.displayImePolicy = parseDisplayImePolicy(value);
break;
case "send_device_meta":
options.sendDeviceMeta = Boolean.parseBoolean(value);
break;
case "send_frame_meta":
options.sendFrameMeta = Boolean.parseBoolean(value);
break;
case "send_dummy_byte":
options.sendDummyByte = Boolean.parseBoolean(value);
break;
case "send_codec_meta":
options.sendCodecMeta = Boolean.parseBoolean(value);
break;
case "raw_stream":
boolean rawStream = Boolean.parseBoolean(value);
if (rawStream) {
options.sendDeviceMeta = false;
options.sendFrameMeta = false;
options.sendDummyByte = false;
options.sendCodecMeta = false;
}
break;
default:
Ln.w("Unknown server option: " + key);
break;
}
options.parseKeyValue(key, value);
}
if (options.newDisplay != null) {
@ -526,6 +539,18 @@ public class Options {
return options;
}
public void parseAdditional(String... args) {
for (String arg : args) {
int equalIndex = arg.indexOf('=');
if (equalIndex == -1) {
throw new IllegalArgumentException("Invalid key=value pair: \"" + arg + "\"");
}
String key = arg.substring(0, equalIndex);
String value = arg.substring(equalIndex + 1);
parseKeyValue(key, value);
}
}
private static Rect parseCrop(String crop) {
// input format: "width:height:x:y"
String[] tokens = crop.split(":");

View file

@ -17,6 +17,7 @@ import com.genymobile.scrcpy.device.Streamer;
import com.genymobile.scrcpy.opengl.OpenGLRunner;
import com.genymobile.scrcpy.util.Ln;
import com.genymobile.scrcpy.util.LogUtils;
import com.genymobile.scrcpy.util.StringUtils;
import com.genymobile.scrcpy.video.CameraCapture;
import com.genymobile.scrcpy.video.NewDisplayCapture;
import com.genymobile.scrcpy.video.ScreenCapture;
@ -75,22 +76,6 @@ public final class Server {
}
private static void scrcpy(Options options) throws IOException, ConfigurationException {
if (Build.VERSION.SDK_INT < AndroidVersions.API_31_ANDROID_12 && options.getVideoSource() == VideoSource.CAMERA) {
Ln.e("Camera mirroring is not supported before Android 12");
throw new ConfigurationException("Camera mirroring is not supported");
}
if (Build.VERSION.SDK_INT < AndroidVersions.API_29_ANDROID_10) {
if (options.getNewDisplay() != null) {
Ln.e("New virtual display is not supported before Android 10");
throw new ConfigurationException("New virtual display is not supported");
}
if (options.getDisplayImePolicy() != -1) {
Ln.e("Display IME policy is not supported before Android 10");
throw new ConfigurationException("Display IME policy is not supported");
}
}
CleanUp cleanUp = null;
if (options.getCleanup()) {
@ -110,6 +95,36 @@ public final class Server {
DesktopConnection connection = DesktopConnection.open(scid, tunnelForward, video, audio, control, sendDummyByte);
try {
if (options.getEnableNetworkArgs()) {
Ln.d("Waiting for additional args (JSON) ...");
String additionalOptions = connection.receiveAdditionalOptions();
if (additionalOptions != null && !additionalOptions.isEmpty()) {
Ln.d("Received additional options: " + additionalOptions);
String args = StringUtils.jsonToArgs(additionalOptions);
Ln.d("Additional args: " + args);
options.parseAdditional(args.split(" "));
} else {
Ln.d("No additional args received.");
}
}
if (Build.VERSION.SDK_INT < AndroidVersions.API_31_ANDROID_12 && options.getVideoSource() == VideoSource.CAMERA) {
Ln.e("Camera mirroring is not supported before Android 12");
throw new ConfigurationException("Camera mirroring is not supported");
}
if (Build.VERSION.SDK_INT < AndroidVersions.API_29_ANDROID_10) {
if (options.getNewDisplay() != null) {
Ln.e("New virtual display is not supported before Android 10");
throw new ConfigurationException("New virtual display is not supported");
}
if (options.getDisplayImePolicy() != -1) {
Ln.e("Display IME policy is not supported before Android 10");
throw new ConfigurationException("Display IME policy is not supported");
}
}
if (options.getSendDeviceMeta()) {
connection.sendDeviceMeta(Device.getDeviceName());
}

View file

@ -1,5 +1,6 @@
package com.genymobile.scrcpy.device;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.genymobile.scrcpy.control.ControlChannel;
import com.genymobile.scrcpy.util.IO;
import com.genymobile.scrcpy.util.StringUtils;
@ -9,9 +10,11 @@ import android.net.LocalSocket;
import android.net.LocalSocketAddress;
import java.io.Closeable;
import java.io.DataInputStream;
import java.io.FileDescriptor;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Map;
public final class DesktopConnection implements Closeable {
@ -164,6 +167,28 @@ public final class DesktopConnection implements Closeable {
IO.writeFully(fd, buffer, 0, buffer.length);
}
public String receiveAdditionalOptions() throws IOException {
LocalSocket socket = getFirstSocket(); // or choose a specific one
DataInputStream input = new DataInputStream(socket.getInputStream());
// Read length prefix (4 bytes, big-endian)
int length = input.readInt(); // throws if the socket closes or data is invalid
if (length == 0) {
return null; // No additional options sent
}
if (length < 0 || length > 10 * 1024 * 1024) { // Limit to 10MB to avoid OOM
throw new IOException("Invalid JSON message length: " + length);
}
// Read the JSON payload
byte[] jsonBytes = new byte[length];
input.readFully(jsonBytes);
return new String(jsonBytes, StandardCharsets.UTF_8);
}
public FileDescriptor getVideoFd() {
return videoFd;
}

View file

@ -1,5 +1,10 @@
package com.genymobile.scrcpy.util;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.util.Map;
public final class StringUtils {
private StringUtils() {
// not instantiable
@ -19,4 +24,22 @@ public final class StringUtils {
}
return len;
}
public static String jsonToArgs(String json) throws IOException {
// Parse the JSON string into a map
ObjectMapper mapper = new ObjectMapper();
@SuppressWarnings("unchecked")
Map<String, Object> map = mapper.readValue(json, Map.class);
// Convert to key=value arguments
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, Object> entry : map.entrySet()) {
if (sb.length() > 0) {
sb.append(' ');
}
sb.append(entry.getKey()).append('=').append(entry.getValue());
}
return sb.toString();
}
}