From c13a24389cae489e01ba5a31f4ef6b384d1f8547 Mon Sep 17 00:00:00 2001 From: Romain Vimont Date: Thu, 30 May 2019 20:25:23 +0200 Subject: [PATCH] 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. --- README.md | 1 + app/src/control_event.c | 18 ++++++++-- app/src/control_event.h | 8 ++++- app/src/input_manager.c | 33 +++++++++++++++++-- app/src/main.c | 3 ++ app/tests/test_control_event_serialize.c | 21 ++++++++++++ .../com/genymobile/scrcpy/ControlEvent.java | 8 +++++ .../genymobile/scrcpy/ControlEventReader.java | 14 +++++++- .../java/com/genymobile/scrcpy/Device.java | 4 +++ .../genymobile/scrcpy/EventController.java | 3 ++ .../scrcpy/wrappers/ClipboardManager.java | 11 +++++++ .../scrcpy/ControlEventReaderTest.java | 20 +++++++++++ 12 files changed, 138 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index cef20b10..ef1424ba 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,7 @@ you are interested, see [issue 14]. | collapse notification panel | `Ctrl`+`Shift`+`n` | | copy device clipboard to computer | `Ctrl`+`c` | | paste computer clipboard to device | `Ctrl`+`v` | + | copy computer clipboard to device | `Ctrl`+`Shift+`v` | | enable/disable FPS counter (on stdout) | `Ctrl`+`i` | _¹Double-click on black borders to remove them._ diff --git a/app/src/control_event.c b/app/src/control_event.c index bd42ddb1..cc4e5b1d 100644 --- a/app/src/control_event.c +++ b/app/src/control_event.c @@ -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[17], (uint32_t) event->scroll_event.vscroll); 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_EXPAND_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 control_event_destroy(struct control_event *event) { - if (event->type == CONTROL_EVENT_TYPE_TEXT) { - SDL_free(event->text_event.text); + switch (event->type) { + 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; } } diff --git a/app/src/control_event.h b/app/src/control_event.h index cc5afa70..c381af26 100644 --- a/app/src/control_event.h +++ b/app/src/control_event.h @@ -10,7 +10,9 @@ #include "common.h" #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 { CONTROL_EVENT_TYPE_KEYCODE, @@ -21,6 +23,7 @@ enum control_event_type { CONTROL_EVENT_TYPE_EXPAND_NOTIFICATION_PANEL, CONTROL_EVENT_TYPE_COLLAPSE_NOTIFICATION_PANEL, CONTROL_EVENT_TYPE_GET_CLIPBOARD, + CONTROL_EVENT_TYPE_SET_CLIPBOARD, }; struct control_event { @@ -44,6 +47,9 @@ struct control_event { int32_t hscroll; int32_t vscroll; } scroll_event; + struct { + char *text; // owned, to be freed by SDL_free() + } set_clipboard_event; }; }; diff --git a/app/src/input_manager.c b/app/src/input_manager.c index 3940f4f8..23073cbc 100644 --- a/app/src/input_manager.c +++ b/app/src/input_manager.c @@ -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 switch_fps_counter_state(struct video_buffer *vb) { mutex_lock(vb->mutex); @@ -267,9 +290,15 @@ input_manager_process_key(struct input_manager *input_manager, } return; case SDLK_v: - if (control && ctrl && !meta && !shift && !repeat + if (control && ctrl && !meta && !repeat && 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; case SDLK_f: diff --git a/app/src/main.c b/app/src/main.c index 8b415909..be611025 100644 --- a/app/src/main.c +++ b/app/src/main.c @@ -143,6 +143,9 @@ static void usage(const char *arg0) { " Ctrl+v\n" " paste computer clipboard to device\n" "\n" + " Ctrl+Shift+v\n" + " copy computer clipboard to device\n" + "\n" " Ctrl+i\n" " enable/disable FPS counter (print frames/second in logs)\n" "\n" diff --git a/app/tests/test_control_event_serialize.c b/app/tests/test_control_event_serialize.c index 038d913d..d9cd0b76 100644 --- a/app/tests/test_control_event_serialize.c +++ b/app/tests/test_control_event_serialize.c @@ -193,6 +193,26 @@ static void test_serialize_get_clipboard_event(void) { 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) { test_serialize_keycode_event(); test_serialize_text_event(); @@ -203,5 +223,6 @@ int main(void) { test_serialize_expand_notification_panel_event(); test_serialize_collapse_notification_panel_event(); test_serialize_get_clipboard_event(); + test_serialize_set_clipboard_event(); return 0; } diff --git a/server/src/main/java/com/genymobile/scrcpy/ControlEvent.java b/server/src/main/java/com/genymobile/scrcpy/ControlEvent.java index 1784c953..51360560 100644 --- a/server/src/main/java/com/genymobile/scrcpy/ControlEvent.java +++ b/server/src/main/java/com/genymobile/scrcpy/ControlEvent.java @@ -13,6 +13,7 @@ public final class ControlEvent { public static final int TYPE_EXPAND_NOTIFICATION_PANEL = 5; public static final int TYPE_COLLAPSE_NOTIFICATION_PANEL = 6; public static final int TYPE_GET_CLIPBOARD = 7; + public static final int TYPE_SET_CLIPBOARD = 8; private int type; private String text; @@ -61,6 +62,13 @@ public final class ControlEvent { 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) { ControlEvent event = new ControlEvent(); event.type = type; diff --git a/server/src/main/java/com/genymobile/scrcpy/ControlEventReader.java b/server/src/main/java/com/genymobile/scrcpy/ControlEventReader.java index c48b1826..ec807232 100644 --- a/server/src/main/java/com/genymobile/scrcpy/ControlEventReader.java +++ b/server/src/main/java/com/genymobile/scrcpy/ControlEventReader.java @@ -13,11 +13,12 @@ public class ControlEventReader { private static final int SCROLL_PAYLOAD_LENGTH = 20; 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 final byte[] rawBuffer = new byte[RAW_BUFFER_SIZE]; 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() { // invariant: the buffer is always in "get" mode @@ -63,6 +64,9 @@ public class ControlEventReader { case ControlEvent.TYPE_SCROLL: controlEvent = parseScrollControlEvent(); break; + case ControlEvent.TYPE_SET_CLIPBOARD: + controlEvent = parseSetClipboardEvent(); + break; case ControlEvent.TYPE_BACK_OR_SCREEN_ON: case ControlEvent.TYPE_EXPAND_NOTIFICATION_PANEL: case ControlEvent.TYPE_COLLAPSE_NOTIFICATION_PANEL: @@ -132,6 +136,14 @@ public class ControlEventReader { 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) { int x = buffer.getInt(); int y = buffer.getInt(); diff --git a/server/src/main/java/com/genymobile/scrcpy/Device.java b/server/src/main/java/com/genymobile/scrcpy/Device.java index c373d390..f791266b 100644 --- a/server/src/main/java/com/genymobile/scrcpy/Device.java +++ b/server/src/main/java/com/genymobile/scrcpy/Device.java @@ -147,6 +147,10 @@ public final class Device { return s.toString(); } + public void setClipboardText(String text) { + serviceManager.getClipboardManager().setText(text); + } + static Rect flipRect(Rect crop) { return new Rect(crop.top, crop.left, crop.bottom, crop.right); } diff --git a/server/src/main/java/com/genymobile/scrcpy/EventController.java b/server/src/main/java/com/genymobile/scrcpy/EventController.java index bec25103..e8cd2d68 100644 --- a/server/src/main/java/com/genymobile/scrcpy/EventController.java +++ b/server/src/main/java/com/genymobile/scrcpy/EventController.java @@ -94,6 +94,9 @@ public class EventController { String clipboardText = device.getClipboardText(); sender.pushClipboardText(clipboardText); break; + case ControlEvent.TYPE_SET_CLIPBOARD: + device.setClipboardText(controlEvent.getText()); + break; default: // do nothing } 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 60ce5f13..a058a8bb 100644 --- a/server/src/main/java/com/genymobile/scrcpy/wrappers/ClipboardManager.java +++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/ClipboardManager.java @@ -9,11 +9,13 @@ import java.lang.reflect.Method; public class ClipboardManager { private final IInterface manager; private final Method getPrimaryClipMethod; + private final Method setPrimaryClipMethod; public ClipboardManager(IInterface manager) { this.manager = manager; try { getPrimaryClipMethod = manager.getClass().getMethod("getPrimaryClip", String.class); + setPrimaryClipMethod = manager.getClass().getMethod("setPrimaryClip", ClipData.class, String.class); } catch (NoSuchMethodException e) { throw new AssertionError(e); } @@ -30,4 +32,13 @@ public class ClipboardManager { 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); + } + } } diff --git a/server/src/test/java/com/genymobile/scrcpy/ControlEventReaderTest.java b/server/src/test/java/com/genymobile/scrcpy/ControlEventReaderTest.java index d19d1bc6..692b5d23 100644 --- a/server/src/test/java/com/genymobile/scrcpy/ControlEventReaderTest.java +++ b/server/src/test/java/com/genymobile/scrcpy/ControlEventReaderTest.java @@ -190,6 +190,26 @@ public class ControlEventReaderTest { 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 public void testMultiEvents() throws IOException { ControlEventReader reader = new ControlEventReader();