diff --git a/shell/platform/android/BUILD.gn b/shell/platform/android/BUILD.gn index 3a3b454beec82e9dba9cc4b323c6c3cdd39cc3f8..e48562f1a29575b9d151aa40be66720d27ae3b38 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 b751e548118c28cb84d7c141099db7b6bdeffdf3..0fda81fd8930c3decc6da8f76bcae7d42f2831ce 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 b366efb1101914cd8694cd91838921bd93562783..0a8d60f2d732a958184676e4cb7bbc7663315cf3 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); + } }