This commit is contained in:
Simon Chan 2026-04-20 19:08:29 +00:00 committed by GitHub
commit 94f2ce9dd3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 521 additions and 9 deletions

View file

@ -5,6 +5,7 @@
#include <stdlib.h>
#include <string.h>
#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;

View file

@ -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;
};
};

View file

@ -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;

View file

@ -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;
};
};

View file

@ -176,11 +176,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());

View file

@ -73,4 +73,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

View file

@ -3,6 +3,7 @@
#include <assert.h>
#include <inttypes.h>
#include <stdlib.h>
#include <string.h>
#include <SDL3/SDL_clipboard.h>
#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);

View file

@ -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

View file

@ -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;
}

View file

@ -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();

View file

@ -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());
@ -639,10 +652,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);
}
}
}
}
@ -669,6 +691,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);

View file

@ -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;
}
}

View file

@ -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);
}

View file

@ -7,6 +7,7 @@ import com.genymobile.scrcpy.model.DeviceApp;
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;
@ -112,6 +113,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) {
@ -130,6 +139,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;

View file

@ -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);