Skip to content

Commit

Permalink
For mozilla-mobile#9338 - Control what InlineAutocompleteEditText pastes
Browse files Browse the repository at this point in the history
Being an EditText InlineAutocompleteEditText offers a framework floating menu
for the most common text options.
Ensure we have more control over what the user pastes from the system clipboard
  • Loading branch information
Mugurell committed Jan 7, 2021
1 parent 03bb4d4 commit f171297
Show file tree
Hide file tree
Showing 5 changed files with 169 additions and 4 deletions.
2 changes: 2 additions & 0 deletions components/ui/autocomplete/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ dependencies {

implementation Dependencies.androidx_appcompat

implementation project(":support-utils")

testImplementation project(":support-test")

testImplementation Dependencies.androidx_test_junit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -424,4 +433,91 @@ 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 `GIVEN 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 `GIVEN 5 chars previous 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 with new text THEN we will display the new text`() {
val editText = spy(InlineAutocompleteEditText(testContext, attributes))
(testContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).apply {
setPrimaryClip(ClipData.newPlainText("Test", "test"))
}

assertEquals("", editText.text.toString())

editText.paste(0, 0, false)

assertEquals("test", editText.text.toString())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
mock-maker-inline
// This allows mocking final classes (classes are final by default in Kotlin)
3 changes: 3 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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".

Expand Down

0 comments on commit f171297

Please sign in to comment.