diff --git a/components/ui/autocomplete/build.gradle b/components/ui/autocomplete/build.gradle index 50e0e335b2b..e6e14b9ddda 100644 --- a/components/ui/autocomplete/build.gradle +++ b/components/ui/autocomplete/build.gradle @@ -28,6 +28,8 @@ dependencies { implementation Dependencies.androidx_appcompat + implementation project(":support-utils") + testImplementation project(":support-test") testImplementation Dependencies.androidx_test_junit diff --git a/components/ui/autocomplete/src/main/java/mozilla/components/ui/autocomplete/InlineAutocompleteEditText.kt b/components/ui/autocomplete/src/main/java/mozilla/components/ui/autocomplete/InlineAutocompleteEditText.kt index 570abab30d6..de9be43d84f 100644 --- a/components/ui/autocomplete/src/main/java/mozilla/components/ui/autocomplete/InlineAutocompleteEditText.kt +++ b/components/ui/autocomplete/src/main/java/mozilla/components/ui/autocomplete/InlineAutocompleteEditText.kt @@ -4,6 +4,7 @@ package mozilla.components.ui.autocomplete +import android.content.ClipboardManager import android.content.Context import android.content.Context.INPUT_METHOD_SERVICE import android.graphics.Rect @@ -13,6 +14,7 @@ import android.provider.Settings.Secure.getString import android.text.Editable import android.text.NoCopySpan import android.text.Selection +import android.text.Spannable import android.text.Spanned import android.text.TextUtils import android.text.TextWatcher @@ -30,6 +32,7 @@ import android.view.inputmethod.InputConnectionWrapper import android.view.inputmethod.InputMethodManager import androidx.annotation.VisibleForTesting import androidx.appcompat.widget.AppCompatEditText +import mozilla.components.support.utils.SafeUrl typealias OnCommitListener = () -> Unit typealias OnFilterListener = (String) -> Unit @@ -716,11 +719,27 @@ open class InlineAutocompleteEditText @JvmOverloads constructor( } override fun onTextContextMenuItem(id: Int): Boolean { - var newId = id - if (newId == android.R.id.paste && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - newId = android.R.id.pasteAsPlainText + // Ensure more control over what gets pasted from the framework floating menu. + // Behavior closely following the default implementation from TextView#onTextContextMenuItem(). + if (id == android.R.id.paste || id == android.R.id.pasteAsPlainText) { + val selectionStart = selectionStart + val selectionEnd = selectionEnd + + val min = 0.coerceAtLeast(selectionStart.coerceAtMost(selectionEnd)) + val max = 0.coerceAtLeast(selectionStart.coerceAtLeast(selectionEnd)) + + if (id == android.R.id.pasteAsPlainText || + (id == android.R.id.paste && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) + ) { + paste(min, max, false) + } else { + paste(min, max, true) + } + + return true // action was performed } - return super.onTextContextMenuItem(newId) + + return callOnTextContextMenuItemSuper(id) } @Suppress("ClickableViewAccessibility") @@ -750,6 +769,49 @@ open class InlineAutocompleteEditText @JvmOverloads constructor( } } + @VisibleForTesting + internal fun callOnTextContextMenuItemSuper(id: Int) = super.onTextContextMenuItem(id) + + /** + * Paste clipboard content between min and max positions. + * + * Method matching TextView#paste() but which also strips unwanted schemes before actually pasting. + */ + @Suppress("NestedBlockDepth") + @VisibleForTesting + internal fun paste(min: Int, max: Int, withFormatting: Boolean) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = clipboard.primaryClip + + if (clip != null) { + var didFirst = false + for (i in 0 until clip.itemCount) { + val textToBePasted: CharSequence? + textToBePasted = if (withFormatting) { + clip.getItemAt(i).coerceToStyledText(context) + } else { + // Get an item as text and remove all spans by toString(). + val text = clip.getItemAt(i).coerceToText(context) + (text as? Spanned)?.toString() ?: text + } + + // Actually stripping unwanted schemes + val safeTextToBePasted = SafeUrl.stripUnsafeUrlSchemes(context, textToBePasted) + + if (safeTextToBePasted != null) { + if (!didFirst) { + Selection.setSelection(editableText as Spannable?, max) + editableText.replace(min, max, safeTextToBePasted) + didFirst = true + } else { + editableText.insert(selectionEnd, "\n") + editableText.insert(selectionEnd, safeTextToBePasted) + } + } + } + } + } + companion object { internal val AUTOCOMPLETE_SPAN = NoCopySpan.Concrete() internal const val DEFAULT_AUTOCOMPLETE_BACKGROUND_COLOR = 0xffb5007f.toInt() diff --git a/components/ui/autocomplete/src/test/java/mozilla/components/ui/autocomplete/InlineAutocompleteEditTextTest.kt b/components/ui/autocomplete/src/test/java/mozilla/components/ui/autocomplete/InlineAutocompleteEditTextTest.kt index 4bd512c4be9..5c59b93c697 100644 --- a/components/ui/autocomplete/src/test/java/mozilla/components/ui/autocomplete/InlineAutocompleteEditTextTest.kt +++ b/components/ui/autocomplete/src/test/java/mozilla/components/ui/autocomplete/InlineAutocompleteEditTextTest.kt @@ -4,6 +4,10 @@ package mozilla.components.ui.autocomplete +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Build import android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE import android.view.KeyEvent import android.view.ViewParent @@ -20,12 +24,17 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mockito.anyInt import org.mockito.Mockito.doReturn import org.mockito.Mockito.mock import org.mockito.Mockito.never import org.mockito.Mockito.spy +import org.mockito.Mockito.times import org.mockito.Mockito.verify import org.robolectric.Robolectric.buildAttributeSet +import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) class InlineAutocompleteEditTextTest { @@ -424,4 +433,89 @@ class InlineAutocompleteEditTextTest { // assigning here so it verifies the setter, not the getter verify(et, never()).isEnabled = true } + + @Test + fun `WHEN onTextContextMenuItem is called for options other than paste THEN we should not paste() and just call super`() { + val editText = spy(InlineAutocompleteEditText(testContext, attributes)) + + editText.onTextContextMenuItem(android.R.id.copy) + editText.onTextContextMenuItem(android.R.id.shareText) + editText.onTextContextMenuItem(android.R.id.cut) + editText.onTextContextMenuItem(android.R.id.selectAll) + + verify(editText, never()).paste(anyInt(), anyInt(), anyBoolean()) + verify(editText, times(4)).callOnTextContextMenuItemSuper(anyInt()) + } + + @Test + fun `WHEN onTextContextMenuItem is called for paste THEN we should paste() and not call super`() { + val editText = spy(InlineAutocompleteEditText(testContext, attributes)) + + editText.onTextContextMenuItem(android.R.id.paste) + + verify(editText).paste(anyInt(), anyInt(), anyBoolean()) + verify(editText, never()).callOnTextContextMenuItemSuper(anyInt()) + } + + @Test + fun `WHEN onTextContextMenuItem is called for pasteAsPlainText THEN we should paste() and not call super`() { + val editText = spy(InlineAutocompleteEditText(testContext, attributes)) + + editText.onTextContextMenuItem(android.R.id.pasteAsPlainText) + + verify(editText).paste(anyInt(), anyInt(), anyBoolean()) + verify(editText, never()).callOnTextContextMenuItemSuper(anyInt()) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.LOLLIPOP, Build.VERSION_CODES.LOLLIPOP_MR1]) + fun `GIVEN an Android L device, WHEN onTextContextMenuItem is called for paste THEN we should paste() with formatting`() { + val editText = spy(InlineAutocompleteEditText(testContext, attributes)) + + editText.onTextContextMenuItem(android.R.id.paste) + + verify(editText).paste(anyInt(), anyInt(), eq(true)) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.M, Build.VERSION_CODES.N, Build.VERSION_CODES.O, Build.VERSION_CODES.P]) + fun `GIVEN an Android M device, WHEN onTextContextMenuItem is called for paste THEN we should paste() without formatting`() { + val editText = spy(InlineAutocompleteEditText(testContext, attributes)) + + editText.onTextContextMenuItem(android.R.id.paste) + + verify(editText).paste(anyInt(), anyInt(), eq(false)) + } + + @Test + fun `WHEN no previous text WHEN paste is selected THEN paste() should be called with 0,0`() { + val editText = spy(InlineAutocompleteEditText(testContext, attributes)) + + editText.onTextContextMenuItem(android.R.id.paste) + + verify(editText).paste(eq(0), eq(0), eq(false)) + } + + @Test + fun `WHEN 5 chars previous text text WHEN paste is selected THEN paste() should be called with 0,5`() { + val editText = spy(InlineAutocompleteEditText(testContext, attributes)) + editText.setText("chars") + editText.selectAll() + + editText.onTextContextMenuItem(android.R.id.paste) + + verify(editText).paste(eq(0), eq(5), eq(false)) + } + + @Test + fun `WHEN paste() is called `() { + val editText = spy(InlineAutocompleteEditText(testContext, attributes)) + (testContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).apply { + setPrimaryClip(ClipData.newPlainText("Test", "test")) + } + + editText.paste(0, 0, false) + + assertEquals("test", editText.text.toString()) + } } diff --git a/components/ui/autocomplete/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/components/ui/autocomplete/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 00000000000..cf1c399ea81 --- /dev/null +++ b/components/ui/autocomplete/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1,2 @@ +mock-maker-inline +// This allows mocking final classes (classes are final by default in Kotlin) diff --git a/docs/changelog.md b/docs/changelog.md index 7d9e2a9d3ed..f2e89adc523 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -12,6 +12,9 @@ permalink: /changelog/ * [Gecko](https://github.com/mozilla-mobile/android-components/blob/master/buildSrc/src/main/java/Gecko.kt) * [Configuration](https://github.com/mozilla-mobile/android-components/blob/master/.config.yml) +* **ui-autocomplete**: + * Pasting from the clipboard now cleans up any unwanted uri schemes. + * **support-utils**: * 🌟 Added SafeUrl#stripUnsafeUrlSchemes that can cleanup unwanted uri schemes. Interested clients can specify what these are by overwriting "mozac_url_schemes_blocklist".