From 69c63db7f1d3d06ce4bd6405d6a8ac4375a1748f Mon Sep 17 00:00:00 2001 From: Edman Anjos Date: Fri, 21 Feb 2020 20:59:24 +0100 Subject: [PATCH 1/3] Add support for software text editing controls Includes selection, copy, cut, paste, as well as partial support for up and down movement. Text editing controls can be accessed in GBoard by: top-left arrow > three dots menu > text editing Partial fix for flutter/flutter#9419 and flutter/flutter#37371. --- .../editing/InputConnectionAdaptor.java | 118 ++++++++++++++++-- 1 file changed, 111 insertions(+), 7 deletions(-) diff --git a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java index b751e548118c2..0fda81fd8930c 100644 --- a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java +++ b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java @@ -5,6 +5,8 @@ package io.flutter.plugin.editing; import android.annotation.SuppressLint; +import android.content.ClipData; +import android.content.ClipboardManager; import android.content.Context; import android.os.Build; import android.provider.Settings; @@ -267,13 +269,53 @@ public boolean sendKeyEvent(KeyEvent event) { } } else if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_LEFT) { int selStart = Selection.getSelectionStart(mEditable); - int newSel = Math.max(selStart - 1, 0); - setSelection(newSel, newSel); + int selEnd = Selection.getSelectionEnd(mEditable); + if (selStart == selEnd && !event.isShiftPressed()) { + int newSel = Math.max(selStart - 1, 0); + setSelection(newSel, newSel); + } else { + int newSelEnd = Math.max(selEnd - 1, 0); + setSelection(selStart, newSelEnd); + } return true; } else if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_RIGHT) { int selStart = Selection.getSelectionStart(mEditable); - int newSel = Math.min(selStart + 1, mEditable.length()); - setSelection(newSel, newSel); + int selEnd = Selection.getSelectionEnd(mEditable); + if (selStart == selEnd && !event.isShiftPressed()) { + int newSel = Math.min(selStart + 1, mEditable.length()); + setSelection(newSel, newSel); + } else { + int newSelEnd = Math.min(selEnd + 1, mEditable.length()); + setSelection(selStart, newSelEnd); + } + return true; + } else if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_UP) { + int selStart = Selection.getSelectionStart(mEditable); + int selEnd = Selection.getSelectionEnd(mEditable); + if (selStart == selEnd && !event.isShiftPressed()) { + Selection.moveUp(mEditable, mLayout); + int newSelStart = Selection.getSelectionStart(mEditable); + setSelection(newSelStart, newSelStart); + } else { + Selection.extendUp(mEditable, mLayout); + int newSelStart = Selection.getSelectionStart(mEditable); + int newSelEnd = Selection.getSelectionEnd(mEditable); + setSelection(newSelStart, newSelEnd); + } + return true; + } else if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_DOWN) { + int selStart = Selection.getSelectionStart(mEditable); + int selEnd = Selection.getSelectionEnd(mEditable); + if (selStart == selEnd && !event.isShiftPressed()) { + Selection.moveDown(mEditable, mLayout); + int newSelStart = Selection.getSelectionStart(mEditable); + setSelection(newSelStart, newSelStart); + } else { + Selection.extendDown(mEditable, mLayout); + int newSelStart = Selection.getSelectionStart(mEditable); + int newSelEnd = Selection.getSelectionEnd(mEditable); + setSelection(newSelStart, newSelEnd); + } return true; // When the enter key is pressed on a non-multiline field, consider it a // submit instead of a newline. @@ -288,13 +330,75 @@ public boolean sendKeyEvent(KeyEvent event) { if (character != 0) { int selStart = Math.max(0, Selection.getSelectionStart(mEditable)); int selEnd = Math.max(0, Selection.getSelectionEnd(mEditable)); - if (selEnd != selStart) mEditable.delete(selStart, selEnd); - mEditable.insert(selStart, String.valueOf((char) character)); - setSelection(selStart + 1, selStart + 1); + int selMin = Math.min(selStart, selEnd); + int selMax = Math.max(selStart, selEnd); + if (selMin != selMax) mEditable.delete(selMin, selMax); + mEditable.insert(selMin, String.valueOf((char) character)); + setSelection(selMin + 1, selMin + 1); } return true; } } + if (event.getAction() == KeyEvent.ACTION_UP + && (event.getKeyCode() == KeyEvent.KEYCODE_SHIFT_LEFT + || event.getKeyCode() == KeyEvent.KEYCODE_SHIFT_RIGHT)) { + int selEnd = Selection.getSelectionEnd(mEditable); + setSelection(selEnd, selEnd); + return true; + } + return false; + } + + @Override + public boolean performContextMenuAction(int id) { + if (id == android.R.id.selectAll) { + setSelection(0, mEditable.length()); + return true; + } else if (id == android.R.id.cut) { + int selStart = Selection.getSelectionStart(mEditable); + int selEnd = Selection.getSelectionEnd(mEditable); + if (selStart != selEnd) { + int selMin = Math.min(selStart, selEnd); + int selMax = Math.max(selStart, selEnd); + CharSequence textToCut = mEditable.subSequence(selMin, selMax); + ClipboardManager clipboard = + (ClipboardManager) + mFlutterView.getContext().getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clip = ClipData.newPlainText("text label?", textToCut); + clipboard.setPrimaryClip(clip); + mEditable.delete(selMin, selMax); + setSelection(selMin, selMin); + } + return true; + } else if (id == android.R.id.copy) { + int selStart = Selection.getSelectionStart(mEditable); + int selEnd = Selection.getSelectionEnd(mEditable); + if (selStart != selEnd) { + CharSequence textToCopy = + mEditable.subSequence(Math.min(selStart, selEnd), Math.max(selStart, selEnd)); + ClipboardManager clipboard = + (ClipboardManager) + mFlutterView.getContext().getSystemService(Context.CLIPBOARD_SERVICE); + clipboard.setPrimaryClip(ClipData.newPlainText("text label?", textToCopy)); + } + return true; + } else if (id == android.R.id.paste) { + ClipboardManager clipboard = + (ClipboardManager) mFlutterView.getContext().getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clip = clipboard.getPrimaryClip(); + if (clip != null) { + CharSequence textToPaste = clip.getItemAt(0).coerceToText(mFlutterView.getContext()); + int selStart = Math.max(0, Selection.getSelectionStart(mEditable)); + int selEnd = Math.max(0, Selection.getSelectionEnd(mEditable)); + int selMin = Math.min(selStart, selEnd); + int selMax = Math.max(selStart, selEnd); + if (selMin != selMax) mEditable.delete(selMin, selMax); + mEditable.insert(selMin, textToPaste); + int newSelStart = selMin + textToPaste.length(); + setSelection(newSelStart, newSelStart); + } + return true; + } return false; } From 6802529b49f78df4d5f8ffcd9c83aa5ebdf6ee78 Mon Sep 17 00:00:00 2001 From: Edman Anjos Date: Fri, 17 Jan 2020 00:00:28 +0100 Subject: [PATCH 2/3] Introduce InputConnectionAdaptor tests Run with: testing/run_tests.py --type=java --java-filter=io.flutter.plugin.editing.InputConnectionAdaptorTest --- .../editing/InputConnectionAdaptorTest.java | 227 +++++++++++++++++- 1 file changed, 226 insertions(+), 1 deletion(-) diff --git a/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java b/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java index b366efb110191..0a8d60f2d732a 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java @@ -1,5 +1,8 @@ package io.flutter.plugin.editing; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.anyString; import static org.mockito.Mockito.eq; import static org.mockito.Mockito.mock; @@ -7,9 +10,12 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import android.content.Context; import android.content.res.AssetManager; import android.text.Editable; import android.text.InputType; +import android.text.Selection; +import android.text.SpannableStringBuilder; import android.view.KeyEvent; import android.view.View; import android.view.inputmethod.EditorInfo; @@ -22,8 +28,10 @@ import org.robolectric.RobolectricTestRunner; import org.robolectric.RuntimeEnvironment; import org.robolectric.annotation.Config; +import org.robolectric.shadow.api.Shadow; +import org.robolectric.shadows.ShadowClipboardManager; -@Config(manifest = Config.NONE, sdk = 27) +@Config(manifest = Config.NONE, sdk = 27, shadows = ShadowClipboardManager.class) @RunWith(RobolectricTestRunner.class) public class InputConnectionAdaptorTest { @Test @@ -47,4 +55,221 @@ public void inputConnectionAdaptor_ReceivesEnter() throws NullPointerException { inputConnectionAdaptor.sendKeyEvent(keyEvent); verify(spyEditable, times(1)).insert(eq(0), anyString()); } + + @Test + public void testPerformContextMenuAction_selectAll() { + int selStart = 5; + Editable editable = sampleEditable(selStart, selStart); + InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); + + boolean didConsume = adaptor.performContextMenuAction(android.R.id.selectAll); + + assertTrue(didConsume); + assertEquals(0, Selection.getSelectionStart(editable)); + assertEquals(editable.length(), Selection.getSelectionEnd(editable)); + } + + @Test + public void testPerformContextMenuAction_cut() { + ShadowClipboardManager clipboardManager = + Shadow.extract(RuntimeEnvironment.application.getSystemService(Context.CLIPBOARD_SERVICE)); + int selStart = 6; + int selEnd = 11; + Editable editable = sampleEditable(selStart, selEnd); + CharSequence textToBeCut = editable.subSequence(selStart, selEnd); + InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); + + boolean didConsume = adaptor.performContextMenuAction(android.R.id.cut); + + assertTrue(didConsume); + assertTrue(clipboardManager.hasText()); + assertEquals(textToBeCut, clipboardManager.getPrimaryClip().getItemAt(0).getText()); + assertFalse(editable.toString().contains(textToBeCut)); + } + + @Test + public void testPerformContextMenuAction_copy() { + ShadowClipboardManager clipboardManager = + Shadow.extract(RuntimeEnvironment.application.getSystemService(Context.CLIPBOARD_SERVICE)); + int selStart = 6; + int selEnd = 11; + Editable editable = sampleEditable(selStart, selEnd); + InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); + + assertFalse(clipboardManager.hasText()); + + boolean didConsume = adaptor.performContextMenuAction(android.R.id.copy); + + assertTrue(didConsume); + assertTrue(clipboardManager.hasText()); + assertEquals( + editable.subSequence(selStart, selEnd), + clipboardManager.getPrimaryClip().getItemAt(0).getText()); + } + + @Test + public void testPerformContextMenuAction_paste() { + ShadowClipboardManager clipboardManager = + Shadow.extract(RuntimeEnvironment.application.getSystemService(Context.CLIPBOARD_SERVICE)); + String textToBePasted = "deadbeef"; + clipboardManager.setText(textToBePasted); + Editable editable = sampleEditable(0, 0); + InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); + + boolean didConsume = adaptor.performContextMenuAction(android.R.id.paste); + + assertTrue(didConsume); + assertTrue(editable.toString().startsWith(textToBePasted)); + } + + @Test + public void testSendKeyEvent_shiftKeyUpCancelsSelection() { + int selStart = 5; + int selEnd = 10; + Editable editable = sampleEditable(selStart, selEnd); + InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); + + KeyEvent shiftKeyUp = new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_SHIFT_LEFT); + boolean didConsume = adaptor.sendKeyEvent(shiftKeyUp); + + assertTrue(didConsume); + assertEquals(selEnd, Selection.getSelectionStart(editable)); + assertEquals(selEnd, Selection.getSelectionEnd(editable)); + } + + @Test + public void testSendKeyEvent_leftKeyMovesCaretLeft() { + int selStart = 5; + Editable editable = sampleEditable(selStart, selStart); + InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); + + KeyEvent leftKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_LEFT); + boolean didConsume = adaptor.sendKeyEvent(leftKeyDown); + + assertTrue(didConsume); + assertEquals(selStart - 1, Selection.getSelectionStart(editable)); + assertEquals(selStart - 1, Selection.getSelectionEnd(editable)); + } + + @Test + public void testSendKeyEvent_leftKeyExtendsSelectionLeft() { + int selStart = 5; + int selEnd = 40; + Editable editable = sampleEditable(selStart, selEnd); + InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); + + KeyEvent leftKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_LEFT); + boolean didConsume = adaptor.sendKeyEvent(leftKeyDown); + + assertTrue(didConsume); + assertEquals(selStart, Selection.getSelectionStart(editable)); + assertEquals(selEnd - 1, Selection.getSelectionEnd(editable)); + } + + @Test + public void testSendKeyEvent_shiftLeftKeyStartsSelectionLeft() { + int selStart = 5; + Editable editable = sampleEditable(selStart, selStart); + InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); + + KeyEvent shiftLeftKeyDown = + new KeyEvent( + 0, 0, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_LEFT, 0, KeyEvent.META_SHIFT_ON); + boolean didConsume = adaptor.sendKeyEvent(shiftLeftKeyDown); + + assertTrue(didConsume); + assertEquals(selStart, Selection.getSelectionStart(editable)); + assertEquals(selStart - 1, Selection.getSelectionEnd(editable)); + } + + @Test + public void testSendKeyEvent_rightKeyMovesCaretRight() { + int selStart = 5; + Editable editable = sampleEditable(selStart, selStart); + InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); + + KeyEvent rightKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT); + boolean didConsume = adaptor.sendKeyEvent(rightKeyDown); + + assertTrue(didConsume); + assertEquals(selStart + 1, Selection.getSelectionStart(editable)); + assertEquals(selStart + 1, Selection.getSelectionEnd(editable)); + } + + @Test + public void testSendKeyEvent_rightKeyExtendsSelectionRight() { + int selStart = 5; + int selEnd = 40; + Editable editable = sampleEditable(selStart, selEnd); + InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); + + KeyEvent rightKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT); + boolean didConsume = adaptor.sendKeyEvent(rightKeyDown); + + assertTrue(didConsume); + assertEquals(selStart, Selection.getSelectionStart(editable)); + assertEquals(selEnd + 1, Selection.getSelectionEnd(editable)); + } + + @Test + public void testSendKeyEvent_shiftRightKeyStartsSelectionRight() { + int selStart = 5; + Editable editable = sampleEditable(selStart, selStart); + InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); + + KeyEvent shiftRightKeyDown = + new KeyEvent( + 0, 0, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT, 0, KeyEvent.META_SHIFT_ON); + boolean didConsume = adaptor.sendKeyEvent(shiftRightKeyDown); + + assertTrue(didConsume); + assertEquals(selStart, Selection.getSelectionStart(editable)); + assertEquals(selStart + 1, Selection.getSelectionEnd(editable)); + } + + @Test + public void testSendKeyEvent_upKeyMovesCaretUp() { + int selStart = SAMPLE_TEXT.indexOf('\n') + 4; + Editable editable = sampleEditable(selStart, selStart); + InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); + + KeyEvent upKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_UP); + boolean didConsume = adaptor.sendKeyEvent(upKeyDown); + + assertTrue(didConsume); + // Checks the caret moved left (to some previous character). Selection.moveUp() behaves + // different in tests than on a real device, we can't verify the exact position. + assertTrue(Selection.getSelectionStart(editable) < selStart); + } + + @Test + public void testSendKeyEvent_downKeyMovesCaretDown() { + int selStart = 4; + Editable editable = sampleEditable(selStart, selStart); + InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); + + KeyEvent downKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_DOWN); + boolean didConsume = adaptor.sendKeyEvent(downKeyDown); + + assertTrue(didConsume); + // Checks the caret moved right (to some following character). Selection.moveDown() behaves + // different in tests than on a real device, we can't verify the exact position. + assertTrue(Selection.getSelectionStart(editable) > selStart); + } + + private static final String SAMPLE_TEXT = + "Lorem ipsum dolor sit amet," + "\nconsectetur adipiscing elit."; + + private static Editable sampleEditable(int selStart, int selEnd) { + SpannableStringBuilder sample = new SpannableStringBuilder(SAMPLE_TEXT); + Selection.setSelection(sample, selStart, selEnd); + return sample; + } + + private static InputConnectionAdaptor sampleInputConnectionAdaptor(Editable editable) { + View testView = new View(RuntimeEnvironment.application); + int client = 0; + TextInputChannel textInputChannel = mock(TextInputChannel.class); + return new InputConnectionAdaptor(testView, client, textInputChannel, editable, null); + } } From a501e490d2978e10fd93ded5e9ea7d49ccd51e9b Mon Sep 17 00:00:00 2001 From: Edman Anjos Date: Thu, 16 Jan 2020 22:32:43 +0100 Subject: [PATCH 3/3] Fix BUILD.gn comment on run_tests.py --java-filter flag --- shell/platform/android/BUILD.gn | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shell/platform/android/BUILD.gn b/shell/platform/android/BUILD.gn index 3a3b454beec82..e48562f1a2957 100644 --- a/shell/platform/android/BUILD.gn +++ b/shell/platform/android/BUILD.gn @@ -406,7 +406,7 @@ action("pom_embedding") { } # To build and run: -# testing/run_tests.py [--type=java] [--filter=io.flutter.TestClassName] +# testing/run_tests.py [--type=java] [--java-filter=io.flutter.TestClassName] action("robolectric_tests") { script = "//build/android/gyp/javac.py" depfile = "$target_gen_dir/$target_name.d"