Implement computer-to-device clipboard copy
It was already possible to _paste_ (with Ctrl+v) the content of the computer clipboard on the device. Technically, it injects a sequence of events to generate the text. Add a new feature (Ctrl+Shift+v) to copy to the device clipboard instead, without injecting the content. Contrary to events injection, this preserves the UTF-8 content exactly, so the text is not broken by special characters. <https://github.com/Genymobile/scrcpy/issues/413>
This commit is contained in:
parent
2322069656
commit
c13a24389c
12 changed files with 138 additions and 6 deletions
|
@ -289,6 +289,7 @@ you are interested, see [issue 14].
|
||||||
| collapse notification panel | `Ctrl`+`Shift`+`n` |
|
| collapse notification panel | `Ctrl`+`Shift`+`n` |
|
||||||
| copy device clipboard to computer | `Ctrl`+`c` |
|
| copy device clipboard to computer | `Ctrl`+`c` |
|
||||||
| paste computer clipboard to device | `Ctrl`+`v` |
|
| paste computer clipboard to device | `Ctrl`+`v` |
|
||||||
|
| copy computer clipboard to device | `Ctrl`+`Shift+`v` |
|
||||||
| enable/disable FPS counter (on stdout) | `Ctrl`+`i` |
|
| enable/disable FPS counter (on stdout) | `Ctrl`+`i` |
|
||||||
|
|
||||||
_¹Double-click on black borders to remove them._
|
_¹Double-click on black borders to remove them._
|
||||||
|
|
|
@ -47,6 +47,12 @@ control_event_serialize(const struct control_event *event, unsigned char *buf) {
|
||||||
buffer_write32be(&buf[13], (uint32_t) event->scroll_event.hscroll);
|
buffer_write32be(&buf[13], (uint32_t) event->scroll_event.hscroll);
|
||||||
buffer_write32be(&buf[17], (uint32_t) event->scroll_event.vscroll);
|
buffer_write32be(&buf[17], (uint32_t) event->scroll_event.vscroll);
|
||||||
return 21;
|
return 21;
|
||||||
|
case CONTROL_EVENT_TYPE_SET_CLIPBOARD: {
|
||||||
|
size_t len = write_string(event->text_event.text,
|
||||||
|
CONTROL_EVENT_CLIPBOARD_TEXT_MAX_LENGTH,
|
||||||
|
&buf[1]);
|
||||||
|
return 1 + len;
|
||||||
|
}
|
||||||
case CONTROL_EVENT_TYPE_BACK_OR_SCREEN_ON:
|
case CONTROL_EVENT_TYPE_BACK_OR_SCREEN_ON:
|
||||||
case CONTROL_EVENT_TYPE_EXPAND_NOTIFICATION_PANEL:
|
case CONTROL_EVENT_TYPE_EXPAND_NOTIFICATION_PANEL:
|
||||||
case CONTROL_EVENT_TYPE_COLLAPSE_NOTIFICATION_PANEL:
|
case CONTROL_EVENT_TYPE_COLLAPSE_NOTIFICATION_PANEL:
|
||||||
|
@ -61,7 +67,15 @@ control_event_serialize(const struct control_event *event, unsigned char *buf) {
|
||||||
|
|
||||||
void
|
void
|
||||||
control_event_destroy(struct control_event *event) {
|
control_event_destroy(struct control_event *event) {
|
||||||
if (event->type == CONTROL_EVENT_TYPE_TEXT) {
|
switch (event->type) {
|
||||||
SDL_free(event->text_event.text);
|
case CONTROL_EVENT_TYPE_TEXT:
|
||||||
|
SDL_free(event->text_event.text);
|
||||||
|
break;
|
||||||
|
case CONTROL_EVENT_TYPE_SET_CLIPBOARD:
|
||||||
|
SDL_free(event->set_clipboard_event.text);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// do nothing
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,9 @@
|
||||||
#include "common.h"
|
#include "common.h"
|
||||||
|
|
||||||
#define CONTROL_EVENT_TEXT_MAX_LENGTH 300
|
#define CONTROL_EVENT_TEXT_MAX_LENGTH 300
|
||||||
#define CONTROL_EVENT_SERIALIZED_MAX_SIZE (3 + CONTROL_EVENT_TEXT_MAX_LENGTH)
|
#define CONTROL_EVENT_CLIPBOARD_TEXT_MAX_LENGTH 4093
|
||||||
|
#define CONTROL_EVENT_SERIALIZED_MAX_SIZE \
|
||||||
|
(3 + CONTROL_EVENT_CLIPBOARD_TEXT_MAX_LENGTH)
|
||||||
|
|
||||||
enum control_event_type {
|
enum control_event_type {
|
||||||
CONTROL_EVENT_TYPE_KEYCODE,
|
CONTROL_EVENT_TYPE_KEYCODE,
|
||||||
|
@ -21,6 +23,7 @@ enum control_event_type {
|
||||||
CONTROL_EVENT_TYPE_EXPAND_NOTIFICATION_PANEL,
|
CONTROL_EVENT_TYPE_EXPAND_NOTIFICATION_PANEL,
|
||||||
CONTROL_EVENT_TYPE_COLLAPSE_NOTIFICATION_PANEL,
|
CONTROL_EVENT_TYPE_COLLAPSE_NOTIFICATION_PANEL,
|
||||||
CONTROL_EVENT_TYPE_GET_CLIPBOARD,
|
CONTROL_EVENT_TYPE_GET_CLIPBOARD,
|
||||||
|
CONTROL_EVENT_TYPE_SET_CLIPBOARD,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct control_event {
|
struct control_event {
|
||||||
|
@ -44,6 +47,9 @@ struct control_event {
|
||||||
int32_t hscroll;
|
int32_t hscroll;
|
||||||
int32_t vscroll;
|
int32_t vscroll;
|
||||||
} scroll_event;
|
} scroll_event;
|
||||||
|
struct {
|
||||||
|
char *text; // owned, to be freed by SDL_free()
|
||||||
|
} set_clipboard_event;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -136,6 +136,29 @@ request_device_clipboard(struct controller *controller) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void
|
||||||
|
set_device_clipboard(struct controller *controller) {
|
||||||
|
char *text = SDL_GetClipboardText();
|
||||||
|
if (!text) {
|
||||||
|
LOGW("Cannot get clipboard text: %s", SDL_GetError());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!*text) {
|
||||||
|
// empty text
|
||||||
|
SDL_free(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct control_event control_event;
|
||||||
|
control_event.type = CONTROL_EVENT_TYPE_SET_CLIPBOARD;
|
||||||
|
control_event.set_clipboard_event.text = text;
|
||||||
|
|
||||||
|
if (!controller_push_event(controller, &control_event)) {
|
||||||
|
SDL_free(text);
|
||||||
|
LOGW("Cannot send clipboard paste event");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static void
|
static void
|
||||||
switch_fps_counter_state(struct video_buffer *vb) {
|
switch_fps_counter_state(struct video_buffer *vb) {
|
||||||
mutex_lock(vb->mutex);
|
mutex_lock(vb->mutex);
|
||||||
|
@ -267,9 +290,15 @@ input_manager_process_key(struct input_manager *input_manager,
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
case SDLK_v:
|
case SDLK_v:
|
||||||
if (control && ctrl && !meta && !shift && !repeat
|
if (control && ctrl && !meta && !repeat
|
||||||
&& event->type == SDL_KEYDOWN) {
|
&& event->type == SDL_KEYDOWN) {
|
||||||
clipboard_paste(input_manager->controller);
|
if (shift) {
|
||||||
|
// store the text in the device clipboard
|
||||||
|
set_device_clipboard(input_manager->controller);
|
||||||
|
} else {
|
||||||
|
// inject the text as input events
|
||||||
|
clipboard_paste(input_manager->controller);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
case SDLK_f:
|
case SDLK_f:
|
||||||
|
|
|
@ -143,6 +143,9 @@ static void usage(const char *arg0) {
|
||||||
" Ctrl+v\n"
|
" Ctrl+v\n"
|
||||||
" paste computer clipboard to device\n"
|
" paste computer clipboard to device\n"
|
||||||
"\n"
|
"\n"
|
||||||
|
" Ctrl+Shift+v\n"
|
||||||
|
" copy computer clipboard to device\n"
|
||||||
|
"\n"
|
||||||
" Ctrl+i\n"
|
" Ctrl+i\n"
|
||||||
" enable/disable FPS counter (print frames/second in logs)\n"
|
" enable/disable FPS counter (print frames/second in logs)\n"
|
||||||
"\n"
|
"\n"
|
||||||
|
|
|
@ -193,6 +193,26 @@ static void test_serialize_get_clipboard_event(void) {
|
||||||
assert(!memcmp(buf, expected, sizeof(expected)));
|
assert(!memcmp(buf, expected, sizeof(expected)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void test_serialize_set_clipboard_event(void) {
|
||||||
|
struct control_event event = {
|
||||||
|
.type = CONTROL_EVENT_TYPE_SET_CLIPBOARD,
|
||||||
|
.text_event = {
|
||||||
|
.text = "hello, world!",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
unsigned char buf[CONTROL_EVENT_SERIALIZED_MAX_SIZE];
|
||||||
|
int size = control_event_serialize(&event, buf);
|
||||||
|
assert(size == 16);
|
||||||
|
|
||||||
|
const unsigned char expected[] = {
|
||||||
|
0x08, // CONTROL_EVENT_TYPE_SET_CLIPBOARD
|
||||||
|
0x00, 0x0d, // text length
|
||||||
|
'h', 'e', 'l', 'l', 'o', ',', ' ', 'w', 'o', 'r', 'l', 'd', '!', // text
|
||||||
|
};
|
||||||
|
assert(!memcmp(buf, expected, sizeof(expected)));
|
||||||
|
}
|
||||||
|
|
||||||
int main(void) {
|
int main(void) {
|
||||||
test_serialize_keycode_event();
|
test_serialize_keycode_event();
|
||||||
test_serialize_text_event();
|
test_serialize_text_event();
|
||||||
|
@ -203,5 +223,6 @@ int main(void) {
|
||||||
test_serialize_expand_notification_panel_event();
|
test_serialize_expand_notification_panel_event();
|
||||||
test_serialize_collapse_notification_panel_event();
|
test_serialize_collapse_notification_panel_event();
|
||||||
test_serialize_get_clipboard_event();
|
test_serialize_get_clipboard_event();
|
||||||
|
test_serialize_set_clipboard_event();
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ public final class ControlEvent {
|
||||||
public static final int TYPE_EXPAND_NOTIFICATION_PANEL = 5;
|
public static final int TYPE_EXPAND_NOTIFICATION_PANEL = 5;
|
||||||
public static final int TYPE_COLLAPSE_NOTIFICATION_PANEL = 6;
|
public static final int TYPE_COLLAPSE_NOTIFICATION_PANEL = 6;
|
||||||
public static final int TYPE_GET_CLIPBOARD = 7;
|
public static final int TYPE_GET_CLIPBOARD = 7;
|
||||||
|
public static final int TYPE_SET_CLIPBOARD = 8;
|
||||||
|
|
||||||
private int type;
|
private int type;
|
||||||
private String text;
|
private String text;
|
||||||
|
@ -61,6 +62,13 @@ public final class ControlEvent {
|
||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static ControlEvent createSetClipboardControlEvent(String text) {
|
||||||
|
ControlEvent event = new ControlEvent();
|
||||||
|
event.type = TYPE_SET_CLIPBOARD;
|
||||||
|
event.text = text;
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
public static ControlEvent createSimpleControlEvent(int type) {
|
public static ControlEvent createSimpleControlEvent(int type) {
|
||||||
ControlEvent event = new ControlEvent();
|
ControlEvent event = new ControlEvent();
|
||||||
event.type = type;
|
event.type = type;
|
||||||
|
|
|
@ -13,11 +13,12 @@ public class ControlEventReader {
|
||||||
private static final int SCROLL_PAYLOAD_LENGTH = 20;
|
private static final int SCROLL_PAYLOAD_LENGTH = 20;
|
||||||
|
|
||||||
public static final int TEXT_MAX_LENGTH = 300;
|
public static final int TEXT_MAX_LENGTH = 300;
|
||||||
|
public static final int CLIPBOARD_TEXT_MAX_LENGTH = 4093;
|
||||||
private static final int RAW_BUFFER_SIZE = 1024;
|
private static final int RAW_BUFFER_SIZE = 1024;
|
||||||
|
|
||||||
private final byte[] rawBuffer = new byte[RAW_BUFFER_SIZE];
|
private final byte[] rawBuffer = new byte[RAW_BUFFER_SIZE];
|
||||||
private final ByteBuffer buffer = ByteBuffer.wrap(rawBuffer);
|
private final ByteBuffer buffer = ByteBuffer.wrap(rawBuffer);
|
||||||
private final byte[] textBuffer = new byte[TEXT_MAX_LENGTH];
|
private final byte[] textBuffer = new byte[CLIPBOARD_TEXT_MAX_LENGTH];
|
||||||
|
|
||||||
public ControlEventReader() {
|
public ControlEventReader() {
|
||||||
// invariant: the buffer is always in "get" mode
|
// invariant: the buffer is always in "get" mode
|
||||||
|
@ -63,6 +64,9 @@ public class ControlEventReader {
|
||||||
case ControlEvent.TYPE_SCROLL:
|
case ControlEvent.TYPE_SCROLL:
|
||||||
controlEvent = parseScrollControlEvent();
|
controlEvent = parseScrollControlEvent();
|
||||||
break;
|
break;
|
||||||
|
case ControlEvent.TYPE_SET_CLIPBOARD:
|
||||||
|
controlEvent = parseSetClipboardEvent();
|
||||||
|
break;
|
||||||
case ControlEvent.TYPE_BACK_OR_SCREEN_ON:
|
case ControlEvent.TYPE_BACK_OR_SCREEN_ON:
|
||||||
case ControlEvent.TYPE_EXPAND_NOTIFICATION_PANEL:
|
case ControlEvent.TYPE_EXPAND_NOTIFICATION_PANEL:
|
||||||
case ControlEvent.TYPE_COLLAPSE_NOTIFICATION_PANEL:
|
case ControlEvent.TYPE_COLLAPSE_NOTIFICATION_PANEL:
|
||||||
|
@ -132,6 +136,14 @@ public class ControlEventReader {
|
||||||
return ControlEvent.createScrollControlEvent(position, hScroll, vScroll);
|
return ControlEvent.createScrollControlEvent(position, hScroll, vScroll);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ControlEvent parseSetClipboardEvent() {
|
||||||
|
String text = parseString();
|
||||||
|
if (text == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ControlEvent.createSetClipboardControlEvent(text);
|
||||||
|
}
|
||||||
|
|
||||||
private static Position readPosition(ByteBuffer buffer) {
|
private static Position readPosition(ByteBuffer buffer) {
|
||||||
int x = buffer.getInt();
|
int x = buffer.getInt();
|
||||||
int y = buffer.getInt();
|
int y = buffer.getInt();
|
||||||
|
|
|
@ -147,6 +147,10 @@ public final class Device {
|
||||||
return s.toString();
|
return s.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setClipboardText(String text) {
|
||||||
|
serviceManager.getClipboardManager().setText(text);
|
||||||
|
}
|
||||||
|
|
||||||
static Rect flipRect(Rect crop) {
|
static Rect flipRect(Rect crop) {
|
||||||
return new Rect(crop.top, crop.left, crop.bottom, crop.right);
|
return new Rect(crop.top, crop.left, crop.bottom, crop.right);
|
||||||
}
|
}
|
||||||
|
|
|
@ -94,6 +94,9 @@ public class EventController {
|
||||||
String clipboardText = device.getClipboardText();
|
String clipboardText = device.getClipboardText();
|
||||||
sender.pushClipboardText(clipboardText);
|
sender.pushClipboardText(clipboardText);
|
||||||
break;
|
break;
|
||||||
|
case ControlEvent.TYPE_SET_CLIPBOARD:
|
||||||
|
device.setClipboardText(controlEvent.getText());
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
// do nothing
|
// do nothing
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,11 +9,13 @@ import java.lang.reflect.Method;
|
||||||
public class ClipboardManager {
|
public class ClipboardManager {
|
||||||
private final IInterface manager;
|
private final IInterface manager;
|
||||||
private final Method getPrimaryClipMethod;
|
private final Method getPrimaryClipMethod;
|
||||||
|
private final Method setPrimaryClipMethod;
|
||||||
|
|
||||||
public ClipboardManager(IInterface manager) {
|
public ClipboardManager(IInterface manager) {
|
||||||
this.manager = manager;
|
this.manager = manager;
|
||||||
try {
|
try {
|
||||||
getPrimaryClipMethod = manager.getClass().getMethod("getPrimaryClip", String.class);
|
getPrimaryClipMethod = manager.getClass().getMethod("getPrimaryClip", String.class);
|
||||||
|
setPrimaryClipMethod = manager.getClass().getMethod("setPrimaryClip", ClipData.class, String.class);
|
||||||
} catch (NoSuchMethodException e) {
|
} catch (NoSuchMethodException e) {
|
||||||
throw new AssertionError(e);
|
throw new AssertionError(e);
|
||||||
}
|
}
|
||||||
|
@ -30,4 +32,13 @@ public class ClipboardManager {
|
||||||
throw new AssertionError(e);
|
throw new AssertionError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setText(CharSequence text) {
|
||||||
|
ClipData clipData = ClipData.newPlainText(null, text);
|
||||||
|
try {
|
||||||
|
setPrimaryClipMethod.invoke(manager, clipData, "com.android.shell");
|
||||||
|
} catch (InvocationTargetException | IllegalAccessException e) {
|
||||||
|
throw new AssertionError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -190,6 +190,26 @@ public class ControlEventReaderTest {
|
||||||
Assert.assertEquals(ControlEvent.TYPE_GET_CLIPBOARD, event.getType());
|
Assert.assertEquals(ControlEvent.TYPE_GET_CLIPBOARD, event.getType());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testParseSetClipboardEvent() throws IOException {
|
||||||
|
ControlEventReader reader = new ControlEventReader();
|
||||||
|
|
||||||
|
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||||
|
DataOutputStream dos = new DataOutputStream(bos);
|
||||||
|
dos.writeByte(ControlEvent.TYPE_SET_CLIPBOARD);
|
||||||
|
byte[] text = "testé".getBytes(StandardCharsets.UTF_8);
|
||||||
|
dos.writeShort(text.length);
|
||||||
|
dos.write(text);
|
||||||
|
|
||||||
|
byte[] packet = bos.toByteArray();
|
||||||
|
|
||||||
|
reader.readFrom(new ByteArrayInputStream(packet));
|
||||||
|
ControlEvent event = reader.next();
|
||||||
|
|
||||||
|
Assert.assertEquals(ControlEvent.TYPE_SET_CLIPBOARD, event.getType());
|
||||||
|
Assert.assertEquals("testé", event.getText());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testMultiEvents() throws IOException {
|
public void testMultiEvents() throws IOException {
|
||||||
ControlEventReader reader = new ControlEventReader();
|
ControlEventReader reader = new ControlEventReader();
|
||||||
|
|
Loading…
Reference in a new issue