diff --git a/server/build.gradle b/server/build.gradle index 02508001..667c7ed6 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -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" diff --git a/server/src/main/java/com/genymobile/scrcpy/Options.java b/server/src/main/java/com/genymobile/scrcpy/Options.java index 66bb68e8..d16c452d 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Options.java +++ b/server/src/main/java/com/genymobile/scrcpy/Options.java @@ -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 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 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(":"); diff --git a/server/src/main/java/com/genymobile/scrcpy/Server.java b/server/src/main/java/com/genymobile/scrcpy/Server.java index 09cfd6cf..921ede11 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Server.java +++ b/server/src/main/java/com/genymobile/scrcpy/Server.java @@ -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()); } diff --git a/server/src/main/java/com/genymobile/scrcpy/device/DesktopConnection.java b/server/src/main/java/com/genymobile/scrcpy/device/DesktopConnection.java index db75aec6..ef165059 100644 --- a/server/src/main/java/com/genymobile/scrcpy/device/DesktopConnection.java +++ b/server/src/main/java/com/genymobile/scrcpy/device/DesktopConnection.java @@ -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; } diff --git a/server/src/main/java/com/genymobile/scrcpy/util/StringUtils.java b/server/src/main/java/com/genymobile/scrcpy/util/StringUtils.java index 8b19ca3d..bd360388 100644 --- a/server/src/main/java/com/genymobile/scrcpy/util/StringUtils.java +++ b/server/src/main/java/com/genymobile/scrcpy/util/StringUtils.java @@ -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 map = mapper.readValue(json, Map.class); + + // Convert to key=value arguments + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : map.entrySet()) { + if (sb.length() > 0) { + sb.append(' '); + } + sb.append(entry.getKey()).append('=').append(entry.getValue()); + } + + return sb.toString(); + } }