From 3c52506fa717a099f4f4ea09523f4d225d51765f Mon Sep 17 00:00:00 2001 From: Simon Chan <1330321+yume-chan@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:01:28 +0800 Subject: [PATCH] Support image clipboard --- app/src/control_msg.c | 26 ++++ app/src/control_msg.h | 10 +- app/src/device_msg.c | 43 +++++++ app/src/device_msg.h | 6 + app/src/input_manager.c | 63 ++++++++++ app/src/input_manager.h | 5 + app/src/receiver.c | 101 +++++++++++++++ .../com/genymobile/scrcpy/FakeContext.java | 32 ++++- .../scrcpy/control/ControlMessage.java | 11 ++ .../scrcpy/control/ControlMessageReader.java | 10 ++ .../genymobile/scrcpy/control/Controller.java | 56 ++++++++- .../scrcpy/control/DeviceMessage.java | 14 +++ .../scrcpy/control/DeviceMessageWriter.java | 20 +++ .../com/genymobile/scrcpy/device/Device.java | 18 +++ .../scrcpy/wrappers/ClipboardManager.java | 115 ++++++++++++++++++ 15 files changed, 521 insertions(+), 9 deletions(-) diff --git a/app/src/control_msg.c b/app/src/control_msg.c index 146fbed7..d126ed34 100644 --- a/app/src/control_msg.c +++ b/app/src/control_msg.c @@ -5,6 +5,7 @@ #include #include +#include "device_msg.h" #include "util/binary.h" #include "util/log.h" #include "util/str.h" @@ -151,6 +152,20 @@ sc_control_msg_serialize(const struct sc_control_msg *msg, uint8_t *buf) { size_t len = write_string(&buf[10], msg->set_clipboard.text, SC_CONTROL_MSG_CLIPBOARD_TEXT_MAX_LENGTH); return 10 + len; + case SC_CONTROL_MSG_TYPE_SET_IMAGE_CLIPBOARD: + sc_write64be(&buf[1], msg->set_image_clipboard.sequence); + buf[9] = !!msg->set_image_clipboard.paste; + + size_t mimetype_len = strlen(msg->set_image_clipboard.mimetype); + sc_write32be(&buf[10], mimetype_len); + memcpy(&buf[14], msg->set_image_clipboard.mimetype, mimetype_len); + + // Write image data length and data + sc_write32be(&buf[14 + mimetype_len], msg->set_image_clipboard.size); + memcpy(&buf[18 + mimetype_len], msg->set_image_clipboard.data, + msg->set_image_clipboard.size); + + return 18 + mimetype_len + msg->set_image_clipboard.size; case SC_CONTROL_MSG_TYPE_SET_DISPLAY_POWER: buf[1] = msg->set_display_power.on; return 2; @@ -269,6 +284,13 @@ sc_control_msg_log(const struct sc_control_msg *msg) { msg->set_clipboard.paste ? "paste" : "nopaste", msg->set_clipboard.text); break; + case SC_CONTROL_MSG_TYPE_SET_IMAGE_CLIPBOARD: + LOG_CMSG("image clipboard %" PRIu64_ " %s size=%u mimetype=\"%s\"", + msg->set_image_clipboard.sequence, + msg->set_image_clipboard.paste ? "paste" : "nopaste", + msg->set_image_clipboard.size, + msg->set_image_clipboard.mimetype); + break; case SC_CONTROL_MSG_TYPE_SET_DISPLAY_POWER: LOG_CMSG("display power %s", msg->set_display_power.on ? "on" : "off"); @@ -358,6 +380,10 @@ sc_control_msg_destroy(struct sc_control_msg *msg) { case SC_CONTROL_MSG_TYPE_SET_CLIPBOARD: free(msg->set_clipboard.text); break; + case SC_CONTROL_MSG_TYPE_SET_IMAGE_CLIPBOARD: + free(msg->set_image_clipboard.data); + free(msg->set_image_clipboard.mimetype); + break; case SC_CONTROL_MSG_TYPE_START_APP: free(msg->start_app.name); break; diff --git a/app/src/control_msg.h b/app/src/control_msg.h index 36e9a4b6..686481d2 100644 --- a/app/src/control_msg.h +++ b/app/src/control_msg.h @@ -46,7 +46,8 @@ enum sc_control_msg_type { SC_CONTROL_MSG_TYPE_CAMERA_SET_TORCH, SC_CONTROL_MSG_TYPE_CAMERA_ZOOM_IN, SC_CONTROL_MSG_TYPE_CAMERA_ZOOM_OUT, -}; + SC_CONTROL_MSG_TYPE_SET_IMAGE_CLIPBOARD, + }; enum sc_copy_key { SC_COPY_KEY_NONE, @@ -117,6 +118,13 @@ struct sc_control_msg { struct { bool on; } camera_set_torch; + struct { + uint64_t sequence; + uint8_t *data; // owned, to be freed by free() + uint32_t size; + char *mimetype; // owned, to be freed by free() + bool paste; + } set_image_clipboard; }; }; diff --git a/app/src/device_msg.c b/app/src/device_msg.c index 2172d59b..745d87a0 100644 --- a/app/src/device_msg.c +++ b/app/src/device_msg.c @@ -71,6 +71,45 @@ sc_device_msg_deserialize(const uint8_t *buf, size_t len, return 5 + size; } + case DEVICE_MSG_TYPE_IMAGE_CLIPBOARD: { + if (len < 9) { + // at least type + mimetype length (4 bytes) + data length (4 bytes) + return 0; // no complete message + } + + uint32_t mimetype_len = sc_read32be(&buf[1]); + uint32_t data_len = sc_read32be(&buf[5]); + + if (mimetype_len + data_len > len - 9) { + return 0; // no complete message + } + + char *mimetype = malloc(mimetype_len + 1); + if (!mimetype) { + LOG_OOM(); + return -1; + } + if (mimetype_len) { + memcpy(mimetype, &buf[9], mimetype_len); + } + mimetype[mimetype_len] = '\0'; + + uint8_t *image_data = malloc(data_len); + if (!image_data) { + LOG_OOM(); + free(mimetype); + return -1; + } + if (data_len) { + memcpy(image_data, &buf[9 + mimetype_len], data_len); + } + + msg->image_clipboard.mimetype = mimetype; + msg->image_clipboard.data = image_data; + msg->image_clipboard.size = data_len; + + return 9 + mimetype_len + data_len; + } default: LOGW("Unknown device message type: %d", (int) msg->type); return -1; // error, we cannot recover @@ -86,6 +125,10 @@ sc_device_msg_destroy(struct sc_device_msg *msg) { case DEVICE_MSG_TYPE_UHID_OUTPUT: free(msg->uhid_output.data); break; + case DEVICE_MSG_TYPE_IMAGE_CLIPBOARD: + free(msg->image_clipboard.data); + free(msg->image_clipboard.mimetype); + break; default: // nothing to do break; diff --git a/app/src/device_msg.h b/app/src/device_msg.h index d6c701bb..d0d2c29f 100644 --- a/app/src/device_msg.h +++ b/app/src/device_msg.h @@ -15,6 +15,7 @@ enum sc_device_msg_type { DEVICE_MSG_TYPE_CLIPBOARD, DEVICE_MSG_TYPE_ACK_CLIPBOARD, DEVICE_MSG_TYPE_UHID_OUTPUT, + DEVICE_MSG_TYPE_IMAGE_CLIPBOARD, }; struct sc_device_msg { @@ -31,6 +32,11 @@ struct sc_device_msg { uint16_t size; uint8_t *data; // owned, to be freed by free() } uhid_output; + struct { + uint8_t *data; // owned, to be freed by free() + uint32_t size; + char *mimetype; // owned, to be freed by free() + } image_clipboard; }; }; diff --git a/app/src/input_manager.c b/app/src/input_manager.c index 6e56d31c..a1088855 100644 --- a/app/src/input_manager.c +++ b/app/src/input_manager.c @@ -173,11 +173,74 @@ get_device_clipboard(struct sc_input_manager *im, enum sc_copy_key copy_key) { return true; } +bool +sc_input_manager_set_device_image_clipboard(struct sc_input_manager *im, bool paste, + uint64_t sequence) { + assert(im->controller && im->kp && !im->camera); + + // Try common image MIME types to check if image clipboard data exists + const char* image_mime_types[] = { + "image/png", + "image/jpeg", + "image/jpg", + "image/gif", + "image/webp", + "image/bmp", + NULL + }; + + for (int i = 0; image_mime_types[i]; i++) { + const char* mime_type = image_mime_types[i]; + size_t size; + void *img_data = SDL_GetClipboardData(mime_type, &size); + if (img_data && size > 0) { + size_t mimetype_len = strlen(mime_type); + + // Check if message exceeds max size + size_t msg_size = 18 + mimetype_len + size; + if (msg_size > SC_CONTROL_MSG_MAX_SIZE) { + LOGW("Image clipboard message too large: %zu bytes, dropping", + msg_size); + return true; + } + + struct sc_control_msg msg; + msg.type = SC_CONTROL_MSG_TYPE_SET_IMAGE_CLIPBOARD; + msg.set_image_clipboard.sequence = sequence; + msg.set_image_clipboard.data = malloc(size); + if (msg.set_image_clipboard.data) { + memcpy(msg.set_image_clipboard.data, img_data, size); + msg.set_image_clipboard.size = size; + msg.set_image_clipboard.mimetype = strdup(mime_type); + msg.set_image_clipboard.paste = paste; + + bool success = sc_controller_push_msg(im->controller, &msg); + SDL_free(img_data); + if (success) { + return true; + } + free(msg.set_image_clipboard.data); + free(msg.set_image_clipboard.mimetype); + } + SDL_free(img_data); + } + } + + // Return false since we can't actually call the SDL3 functions in this context + return false; +} + static bool set_device_clipboard(struct sc_input_manager *im, bool paste, uint64_t sequence) { assert(im->controller && im->kp && !im->camera); + if (sc_input_manager_set_device_image_clipboard(im, paste, sequence)) { + // Successfully sent image clipboard, return true + return true; + } + + // Fallback to text clipboard char *text = SDL_GetClipboardText(); if (!text) { LOGW("Could not get clipboard text: %s", SDL_GetError()); diff --git a/app/src/input_manager.h b/app/src/input_manager.h index ed96a3dd..8aae606f 100644 --- a/app/src/input_manager.h +++ b/app/src/input_manager.h @@ -71,4 +71,9 @@ void sc_input_manager_handle_event(struct sc_input_manager *im, const SDL_Event *event); +// Send image clipboard from computer to device +bool +sc_input_manager_set_device_image_clipboard(struct sc_input_manager *im, bool paste, + uint64_t sequence); + #endif diff --git a/app/src/receiver.c b/app/src/receiver.c index 659f443f..faa93b63 100644 --- a/app/src/receiver.c +++ b/app/src/receiver.c @@ -3,6 +3,7 @@ #include #include #include +#include #include #include "device_msg.h" @@ -18,6 +19,28 @@ struct sc_uhid_output_task_data { uint8_t *data; }; +struct sc_image_clipboard_data { + uint8_t *data; + uint32_t size; + char *mimetype; +}; + +static const void * SDLCALL +clipboard_data_callback(void *userdata, const char *mime_type, size_t *size) { + struct sc_image_clipboard_data *image_data = userdata; + (void) mime_type; + *size = image_data->size; + return image_data->data; +} + +static void SDLCALL +clipboard_cleanup_callback(void *userdata) { + struct sc_image_clipboard_data *image_data = userdata; + free(image_data->data); + free(image_data->mimetype); + free(image_data); +} + bool sc_receiver_init(struct sc_receiver *receiver, sc_socket control_socket, const struct sc_receiver_callbacks *cbs, void *cbs_userdata) { @@ -65,6 +88,44 @@ task_set_clipboard(void *userdata) { free(text); } +static void +task_set_image_clipboard(void *userdata) { + assert(sc_thread_get_id() == SC_MAIN_THREAD_ID); + + struct sc_device_msg *msg = userdata; + + struct sc_image_clipboard_data *image_data = + malloc(sizeof(struct sc_image_clipboard_data)); + if (!image_data) { + LOG_OOM(); + free(msg->image_clipboard.data); + free(msg->image_clipboard.mimetype); + free(msg); + return; + } + + image_data->data = msg->image_clipboard.data; + image_data->size = msg->image_clipboard.size; + image_data->mimetype = msg->image_clipboard.mimetype; + + const char *mime_types[1] = { image_data->mimetype }; + + bool ok = SDL_SetClipboardData(clipboard_data_callback, + clipboard_cleanup_callback, + image_data, + mime_types, 1); + if (ok) { + LOGI("Device image clipboard copied"); + } else { + LOGE("Could not set image clipboard: %s", SDL_GetError()); + free(image_data->data); + free(image_data->mimetype); + free(image_data); + } + + free(msg); +} + static void task_uhid_output(void *userdata) { assert(sc_thread_get_id() == SC_MAIN_THREAD_ID); @@ -94,6 +155,46 @@ process_msg(struct sc_receiver *receiver, struct sc_device_msg *msg) { break; } + case DEVICE_MSG_TYPE_IMAGE_CLIPBOARD: { + // Create a copy of the message to send to the main thread + struct sc_device_msg *msg_copy = malloc(sizeof(struct sc_device_msg)); + if (!msg_copy) { + LOG_OOM(); + return; + } + + msg_copy->type = DEVICE_MSG_TYPE_IMAGE_CLIPBOARD; + msg_copy->image_clipboard.data = malloc(msg->image_clipboard.size); + if (!msg_copy->image_clipboard.data) { + LOG_OOM(); + free(msg_copy); + return; + } + memcpy(msg_copy->image_clipboard.data, msg->image_clipboard.data, msg->image_clipboard.size); + msg_copy->image_clipboard.size = msg->image_clipboard.size; + + // Duplicate the mimetype string + size_t mimetype_len = strlen(msg->image_clipboard.mimetype); + msg_copy->image_clipboard.mimetype = malloc(mimetype_len + 1); + if (!msg_copy->image_clipboard.mimetype) { + LOG_OOM(); + free(msg_copy->image_clipboard.data); + free(msg_copy); + return; + } + strcpy(msg_copy->image_clipboard.mimetype, msg->image_clipboard.mimetype); + + bool ok = sc_post_to_main_thread(task_set_image_clipboard, msg_copy); + if (!ok) { + LOGW("Could not post image clipboard to main thread"); + free(msg_copy->image_clipboard.data); + free(msg_copy->image_clipboard.mimetype); + free(msg_copy); + return; + } + + break; + } case DEVICE_MSG_TYPE_ACK_CLIPBOARD: LOGD("Ack device clipboard sequence=%" PRIu64_, msg->ack_clipboard.sequence); diff --git a/server/src/main/java/com/genymobile/scrcpy/FakeContext.java b/server/src/main/java/com/genymobile/scrcpy/FakeContext.java index 5d41a8f3..149ac1d9 100644 --- a/server/src/main/java/com/genymobile/scrcpy/FakeContext.java +++ b/server/src/main/java/com/genymobile/scrcpy/FakeContext.java @@ -1,5 +1,6 @@ package com.genymobile.scrcpy; +import com.genymobile.scrcpy.util.Ln; import com.genymobile.scrcpy.wrappers.ServiceManager; import android.annotation.SuppressLint; @@ -9,10 +10,13 @@ import android.content.ContentResolver; import android.content.Context; import android.content.ContextWrapper; import android.content.IContentProvider; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; import android.os.Binder; import android.os.Process; import java.lang.reflect.Field; +import java.lang.reflect.Method; public final class FakeContext extends ContextWrapper { @@ -25,6 +29,29 @@ public final class FakeContext extends ContextWrapper { return INSTANCE; } + private static Context getSystemContext() { + Context base = Workarounds.getSystemContext(); + + try { + // `getFilesDir()` method requires correct app info + PackageManager pm = base.getPackageManager(); + ApplicationInfo appInfo = pm.getApplicationInfo(PACKAGE_NAME, 0); + + Field mPackageInfoField = base.getClass().getDeclaredField("mPackageInfo"); + mPackageInfoField.setAccessible(true); + Object mPackageInfo = mPackageInfoField.get(base); + + Method setApplicationInfoMethod = mPackageInfo.getClass().getDeclaredMethod("setApplicationInfo", ApplicationInfo.class); + setApplicationInfoMethod.setAccessible(true); + setApplicationInfoMethod.invoke(mPackageInfo, appInfo); + + return base; + } catch (Exception e) { + Ln.w("Can't set application info to base context" , e); + return base; + } + } + private final ContentResolver contentResolver = new ContentResolver(this) { @SuppressWarnings({"unused", "ProtectedMemberInFinalClass"}) // @Override (but super-class method not visible) @@ -41,7 +68,7 @@ public final class FakeContext extends ContextWrapper { @SuppressWarnings({"unused", "ProtectedMemberInFinalClass"}) // @Override (but super-class method not visible) protected IContentProvider acquireUnstableProvider(Context c, String name) { - return null; + return ServiceManager.getActivityManager().getContentProviderExternal(name, new Binder()); } @SuppressWarnings("unused") @@ -58,7 +85,8 @@ public final class FakeContext extends ContextWrapper { }; private FakeContext() { - super(Workarounds.getSystemContext()); + super(getSystemContext()); + } @Override diff --git a/server/src/main/java/com/genymobile/scrcpy/control/ControlMessage.java b/server/src/main/java/com/genymobile/scrcpy/control/ControlMessage.java index 6aea226e..3d8120f4 100644 --- a/server/src/main/java/com/genymobile/scrcpy/control/ControlMessage.java +++ b/server/src/main/java/com/genymobile/scrcpy/control/ControlMessage.java @@ -28,6 +28,7 @@ public final class ControlMessage { public static final int TYPE_CAMERA_SET_TORCH = 18; public static final int TYPE_CAMERA_ZOOM_IN = 19; public static final int TYPE_CAMERA_ZOOM_OUT = 20; + public static final int TYPE_SET_IMAGE_CLIPBOARD = 21; public static final long SEQUENCE_INVALID = 0; @@ -176,6 +177,16 @@ public final class ControlMessage { return msg; } + public static ControlMessage createSetImageClipboard(long sequence, boolean paste, String mimeType, byte[] data) { + ControlMessage msg = new ControlMessage(); + msg.type = TYPE_SET_IMAGE_CLIPBOARD; + msg.sequence = sequence; + msg.paste = paste; + msg.text = mimeType; // Store mimeType in text field temporarily + msg.data = data; + return msg; + } + public int getType() { return type; } diff --git a/server/src/main/java/com/genymobile/scrcpy/control/ControlMessageReader.java b/server/src/main/java/com/genymobile/scrcpy/control/ControlMessageReader.java index fd29edbe..809afa2c 100644 --- a/server/src/main/java/com/genymobile/scrcpy/control/ControlMessageReader.java +++ b/server/src/main/java/com/genymobile/scrcpy/control/ControlMessageReader.java @@ -60,6 +60,8 @@ public class ControlMessageReader { return parseStartApp(); case ControlMessage.TYPE_CAMERA_SET_TORCH: return parseCameraSetTorch(); + case ControlMessage.TYPE_SET_IMAGE_CLIPBOARD: + return parseSetImageClipboard(); default: throw new ControlProtocolException("Unknown event type: " + type); } @@ -175,6 +177,14 @@ public class ControlMessageReader { return ControlMessage.createCameraSetTorch(on); } + private ControlMessage parseSetImageClipboard() throws IOException { + long sequence = dis.readLong(); + boolean paste = dis.readByte() != 0; + String mimeType = parseString(); + byte[] data = parseByteArray(4); // Read image data + return ControlMessage.createSetImageClipboard(sequence, paste, mimeType, data); + } + private Position parsePosition() throws IOException { int x = dis.readInt(); int y = dis.readInt(); diff --git a/server/src/main/java/com/genymobile/scrcpy/control/Controller.java b/server/src/main/java/com/genymobile/scrcpy/control/Controller.java index 968663a0..cf57b6e2 100644 --- a/server/src/main/java/com/genymobile/scrcpy/control/Controller.java +++ b/server/src/main/java/com/genymobile/scrcpy/control/Controller.java @@ -17,6 +17,7 @@ import com.genymobile.scrcpy.video.SurfaceCapture; import com.genymobile.scrcpy.video.VideoSource; import com.genymobile.scrcpy.video.VirtualDisplayListener; import com.genymobile.scrcpy.wrappers.ClipboardManager; +import com.genymobile.scrcpy.wrappers.ClipboardManager.ClipboardImage; import com.genymobile.scrcpy.wrappers.InputManager; import com.genymobile.scrcpy.wrappers.ServiceManager; @@ -140,10 +141,19 @@ public class Controller implements AsyncProcessor, VirtualDisplayListener { // This is a notification for the change we are currently applying, ignore it return; } - String text = Device.getClipboardText(); - if (text != null) { - DeviceMessage msg = DeviceMessage.createClipboard(text); + // Check for image clipboard first + ClipboardImage clipboardImage = Device.getClipboardImage(); + if (clipboardImage != null && clipboardImage.data().length > 0) { + // Send image clipboard data + DeviceMessage msg = DeviceMessage.createImageClipboard(clipboardImage.data(), clipboardImage.mimeType()); sender.send(msg); + } else { + // Fall back to text clipboard + String text = Device.getClipboardText(); + if (text != null) { + DeviceMessage msg = DeviceMessage.createClipboard(text); + sender.send(msg); + } } }); } else { @@ -343,6 +353,9 @@ public class Controller implements AsyncProcessor, VirtualDisplayListener { case ControlMessage.TYPE_SET_CLIPBOARD: setClipboard(msg.getText(), msg.getPaste(), msg.getSequence()); return true; + case ControlMessage.TYPE_SET_IMAGE_CLIPBOARD: + setImageClipboard(msg.getSequence(), msg.getPaste(), msg.getText(), msg.getData()); // text field contains mimeType + return true; case ControlMessage.TYPE_SET_DISPLAY_POWER: if (supportsInputEvents) { setDisplayPower(msg.getOn()); @@ -634,10 +647,19 @@ public class Controller implements AsyncProcessor, VirtualDisplayListener { // particular when COPY or CUT are injected, so it should not be synchronized twice. On Android < 7, do not synchronize at all rather than // copying an old clipboard content. if (!clipboardAutosync) { - String clipboardText = Device.getClipboardText(); - if (clipboardText != null) { - DeviceMessage msg = DeviceMessage.createClipboard(clipboardText); + // Try to get image clipboard first + ClipboardImage clipboardImage = Device.getClipboardImage(); + if (clipboardImage != null && clipboardImage.data().length > 0) { + // Send image clipboard data + DeviceMessage msg = DeviceMessage.createImageClipboard(clipboardImage.data(), clipboardImage.mimeType()); sender.send(msg); + } else { + // Fall back to text clipboard + String clipboardText = Device.getClipboardText(); + if (clipboardText != null) { + DeviceMessage msg = DeviceMessage.createClipboard(clipboardText); + sender.send(msg); + } } } } @@ -664,6 +686,28 @@ public class Controller implements AsyncProcessor, VirtualDisplayListener { return ok; } + private boolean setImageClipboard(long sequence, boolean paste, String mimeType, byte[] imageData) { + isSettingClipboard.set(true); + boolean ok = Device.setClipboardImage(imageData, mimeType); + isSettingClipboard.set(false); + if (ok) { + Ln.i("Device image clipboard set"); + } + + // On Android >= 7, also press the PASTE key if requested + if (paste && Build.VERSION.SDK_INT >= AndroidVersions.API_24_ANDROID_7_0 && supportsInputEvents) { + pressReleaseKeycode(KeyEvent.KEYCODE_PASTE, Device.INJECT_MODE_ASYNC); + } + + if (sequence != ControlMessage.SEQUENCE_INVALID) { + // Acknowledgement requested + DeviceMessage msg = DeviceMessage.createAckClipboard(sequence); + sender.send(msg); + } + + return ok; + } + private void openHardKeyboardSettings() { Intent intent = new Intent("android.settings.HARD_KEYBOARD_SETTINGS"); ServiceManager.getActivityManager().startActivity(intent); diff --git a/server/src/main/java/com/genymobile/scrcpy/control/DeviceMessage.java b/server/src/main/java/com/genymobile/scrcpy/control/DeviceMessage.java index 079a7a04..59778c57 100644 --- a/server/src/main/java/com/genymobile/scrcpy/control/DeviceMessage.java +++ b/server/src/main/java/com/genymobile/scrcpy/control/DeviceMessage.java @@ -5,9 +5,11 @@ public final class DeviceMessage { public static final int TYPE_CLIPBOARD = 0; public static final int TYPE_ACK_CLIPBOARD = 1; public static final int TYPE_UHID_OUTPUT = 2; + public static final int TYPE_IMAGE_CLIPBOARD = 3; private int type; private String text; + private String mimeType; private long sequence; private int id; private byte[] data; @@ -37,6 +39,14 @@ public final class DeviceMessage { return event; } + public static DeviceMessage createImageClipboard(byte[] imageData, String mimeType) { + DeviceMessage event = new DeviceMessage(); + event.type = TYPE_IMAGE_CLIPBOARD; + event.data = imageData; + event.mimeType = mimeType; + return event; + } + public int getType() { return type; } @@ -56,4 +66,8 @@ public final class DeviceMessage { public byte[] getData() { return data; } + + public String getMimeType() { + return mimeType; + } } diff --git a/server/src/main/java/com/genymobile/scrcpy/control/DeviceMessageWriter.java b/server/src/main/java/com/genymobile/scrcpy/control/DeviceMessageWriter.java index a18a2e5d..6a7e4715 100644 --- a/server/src/main/java/com/genymobile/scrcpy/control/DeviceMessageWriter.java +++ b/server/src/main/java/com/genymobile/scrcpy/control/DeviceMessageWriter.java @@ -1,6 +1,7 @@ package com.genymobile.scrcpy.control; import com.genymobile.scrcpy.util.StringUtils; +import com.genymobile.scrcpy.util.Ln; import java.io.BufferedOutputStream; import java.io.DataOutputStream; @@ -39,6 +40,25 @@ public class DeviceMessageWriter { dos.writeShort(data.length); dos.write(data); break; + case DeviceMessage.TYPE_IMAGE_CLIPBOARD: + byte[] imageData = msg.getData(); + String mimeType = msg.getMimeType(); + + byte[] mimeTypeBytes = mimeType.getBytes(StandardCharsets.UTF_8); + int messageSize = 1 + 4 + mimeTypeBytes.length + 4 + imageData.length; + if (messageSize > MESSAGE_MAX_SIZE) { + Ln.w("Image clipboard message too large: " + messageSize + " bytes, dropping"); + return; + } + + // Write fixed length fields first + // so client can easily detect whether they have received the whole message + dos.writeInt(mimeTypeBytes.length); + dos.writeInt(imageData.length); + + dos.write(mimeTypeBytes); + dos.write(imageData); + break; default: throw new ControlProtocolException("Unknown event type: " + type); } diff --git a/server/src/main/java/com/genymobile/scrcpy/device/Device.java b/server/src/main/java/com/genymobile/scrcpy/device/Device.java index 3553dc27..369b3a97 100644 --- a/server/src/main/java/com/genymobile/scrcpy/device/Device.java +++ b/server/src/main/java/com/genymobile/scrcpy/device/Device.java @@ -5,6 +5,7 @@ import com.genymobile.scrcpy.FakeContext; import com.genymobile.scrcpy.util.Ln; import com.genymobile.scrcpy.wrappers.ActivityManager; import com.genymobile.scrcpy.wrappers.ClipboardManager; +import com.genymobile.scrcpy.wrappers.ClipboardManager.ClipboardImage; import com.genymobile.scrcpy.wrappers.DisplayControl; import com.genymobile.scrcpy.wrappers.InputManager; import com.genymobile.scrcpy.wrappers.ServiceManager; @@ -110,6 +111,14 @@ public final class Device { return s.toString(); } + public static ClipboardImage getClipboardImage() { + ClipboardManager clipboardManager = ServiceManager.getClipboardManager(); + if (clipboardManager == null) { + return null; + } + return clipboardManager.getImage(); + } + public static boolean setClipboardText(String text) { ClipboardManager clipboardManager = ServiceManager.getClipboardManager(); if (clipboardManager == null) { @@ -128,6 +137,15 @@ public final class Device { return clipboardManager.setText(text); } + public static boolean setClipboardImage(byte[] imageData, String mimeType) { + ClipboardManager clipboardManager = ServiceManager.getClipboardManager(); + if (clipboardManager == null) { + return false; + } + + return clipboardManager.setImage(imageData, mimeType); + } + public static boolean setDisplayPower(int displayId, boolean on) { assert displayId != Device.DISPLAY_ID_NONE; diff --git a/server/src/main/java/com/genymobile/scrcpy/wrappers/ClipboardManager.java b/server/src/main/java/com/genymobile/scrcpy/wrappers/ClipboardManager.java index 54936122..be0ed9cc 100644 --- a/server/src/main/java/com/genymobile/scrcpy/wrappers/ClipboardManager.java +++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/ClipboardManager.java @@ -2,12 +2,48 @@ package com.genymobile.scrcpy.wrappers; import com.genymobile.scrcpy.FakeContext; +import com.genymobile.scrcpy.util.Ln; + import android.content.ClipData; +import android.content.ClipDescription; +import android.content.ContentResolver; import android.content.Context; +import android.net.Uri; +import android.os.Build; +import android.provider.MediaStore; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.IOException; +import java.io.File; +import java.io.FileOutputStream; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; public final class ClipboardManager { + + public static class ClipboardImage { + private final String mimeType; + private final byte[] data; + + public ClipboardImage(String mimeType, byte[] data) { + this.mimeType = mimeType; + this.data = data; + } + + public String mimeType() { + return mimeType; + } + + public byte[] data() { + return data; + } + } + private final android.content.ClipboardManager manager; + private File cachedFolder; + static ClipboardManager create() { android.content.ClipboardManager manager = (android.content.ClipboardManager) FakeContext.get().getSystemService(Context.CLIPBOARD_SERVICE); if (manager == null) { @@ -31,6 +67,85 @@ public final class ClipboardManager { return clipData.getItemAt(0).getText(); } + public ClipboardImage getImage() { + ClipData clipData = manager.getPrimaryClip(); + if (clipData == null || clipData.getItemCount() == 0) { + return null; + } + + ClipData.Item item = clipData.getItemAt(0); + + // Check if it's an image URI + ClipDescription description = clipData.getDescription(); + if (description != null) { + String mimeType = description.filterMimeTypes("image/*")[0]; + if (mimeType == null) { + return null; + } + + Uri uri = item.getUri(); + if (uri == null) { + return null; + } + + try (InputStream inputStream = FakeContext.get().getContentResolver().openInputStream(uri); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + + if (inputStream != null) { + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + return new ClipboardImage(mimeType, outputStream.toByteArray()); + } + } catch (IOException e) { + // Failed to read image data from URI + Ln.e("Failed to read clipboard image", e); + } + } + + return null; + } + + public boolean setImage(byte[] imageData, String mimeType) { + try { + if (cachedFolder == null) { + android.content.Context context = FakeContext.get(); + + if (Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { + context = context.createDeviceProtectedStorageContext(); + } + + File fileRoot = context.getFilesDir(); + cachedFolder = new File(fileRoot, "bugreports"); + cachedFolder.mkdirs(); + } + + // Use atomic write: write to temporary file first, then move to final location + File tempFile = new File(cachedFolder, "clipboard.tmp"); + try (FileOutputStream fos = new FileOutputStream(tempFile)) { + fos.write(imageData); + } + + File finalFile = new File(cachedFolder, "clipboard"); + Files.move(tempFile.toPath(), finalFile.toPath(), StandardCopyOption.REPLACE_EXISTING); + + android.net.Uri uri = android.net.Uri.parse("content://com.android.shell/bugreports/clipboard"); + + ClipData clipData = new ClipData( + null, + new String[]{mimeType}, + new ClipData.Item(uri) + ); + manager.setPrimaryClip(clipData); + return true; + } catch (Exception e) { + Ln.e("Failed to set image clipboard", e); + return false; + } + } + public boolean setText(CharSequence text) { ClipData clipData = ClipData.newPlainText(null, text); manager.setPrimaryClip(clipData);