From 9beac71a2e1105023a04984289abc94e78dfb790 Mon Sep 17 00:00:00 2001 From: "Edman P. Anjos" Date: Fri, 28 Feb 2020 20:18:04 +0100 Subject: [PATCH] Add support for software text editing controls (#15560) * 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. * Introduce InputConnectionAdaptor tests Run with: testing/run_tests.py --type=java --java-filter=io.flutter.plugin.editing.InputConnectionAdaptorTest * Fix BUILD.gn comment on run_tests.py --java-filter flag --- shell/platform/android/BUILD.gn | 2 +- .../editing/InputConnectionAdaptor.java | 118 ++++++++- .../editing/InputConnectionAdaptorTest.java | 227 +++++++++++++++++- 3 files changed, 338 insertions(+), 9 deletions(-) diff --git a/shell/platform/android/BUILD.gn b/shell/platform/android/BUILD.gn index 3a3b454be..e48562f1a 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" diff --git a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java index b751e5481..0fda81fd8 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 @@ class InputConnectionAdaptor extends BaseInputConnection { } } 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 @@ class InputConnectionAdaptor extends BaseInputConnection { 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; } 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 b366efb11..0a8d60f2d 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.spy; 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.junit.runner.RunWith; 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 class InputConnectionAdaptorTest { 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); + } } -- GitLab