summaryrefslogtreecommitdiffstats
path: root/mobile/android/android-components/components/ui/autocomplete
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:34:42 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:34:42 +0000
commitda4c7e7ed675c3bf405668739c3012d140856109 (patch)
treecdd868dba063fecba609a1d819de271f0d51b23e /mobile/android/android-components/components/ui/autocomplete
parentAdding upstream version 125.0.3. (diff)
downloadfirefox-da4c7e7ed675c3bf405668739c3012d140856109.tar.xz
firefox-da4c7e7ed675c3bf405668739c3012d140856109.zip
Adding upstream version 126.0.upstream/126.0
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'mobile/android/android-components/components/ui/autocomplete')
-rw-r--r--mobile/android/android-components/components/ui/autocomplete/README.md19
-rw-r--r--mobile/android/android-components/components/ui/autocomplete/build.gradle40
-rw-r--r--mobile/android/android-components/components/ui/autocomplete/proguard-rules.pro21
-rw-r--r--mobile/android/android-components/components/ui/autocomplete/src/main/AndroidManifest.xml4
-rw-r--r--mobile/android/android-components/components/ui/autocomplete/src/main/java/mozilla/components/ui/autocomplete/InlineAutocompleteEditText.kt905
-rw-r--r--mobile/android/android-components/components/ui/autocomplete/src/main/res/values/attrs.xml9
-rw-r--r--mobile/android/android-components/components/ui/autocomplete/src/test/java/mozilla/components/ui/autocomplete/InlineAutocompleteEditTextTest.kt585
-rw-r--r--mobile/android/android-components/components/ui/autocomplete/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker2
-rw-r--r--mobile/android/android-components/components/ui/autocomplete/src/test/resources/robolectric.properties1
9 files changed, 1586 insertions, 0 deletions
diff --git a/mobile/android/android-components/components/ui/autocomplete/README.md b/mobile/android/android-components/components/ui/autocomplete/README.md
new file mode 100644
index 0000000000..da22f2fea5
--- /dev/null
+++ b/mobile/android/android-components/components/ui/autocomplete/README.md
@@ -0,0 +1,19 @@
+# [Android Components](../../../README.md) > UI > Autocomplete
+
+A set of components to provide autocomplete functionality.
+
+## Usage
+
+### Setting up the dependency
+
+Use Gradle to download the library from [maven.mozilla.org](https://maven.mozilla.org/) ([Setup repository](../../../README.md#maven-repository)):
+
+```Groovy
+implementation "org.mozilla.components:ui-autocomplete:{latest-version}"
+```
+
+## License
+
+ This Source Code Form is subject to the terms of the Mozilla Public
+ License, v. 2.0. If a copy of the MPL was not distributed with this
+ file, You can obtain one at http://mozilla.org/MPL/2.0/
diff --git a/mobile/android/android-components/components/ui/autocomplete/build.gradle b/mobile/android/android-components/components/ui/autocomplete/build.gradle
new file mode 100644
index 0000000000..16f9792a02
--- /dev/null
+++ b/mobile/android/android-components/components/ui/autocomplete/build.gradle
@@ -0,0 +1,40 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+
+android {
+ defaultConfig {
+ minSdkVersion config.minSdkVersion
+ compileSdk config.compileSdkVersion
+ targetSdkVersion config.targetSdkVersion
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ namespace 'mozilla.components.ui.autocomplete'
+}
+
+dependencies {
+ implementation ComponentsDependencies.androidx_appcompat
+
+ implementation project(":support-base")
+ implementation project(":support-utils")
+
+ testImplementation project(":support-test")
+
+ testImplementation ComponentsDependencies.androidx_test_junit
+ testImplementation ComponentsDependencies.testing_robolectric
+}
+
+apply from: '../../../publish.gradle'
+ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description)
diff --git a/mobile/android/android-components/components/ui/autocomplete/proguard-rules.pro b/mobile/android/android-components/components/ui/autocomplete/proguard-rules.pro
new file mode 100644
index 0000000000..f1b424510d
--- /dev/null
+++ b/mobile/android/android-components/components/ui/autocomplete/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/mobile/android/android-components/components/ui/autocomplete/src/main/AndroidManifest.xml b/mobile/android/android-components/components/ui/autocomplete/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..e16cda1d34
--- /dev/null
+++ b/mobile/android/android-components/components/ui/autocomplete/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<manifest />
diff --git a/mobile/android/android-components/components/ui/autocomplete/src/main/java/mozilla/components/ui/autocomplete/InlineAutocompleteEditText.kt b/mobile/android/android-components/components/ui/autocomplete/src/main/java/mozilla/components/ui/autocomplete/InlineAutocompleteEditText.kt
new file mode 100644
index 0000000000..c029f226eb
--- /dev/null
+++ b/mobile/android/android-components/components/ui/autocomplete/src/main/java/mozilla/components/ui/autocomplete/InlineAutocompleteEditText.kt
@@ -0,0 +1,905 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.ui.autocomplete
+
+import android.content.ClipboardManager
+import android.content.Context
+import android.content.Context.INPUT_METHOD_SERVICE
+import android.graphics.Rect
+import android.os.Build
+import android.provider.Settings.Secure.DEFAULT_INPUT_METHOD
+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
+import android.text.style.BackgroundColorSpan
+import android.text.style.ForegroundColorSpan
+import android.util.AttributeSet
+import android.view.KeyEvent
+import android.view.MotionEvent
+import android.view.View
+import android.view.accessibility.AccessibilityEvent
+import android.view.inputmethod.BaseInputConnection
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputConnection
+import android.view.inputmethod.InputConnectionWrapper
+import android.view.inputmethod.InputMethodManager
+import androidx.annotation.VisibleForTesting
+import androidx.appcompat.widget.AppCompatEditText
+import mozilla.components.support.base.Component
+import mozilla.components.support.base.facts.Action
+import mozilla.components.support.base.facts.Fact
+import mozilla.components.support.base.facts.collect
+import mozilla.components.support.utils.SafeUrl
+import androidx.appcompat.R as appcompatR
+
+typealias OnCommitListener = () -> Unit
+typealias OnFilterListener = (String) -> Unit
+typealias OnSearchStateChangeListener = (Boolean) -> Unit
+typealias OnTextChangeListener = (String, String) -> Unit
+typealias OnDispatchKeyEventPreImeListener = (KeyEvent?) -> Boolean
+typealias OnKeyPreImeListener = (View, Int, KeyEvent) -> Boolean
+typealias OnSelectionChangedListener = (Int, Int) -> Unit
+typealias OnWindowsFocusChangeListener = (Boolean) -> Unit
+
+typealias TextFormatter = (String) -> String
+
+/**
+ * Aids in testing functionality which relies on some aspects of InlineAutocompleteEditText.
+ */
+interface AutocompleteView {
+
+ /**
+ * Current text.
+ */
+ val originalText: String
+
+ /**
+ * Apply provided [result] autocomplete result.
+ */
+ fun applyAutocompleteResult(result: InlineAutocompleteEditText.AutocompleteResult)
+
+ /**
+ * Notify that there is no autocomplete result available.
+ */
+ fun noAutocompleteResult()
+}
+
+/**
+ * A UI edit text component which supports inline autocompletion.
+ *
+ * The background color of autocomplete spans can be configured using
+ * the custom autocompleteBackgroundColor attribute e.g.
+ * app:autocompleteBackgroundColor="#ffffff".
+ *
+ * A filter listener (see [setOnFilterListener]) needs to be attached to
+ * provide autocomplete results. It will be invoked when the input
+ * text changes. The listener gets direct access to this component (via its view
+ * parameter), so it can call {@link applyAutocompleteResult} in return.
+ *
+ * A commit listener (see [setOnCommitListener]) can be attached which is
+ * invoked when the user selected the result i.e. is done editing.
+ *
+ * Various other listeners can be attached to enhance default behaviour e.g.
+ * [setOnSelectionChangedListener] and [setOnWindowsFocusChangeListener] which
+ * will be invoked in response to [onSelectionChanged] and [onWindowFocusChanged]
+ * respectively (see also [setOnTextChangeListener],
+ * [setOnSelectionChangedListener], and [setOnWindowsFocusChangeListener]).
+ */
+@Suppress("LargeClass", "TooManyFunctions")
+open class InlineAutocompleteEditText @JvmOverloads constructor(
+ ctx: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = appcompatR.attr.editTextStyle,
+) : AppCompatEditText(ctx, attrs, defStyleAttr), AutocompleteView {
+
+ data class AutocompleteResult(
+ val text: String,
+ val source: String,
+ val totalItems: Int,
+ ) {
+ fun startsWith(text: String): Boolean = this.text.startsWith(text)
+ }
+
+ private var commitListener: OnCommitListener? = null
+ fun setOnCommitListener(l: OnCommitListener) { commitListener = l }
+
+ private var filterListener: OnFilterListener? = null
+ fun setOnFilterListener(l: OnFilterListener) { filterListener = l }
+ fun refreshAutocompleteSuggestions() { filterListener?.invoke(originalText) }
+
+ private var searchStateChangeListener: OnSearchStateChangeListener? = null
+ fun setOnSearchStateChangeListener(l: OnSearchStateChangeListener) { searchStateChangeListener = l }
+
+ private var textChangeListener: OnTextChangeListener? = null
+ fun setOnTextChangeListener(l: OnTextChangeListener) { textChangeListener = l }
+
+ private var dispatchKeyEventPreImeListener: OnDispatchKeyEventPreImeListener? = null
+ fun setOnDispatchKeyEventPreImeListener(l: OnDispatchKeyEventPreImeListener?) { dispatchKeyEventPreImeListener = l }
+
+ private var keyPreImeListener: OnKeyPreImeListener? = null
+ fun setOnKeyPreImeListener(l: OnKeyPreImeListener) { keyPreImeListener = l }
+
+ private var selectionChangedListener: OnSelectionChangedListener? = null
+ fun setOnSelectionChangedListener(l: OnSelectionChangedListener) { selectionChangedListener = l }
+
+ private var windowFocusChangeListener: OnWindowsFocusChangeListener? = null
+ fun setOnWindowsFocusChangeListener(l: OnWindowsFocusChangeListener) { windowFocusChangeListener = l }
+
+ // The previous autocomplete result returned to us
+ var autocompleteResult: AutocompleteResult? = null
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ set
+
+ // Length of the user-typed portion of the result
+ private var autoCompletePrefixLength: Int = 0
+
+ // If text change is due to us setting autocomplete
+ private var settingAutoComplete: Boolean = false
+
+ // Spans used for marking the autocomplete text
+ private var autoCompleteSpans: List<Any>? = null
+
+ // Do not process autocomplete result
+ private var discardAutoCompleteResult: Boolean = false
+
+ val nonAutocompleteText: String
+ get() = getNonAutocompleteText(text)
+
+ override val originalText: String
+ get() = text.subSequence(0, autoCompletePrefixLength).toString()
+
+ /**
+ * The background color used for the autocomplete suggestion.
+ */
+ var autoCompleteBackgroundColor: Int = {
+ val a = context.obtainStyledAttributes(attrs, R.styleable.InlineAutocompleteEditText)
+ val color = a.getColor(
+ R.styleable.InlineAutocompleteEditText_autocompleteBackgroundColor,
+ DEFAULT_AUTOCOMPLETE_BACKGROUND_COLOR,
+ )
+ a.recycle()
+ color
+ }()
+
+ /**
+ * The Foreground color used for the autocomplete suggestion.
+ */
+ var autoCompleteForegroundColor: Int? = null
+
+ private val inputMethodManger get() = context.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager?
+
+ @SuppressWarnings("ReturnCount")
+ private val onKeyPreIme = fun (_: View, keyCode: Int, event: KeyEvent): Boolean {
+ // We only want to process one event per tap
+ if (event.action != KeyEvent.ACTION_DOWN) {
+ return false
+ }
+
+ if (keyCode == KeyEvent.KEYCODE_ENTER) {
+ // If the edit text has a composition string, don't submit the text yet.
+ // ENTER is needed to commit the composition string.
+ val content = text
+ if (!hasCompositionString(content)) {
+ commitListener?.invoke()
+ return true
+ }
+ }
+
+ if (keyCode == KeyEvent.KEYCODE_BACK) {
+ removeAutocomplete(text)
+ return false
+ }
+
+ return false
+ }
+
+ private val onKey = fun (_: View, keyCode: Int, event: KeyEvent): Boolean {
+ if (keyCode == KeyEvent.KEYCODE_ENTER) {
+ if (event.action != KeyEvent.ACTION_DOWN) {
+ return true
+ }
+
+ commitListener?.invoke()
+ return true
+ }
+
+ // Delete autocomplete text when backspacing or forward deleting.
+ return (
+ (
+ keyCode == KeyEvent.KEYCODE_DEL ||
+ keyCode == KeyEvent.KEYCODE_FORWARD_DEL
+ ) &&
+ removeAutocomplete(text)
+ )
+ }
+
+ private val onSelectionChanged = fun (selStart: Int, selEnd: Int) {
+ // The user has repositioned the cursor somewhere. We need to adjust
+ // the autocomplete text depending on where the new cursor is.
+ val text = text
+ val start = text.getSpanStart(AUTOCOMPLETE_SPAN)
+
+ val nothingSelected = start == selStart && start == selEnd
+ if (settingAutoComplete || nothingSelected || start < 0) {
+ // Do not commit autocomplete text if there is no autocomplete text
+ // or if selection is still at start of autocomplete text
+ return
+ }
+
+ if (selStart <= start && selEnd <= start) {
+ // The cursor is in user-typed text; remove any autocomplete text.
+ removeAutocomplete(text)
+ } else {
+ // The cursor is in the autocomplete text; commit it so it becomes regular text.
+ commitAutocomplete(text)
+ }
+ }
+
+ private val isAmazonEchoShowKeyboard: Boolean
+ get() = INPUT_METHOD_AMAZON_ECHO_SHOW == getCurrentInputMethod()
+
+ public override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+
+ if (this.keyPreImeListener == null) { this.keyPreImeListener = onKeyPreIme }
+ if (this.selectionChangedListener == null) { this.selectionChangedListener = onSelectionChanged }
+
+ setOnKeyListener(onKey)
+ addTextChangedListener(TextChangeListener())
+ }
+
+ public override fun onFocusChanged(gainFocus: Boolean, direction: Int, previouslyFocusedRect: Rect?) {
+ super.onFocusChanged(gainFocus, direction, previouslyFocusedRect)
+
+ // Make search icon inactive when edit toolbar search term isn't a user entered
+ // search term
+ val isActive = !TextUtils.isEmpty(text)
+
+ searchStateChangeListener?.invoke(isActive)
+
+ if (gainFocus) {
+ resetAutocompleteState()
+ return
+ }
+
+ removeAutocomplete(text)
+
+ try {
+ restartInput()
+ inputMethodManger?.hideSoftInputFromWindow(windowToken, 0)
+ } catch (ignored: NullPointerException) {
+ // See bug 782096 for details
+ }
+ }
+
+ override fun setText(text: CharSequence?, type: BufferType) {
+ val textString = text?.toString() ?: ""
+ super.setText(textString, type)
+
+ // Any autocomplete text would have been overwritten, so reset our autocomplete states.
+ resetAutocompleteState()
+ }
+
+ /**
+ * Sets the text of the edit text.
+ * @param text The text to set.
+ * @param shouldAutoComplete If false, [TextChangeListener] the text watcher will be disabled for this set.
+ */
+ fun setText(text: CharSequence?, shouldAutoComplete: Boolean = true) {
+ val wasSettingAutoComplete = settingAutoComplete
+
+ // Disable listeners in order to stop auto completion
+ settingAutoComplete = !shouldAutoComplete
+ setText(text, BufferType.EDITABLE)
+ settingAutoComplete = wasSettingAutoComplete
+ }
+
+ /**
+ * Appends the given text to the end of the current text.
+ * @param text The text to append.
+ * @param shouldAutoComplete If false, [TextChangeListener] text watcher will be disabled for this append.
+ */
+ fun appendText(text: CharSequence?, shouldAutoComplete: Boolean = true) {
+ val wasSettingAutoComplete = settingAutoComplete
+
+ // Disable listeners in order to stop auto completion
+ settingAutoComplete = !shouldAutoComplete
+ append(text)
+ settingAutoComplete = wasSettingAutoComplete
+ }
+
+ override fun getText(): Editable {
+ return super.getText() as Editable
+ }
+
+ override fun sendAccessibilityEventUnchecked(event: AccessibilityEvent) {
+ // We need to bypass the isShown() check in the default implementation
+ // for TYPE_VIEW_TEXT_SELECTION_CHANGED events so that accessibility
+ // services could detect a url change.
+ if (event.eventType == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED &&
+ parent != null && !isShown
+ ) {
+ onInitializeAccessibilityEvent(event)
+ dispatchPopulateAccessibilityEvent(event)
+ parent.requestSendAccessibilityEvent(this, event)
+ } else {
+ super.sendAccessibilityEventUnchecked(event)
+ }
+ }
+
+ /**
+ * Mark the start of autocomplete changes so our text change
+ * listener does not react to changes in autocomplete text
+ */
+ private fun beginSettingAutocomplete() {
+ beginBatchEdit()
+ settingAutoComplete = true
+ }
+
+ /**
+ * Mark the end of autocomplete changes
+ */
+ private fun endSettingAutocomplete() {
+ settingAutoComplete = false
+ endBatchEdit()
+ }
+
+ /**
+ * Reset autocomplete states to their initial values
+ */
+ private fun resetAutocompleteState() {
+ autoCompleteSpans = mutableListOf(
+ AUTOCOMPLETE_SPAN,
+ BackgroundColorSpan(autoCompleteBackgroundColor),
+ ).apply {
+ autoCompleteForegroundColor?.let { add(ForegroundColorSpan(it)) }
+ }
+ autocompleteResult = null
+ // Pretend we already autocompleted the existing text,
+ // so that actions like backspacing don't trigger autocompletion.
+ autoCompletePrefixLength = text.length
+ isCursorVisible = true
+ }
+
+ /**
+ * Remove any autocomplete text
+ *
+ * @param text Current text content that may include autocomplete text
+ */
+ private fun removeAutocomplete(text: Editable): Boolean {
+ val start = text.getSpanStart(AUTOCOMPLETE_SPAN)
+ if (start < 0) {
+ // No autocomplete text
+ return false
+ }
+
+ beginSettingAutocomplete()
+
+ // When we call delete() here, the autocomplete spans we set are removed as well.
+ text.delete(start, text.length)
+
+ // Keep autoCompletePrefixLength the same because the prefix has not changed.
+ // Clear mAutoCompleteResult to make sure we get fresh autocomplete text next time.
+ autocompleteResult = null
+
+ // Reshow the cursor.
+ isCursorVisible = true
+
+ endSettingAutocomplete()
+ return true
+ }
+
+ /**
+ * Convert any autocomplete text to regular text
+ *
+ * @param text Current text content that may include autocomplete text
+ */
+ private fun commitAutocomplete(text: Editable): Boolean {
+ val start = text.getSpanStart(AUTOCOMPLETE_SPAN)
+ if (start < 0) {
+ // No autocomplete text
+ return false
+ }
+
+ beginSettingAutocomplete()
+
+ // Remove all spans here to convert from autocomplete text to regular text
+ for (span in autoCompleteSpans!!) {
+ text.removeSpan(span)
+ }
+
+ // Keep mAutoCompleteResult the same because the result has not changed.
+ // Reset autoCompletePrefixLength because the prefix now includes the autocomplete text.
+ autoCompletePrefixLength = text.length
+
+ // Reshow the cursor.
+ isCursorVisible = true
+
+ endSettingAutocomplete()
+
+ // Invoke textChangeListener manually, because previous autocomplete text is now committed
+ textChangeListener?.apply {
+ val fullText = text.toString()
+ invoke(fullText, fullText)
+ }
+
+ return true
+ }
+
+ /**
+ * Applies the provided result by updating the current autocomplete
+ * text and selection, if any.
+ *
+ * @param result the [AutocompleteResult] to apply
+ */
+ override fun applyAutocompleteResult(result: AutocompleteResult) {
+ // If discardAutoCompleteResult is true, we temporarily disabled
+ // autocomplete (due to backspacing, etc.) and we should bail early.
+ if (discardAutoCompleteResult) {
+ return
+ }
+
+ if (!isEnabled) {
+ autocompleteResult = null
+ return
+ }
+
+ val text = text
+ val autoCompleteStart = text.getSpanStart(AUTOCOMPLETE_SPAN)
+ autocompleteResult = result
+
+ if (autoCompleteStart > -1) {
+ // Autocomplete text already exists; we should replace existing autocomplete text.
+ replaceAutocompleteText(result, autoCompleteStart)
+ } else {
+ // No autocomplete text yet; we should add autocomplete text
+ addAutocompleteText(result)
+ }
+
+ announceForAccessibility(text.toString())
+ }
+
+ private fun replaceAutocompleteText(result: AutocompleteResult, autoCompleteStart: Int) {
+ // Autocomplete text already exists; we should replace existing autocomplete text.
+ val text = text
+ val resultLength = result.text.length
+
+ // If the result and the current text don't have the same prefixes,
+ // the result is stale and we should wait for the another result to come in.
+ if (!TextUtils.regionMatches(result.text, 0, text, 0, autoCompleteStart)) {
+ return
+ }
+
+ beginSettingAutocomplete()
+
+ // Replace the existing autocomplete text with new one.
+ // replace() preserves the autocomplete spans that we set before.
+ text.replace(autoCompleteStart, text.length, result.text, autoCompleteStart, resultLength)
+
+ // Reshow the cursor if there is no longer any autocomplete text.
+ if (autoCompleteStart == resultLength) {
+ isCursorVisible = true
+ }
+
+ endSettingAutocomplete()
+ }
+
+ private fun addAutocompleteText(result: AutocompleteResult) {
+ // No autocomplete text yet; we should add autocomplete text
+ val text = text
+ val textLength = text.length
+ val resultLength = result.text.length
+
+ // If the result prefix doesn't match the current text,
+ // the result is stale and we should wait for the another result to come in.
+ if (resultLength <= textLength || !TextUtils.regionMatches(result.text, 0, text, 0, textLength)) {
+ return
+ }
+
+ val spans = text.getSpans(textLength, textLength, Any::class.java)
+ val spanStarts = IntArray(spans.size)
+ val spanEnds = IntArray(spans.size)
+ val spanFlags = IntArray(spans.size)
+
+ // Save selection/composing span bounds so we can restore them later.
+ for (i in spans.indices) {
+ val span = spans[i]
+ val spanFlag = text.getSpanFlags(span)
+
+ // We don't care about spans that are not selection or composing spans.
+ // For those spans, spanFlag[i] will be 0 and we don't restore them.
+ if (spanFlag and Spanned.SPAN_COMPOSING == 0 &&
+ span !== Selection.SELECTION_START &&
+ span !== Selection.SELECTION_END
+ ) {
+ continue
+ }
+
+ spanStarts[i] = text.getSpanStart(span)
+ spanEnds[i] = text.getSpanEnd(span)
+ spanFlags[i] = spanFlag
+ }
+
+ beginSettingAutocomplete()
+
+ // First add trailing text.
+ text.append(result.text, textLength, resultLength)
+
+ // Restore selection/composing spans.
+ for (i in spans.indices) {
+ val spanFlag = spanFlags[i]
+ if (spanFlag == 0) {
+ // Skip if the span was ignored before.
+ continue
+ }
+ text.setSpan(spans[i], spanStarts[i], spanEnds[i], spanFlag)
+ }
+
+ // Mark added text as autocomplete text.
+ for (span in autoCompleteSpans!!) {
+ text.setSpan(span, textLength, resultLength, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
+ }
+
+ // Hide the cursor.
+ isCursorVisible = false
+
+ // Make sure the autocomplete text is visible. If the autocomplete text is too
+ // long, it would appear the cursor will be scrolled out of view. However, this
+ // is not the case in practice, because EditText still makes sure the cursor is
+ // still in view.
+ bringPointIntoView(resultLength)
+
+ endSettingAutocomplete()
+ }
+
+ override fun noAutocompleteResult() {
+ removeAutocomplete(text)
+ }
+
+ /**
+ * Code to handle deleting autocomplete first when backspacing.
+ * If there is no autocomplete text, both removeAutocomplete() and commitAutocomplete()
+ * are no-ops and return false. Therefore we can use them here without checking explicitly
+ * if we have autocomplete text or not.
+ *
+ * Also turns off text prediction for private mode tabs.
+ */
+ @SuppressWarnings("ComplexMethod")
+ override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
+ val ic = super.onCreateInputConnection(outAttrs) ?: return null
+
+ return object : InputConnectionWrapper(ic, false) {
+ override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
+ if (removeAutocomplete(text)) {
+ // If we have autocomplete text, the cursor is at the boundary between
+ // regular and autocomplete text. So regardless of which direction we
+ // are deleting, we should delete the autocomplete text first.
+ //
+ // On Amazon Echo Show devices, restarting input prevents us from backspacing
+ // the last few characters of autocomplete: #911. However, on non-Echo devices,
+ // not restarting input will cause the keyboard to desync when backspacing: #1489.
+ if (!isAmazonEchoShowKeyboard) {
+ restartInput()
+ }
+ return false
+ }
+ return super.deleteSurroundingText(beforeLength, afterLength)
+ }
+
+ // available on API level 24+
+ override fun deleteSurroundingTextInCodePoints(beforeLength: Int, afterLength: Int): Boolean {
+ if (removeAutocomplete(text)) {
+ restartInput()
+ return false
+ }
+ return super.deleteSurroundingTextInCodePoints(beforeLength, afterLength)
+ }
+
+ /**
+ * Optionally remove the current autocompletion depending on the new [text].
+ *
+ * Cases in which the autocompletion will be removed:
+ * - if the user pressed the backspace to remove the autocompletion or
+ * - if the user modified their input such that the autocompletion does not apply anymore.
+ *
+ * @return `true` if this method consumed the user input, `false` otherwise.
+ */
+ @Suppress("ComplexCondition")
+ private fun removeAutocompleteOnComposing(text: CharSequence): Boolean {
+ val editable = getText()
+
+ // Remove the autocomplete text as soon as possible if not applicable anymore.
+ if (!editableText.startsWith(text) && removeAutocomplete(editable)) {
+ return false // If the user modified their input then allow the new text to be set.
+ }
+
+ val composingStart = BaseInputConnection.getComposingSpanStart(editable)
+ val composingEnd = BaseInputConnection.getComposingSpanEnd(editable)
+ // We only delete the autocomplete text when the user is backspacing,
+ // i.e. when the composing text is getting shorter.
+ if (composingStart >= 0 &&
+ composingEnd >= 0 &&
+ composingEnd - composingStart > text.length &&
+ removeAutocomplete(editable)
+ ) {
+ finishComposingText()
+ // Make the IME aware that we interrupted the setComposingText call,
+ // by calling restartInput()
+ restartInput()
+ return true
+ }
+ return false
+ }
+
+ override fun commitText(text: CharSequence, newCursorPosition: Int): Boolean {
+ return if (removeAutocompleteOnComposing(text)) {
+ false
+ } else {
+ super.commitText(text, newCursorPosition)
+ }
+ }
+
+ override fun setComposingText(text: CharSequence, newCursorPosition: Int): Boolean {
+ return if (removeAutocompleteOnComposing(text)) {
+ false
+ } else {
+ super.setComposingText(text, newCursorPosition)
+ }
+ }
+ }
+ }
+
+ private fun restartInput() {
+ inputMethodManger?.restartInput(this)
+ }
+
+ private fun getCurrentInputMethod(): String {
+ val inputMethod = getString(context.contentResolver, DEFAULT_INPUT_METHOD)
+ return inputMethod ?: ""
+ }
+
+ /**
+ * This class watches for text changes and adds or removes autocomplete text accordingly.
+ * Using this class is preferred when making text changes as it will not interfere
+ * with any composing text at the same time as custom keyboards.
+ *
+ * Known issue: autocomplete will not be added when replacing the current text with one
+ * that has a text length equal to the one being replaced minus 1.
+ * */
+ private inner class TextChangeListener : TextWatcher {
+
+ /**
+ * Holds the value of the non-autocomplete text before any changes have been made.
+ * */
+ private var beforeChangedTextNonAutocomplete: String = ""
+
+ /**
+ * The number of characters that have been changed in [onTextChanged].
+ * When using keyboards that do not have their own text correction enabled
+ * and the user is pressing backspace this value will be 0.
+ * */
+ private var textChangedCount: Int = 0
+
+ override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {
+ if (!isEnabled || settingAutoComplete) return
+ beforeChangedTextNonAutocomplete = getNonAutocompleteText(text)
+ }
+
+ override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
+ if (settingAutoComplete) return
+
+ // In this initial implementation, we do not include the changed text to minimize PII.
+ Fact(Component.UI_AUTOCOMPLETE, Action.IMPLEMENTATION_DETAIL, "onTextChanged", "InlineAutocompleteEditText")
+ .collect()
+
+ if (!isEnabled) return
+
+ textChangedCount = count
+ }
+
+ override fun afterTextChanged(editable: Editable) {
+ if (!isEnabled || settingAutoComplete) return
+
+ val afterNonAutocompleteText = getNonAutocompleteText(editable)
+
+ val hasTextShortenedByOne: Boolean =
+ beforeChangedTextNonAutocomplete.length == afterNonAutocompleteText.length + 1
+
+ // Covers both keyboards with text correction activated and those without.
+ val hasBackspaceBeenPressed =
+ textChangedCount == 0 || hasTextShortenedByOne
+
+ // No autocompleting when typing a search query
+ val afterTextIsSearch = afterNonAutocompleteText.contains(" ")
+
+ val hasTextBeenAdded: Boolean =
+ (
+ afterNonAutocompleteText.contains(beforeChangedTextNonAutocomplete) ||
+ beforeChangedTextNonAutocomplete.isEmpty()
+ ) &&
+ afterNonAutocompleteText.length > beforeChangedTextNonAutocomplete.length
+
+ var shouldAddAutocomplete: Boolean = hasTextBeenAdded || (!afterTextIsSearch && !hasBackspaceBeenPressed)
+
+ autoCompletePrefixLength = afterNonAutocompleteText.length
+
+ // If we are not autocompleting, we set discardAutoCompleteResult to true
+ // to discard any autocomplete results that are in-flight, and vice versa.
+ discardAutoCompleteResult = !shouldAddAutocomplete
+
+ if (!shouldAddAutocomplete) {
+ // Remove the old autocomplete text until any new autocomplete text gets added.
+ removeAutocomplete(editable)
+ } else {
+ // If this text already matches our autocomplete text, autocomplete likely
+ // won't change. Just reuse the old autocomplete value.
+ autocompleteResult?.takeIf { it.startsWith(afterNonAutocompleteText) }?.let {
+ applyAutocompleteResult(it)
+ shouldAddAutocomplete = false
+ }
+ }
+
+ // Update search icon with an active state since user is typing
+ searchStateChangeListener?.invoke(afterNonAutocompleteText.isNotEmpty())
+
+ if (shouldAddAutocomplete) {
+ filterListener?.invoke(afterNonAutocompleteText)
+ }
+
+ textChangeListener?.invoke(afterNonAutocompleteText, text.toString())
+ }
+ }
+
+ override fun dispatchKeyEventPreIme(event: KeyEvent?): Boolean {
+ return event?.let {
+ dispatchKeyEventPreImeListener?.invoke(it) ?: onKeyPreIme(it.keyCode, it)
+ } ?: super.dispatchKeyEventPreIme(event)
+ }
+
+ override fun onKeyPreIme(keyCode: Int, event: KeyEvent): Boolean {
+ return keyPreImeListener?.invoke(this, keyCode, event) ?: false
+ }
+
+ public override fun onSelectionChanged(selStart: Int, selEnd: Int) {
+ selectionChangedListener?.invoke(selStart, selEnd)
+ super.onSelectionChanged(selStart, selEnd)
+ }
+
+ override fun onWindowFocusChanged(hasFocus: Boolean) {
+ super.onWindowFocusChanged(hasFocus)
+ windowFocusChangeListener?.invoke(hasFocus)
+ }
+
+ override fun onTextContextMenuItem(id: Int): Boolean {
+ // 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 callOnTextContextMenuItemSuper(id)
+ }
+
+ @Suppress("ClickableViewAccessibility")
+ override fun onTouchEvent(event: MotionEvent): Boolean {
+ return if (Build.VERSION.SDK_INT == Build.VERSION_CODES.M &&
+ event.actionMasked == MotionEvent.ACTION_UP
+ ) {
+ // Android 6 occasionally throws a NullPointerException inside Editor.onTouchEvent()
+ // for ACTION_UP when attempting to display (uninitialised) text handles. The Editor
+ // and TextView IME interactions are quite complex, so I don't know how to properly
+ // work around this issue, but we can at least catch the NPE to prevent crashing
+ // the whole app.
+ // (Editor tries to make both selection handles visible, but in certain cases they haven't
+ // been initialised yet, causing the NPE. It doesn't bother to check the selection handle
+ // state, and making some other calls to ensure the handles exist doesn't seem like a
+ // clean solution either since I don't understand most of the selection logic. This implementation
+ // only seems to exist in Android 6, both Android 5 and 7 have different implementations.)
+ try {
+ super.onTouchEvent(event)
+ } catch (ignored: NullPointerException) {
+ // Ignore this (see above) - since we're now in an unknown state let's clear all selection
+ // (which is still better than an arbitrary crash that we can't control):
+ clearFocus()
+ true
+ }
+ } else {
+ super.onTouchEvent(event)
+ }
+ }
+
+ @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()
+
+ // The Echo Show IME does not conflict with Fire TV: com.amazon.tv.ime/.FireTVIME
+ // However, it may be used by other Amazon keyboards. In theory, if they have the same IME
+ // ID, they should have similar behavior.
+ const val INPUT_METHOD_AMAZON_ECHO_SHOW = "com.amazon.bluestone.keyboard/.DictationIME"
+
+ /**
+ * Get the portion of text that is not marked as autocomplete text.
+ *
+ * @param text Current text content that may include autocomplete text
+ */
+ private fun getNonAutocompleteText(text: Editable): String {
+ val start = text.getSpanStart(AUTOCOMPLETE_SPAN)
+ return if (start < 0) {
+ // No autocomplete text; return the whole string.
+ text.toString()
+ } else {
+ // Only return the portion that's not autocomplete text
+ TextUtils.substring(text, 0, start)
+ }
+ }
+
+ private fun hasCompositionString(content: Editable): Boolean {
+ val spans = content.getSpans(0, content.length, Any::class.java)
+ return spans.any { span -> content.getSpanFlags(span) and Spanned.SPAN_COMPOSING != 0 }
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/ui/autocomplete/src/main/res/values/attrs.xml b/mobile/android/android-components/components/ui/autocomplete/src/main/res/values/attrs.xml
new file mode 100644
index 0000000000..2b23026264
--- /dev/null
+++ b/mobile/android/android-components/components/ui/autocomplete/src/main/res/values/attrs.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<resources>
+ <declare-styleable name="InlineAutocompleteEditText">
+ <attr name="autocompleteBackgroundColor" format="color" />
+ </declare-styleable>
+</resources> \ No newline at end of file
diff --git a/mobile/android/android-components/components/ui/autocomplete/src/test/java/mozilla/components/ui/autocomplete/InlineAutocompleteEditTextTest.kt b/mobile/android/android-components/components/ui/autocomplete/src/test/java/mozilla/components/ui/autocomplete/InlineAutocompleteEditTextTest.kt
new file mode 100644
index 0000000000..d455b0a28e
--- /dev/null
+++ b/mobile/android/android-components/components/ui/autocomplete/src/test/java/mozilla/components/ui/autocomplete/InlineAutocompleteEditTextTest.kt
@@ -0,0 +1,585 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+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
+import android.view.accessibility.AccessibilityEvent
+import android.view.inputmethod.BaseInputConnection
+import android.view.inputmethod.EditorInfo
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.ui.autocomplete.InlineAutocompleteEditText.AutocompleteResult
+import mozilla.components.ui.autocomplete.InlineAutocompleteEditText.Companion.AUTOCOMPLETE_SPAN
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+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 {
+
+ private val attributes = buildAttributeSet().build()
+
+ @Test
+ fun autoCompleteResult() {
+ val result = AutocompleteResult("testText", "testSource", 1)
+ assertEquals("testText", result.text)
+ assertEquals("testSource", result.source)
+ assertEquals(1, result.totalItems)
+ }
+
+ @Test
+ fun getNonAutocompleteText() {
+ val et = InlineAutocompleteEditText(testContext)
+ et.setText("Test")
+ assertEquals("Test", et.nonAutocompleteText)
+
+ et.text.setSpan(AUTOCOMPLETE_SPAN, 2, 3, SPAN_EXCLUSIVE_EXCLUSIVE)
+ assertEquals("Te", et.nonAutocompleteText)
+
+ et.text.setSpan(AUTOCOMPLETE_SPAN, 0, 3, SPAN_EXCLUSIVE_EXCLUSIVE)
+ assertEquals("", et.nonAutocompleteText)
+ }
+
+ @Test
+ fun getOriginalText() {
+ val et = InlineAutocompleteEditText(testContext, attributes)
+ et.setText("Test")
+ assertEquals("Test", et.originalText)
+
+ et.text.setSpan(AUTOCOMPLETE_SPAN, 2, 3, SPAN_EXCLUSIVE_EXCLUSIVE)
+ assertEquals("Test", et.originalText)
+
+ et.text.setSpan(AUTOCOMPLETE_SPAN, 0, 3, SPAN_EXCLUSIVE_EXCLUSIVE)
+ assertEquals("Test", et.originalText)
+ }
+
+ @Test
+ fun onFocusChange() {
+ val et = InlineAutocompleteEditText(testContext, attributes)
+ val searchStates = mutableListOf<Boolean>()
+
+ et.setOnSearchStateChangeListener { b: Boolean -> searchStates.add(searchStates.size, b) }
+ et.onFocusChanged(false, 0, null)
+
+ et.setText("text")
+ et.text.setSpan(AUTOCOMPLETE_SPAN, 0, 3, SPAN_EXCLUSIVE_EXCLUSIVE)
+ et.onFocusChanged(false, 0, null)
+ assertTrue(et.text.isEmpty())
+
+ et.setText("text")
+ et.text.setSpan(AUTOCOMPLETE_SPAN, 0, 3, SPAN_EXCLUSIVE_EXCLUSIVE)
+ et.onFocusChanged(true, 0, null)
+ assertFalse(et.text.isEmpty())
+ assertEquals(listOf(false, true, true), searchStates)
+ }
+
+ @Test
+ fun sendAccessibilityEventUnchecked() {
+ val et = spy(InlineAutocompleteEditText(testContext, attributes))
+ doReturn(false).`when`(et).isShown
+ doReturn(mock(ViewParent::class.java)).`when`(et).parent
+
+ val event = AccessibilityEvent()
+ event.eventType = AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED
+ et.sendAccessibilityEventUnchecked(event)
+
+ verify(et).onInitializeAccessibilityEvent(event)
+ verify(et).dispatchPopulateAccessibilityEvent(event)
+ verify(et.parent).requestSendAccessibilityEvent(et, event)
+ }
+
+ @Test
+ fun onAutocompleteSetsEmptyResult() {
+ val et = spy(InlineAutocompleteEditText(testContext, attributes))
+
+ doReturn(false).`when`(et).isEnabled
+ et.applyAutocompleteResult(AutocompleteResult("text", "source", 1))
+ assertNull(et.autocompleteResult)
+ }
+
+ @Test
+ fun onAutocompleteDiscardsStaleResult() {
+ val et = spy(InlineAutocompleteEditText(testContext, attributes))
+ doReturn(true).`when`(et).isEnabled
+ et.setText("text")
+
+ et.applyAutocompleteResult(AutocompleteResult("stale result", "source", 1))
+ assertEquals("text", et.text.toString())
+
+ et.text.setSpan(AUTOCOMPLETE_SPAN, 1, 3, SPAN_EXCLUSIVE_EXCLUSIVE)
+ et.applyAutocompleteResult(AutocompleteResult("stale result", "source", 1))
+ assertEquals("text", et.text.toString())
+ }
+
+ @Test
+ fun onAutocompleteReplacesExistingAutocompleteText() {
+ val et = spy(InlineAutocompleteEditText(testContext, attributes))
+ doReturn(true).`when`(et).isEnabled
+
+ et.setText("text")
+ et.text.setSpan(AUTOCOMPLETE_SPAN, 1, 3, SPAN_EXCLUSIVE_EXCLUSIVE)
+ et.applyAutocompleteResult(AutocompleteResult("text completed", "source", 1))
+ assertEquals("text completed", et.text.toString())
+ }
+
+ @Test
+ fun onAutocompleteAppendsExistingText() {
+ val et = spy(InlineAutocompleteEditText(testContext, attributes))
+ doReturn(true).`when`(et).isEnabled
+
+ et.setText("text")
+ et.applyAutocompleteResult(AutocompleteResult("text completed", "source", 1))
+ assertEquals("text completed", et.text.toString())
+ }
+
+ @Test
+ fun onAutocompleteSetsSpan() {
+ val et = spy(InlineAutocompleteEditText(testContext, attributes))
+ doReturn(true).`when`(et).isEnabled
+
+ et.setText("text")
+ et.applyAutocompleteResult(AutocompleteResult("text completed", "source", 1))
+
+ assertEquals(4, et.text.getSpanStart(AUTOCOMPLETE_SPAN))
+ assertEquals(14, et.text.getSpanEnd(AUTOCOMPLETE_SPAN))
+ assertEquals(SPAN_EXCLUSIVE_EXCLUSIVE, et.text.getSpanFlags(AUTOCOMPLETE_SPAN))
+ }
+
+ @Test
+ fun onKeyPreImeListenerInvocation() {
+ val et = InlineAutocompleteEditText(testContext, attributes)
+ var invokedWithParams: List<Any>? = null
+ et.setOnKeyPreImeListener { p1, p2, p3 ->
+ invokedWithParams = listOf(p1, p2, p3)
+ true
+ }
+ val event = mock(KeyEvent::class.java)
+ et.onKeyPreIme(1, event)
+ assertEquals(listOf(et, 1, event), invokedWithParams)
+ }
+
+ @Test
+ fun onSelectionChangedListenerInvocation() {
+ val et = InlineAutocompleteEditText(testContext, attributes)
+ var invokedWithParams: List<Any>? = null
+ et.setOnSelectionChangedListener { p1, p2 ->
+ invokedWithParams = listOf(p1, p2)
+ }
+ et.onSelectionChanged(0, 1)
+ assertEquals(listOf(0, 1), invokedWithParams)
+ }
+
+ @Test
+ fun onSelectionChangedCommitsResult() {
+ val et = InlineAutocompleteEditText(testContext, attributes)
+ et.onAttachedToWindow()
+
+ et.setText("text")
+ et.applyAutocompleteResult(AutocompleteResult("text completed", "source", 1))
+ assertEquals(4, et.text.getSpanStart(AUTOCOMPLETE_SPAN))
+
+ et.onSelectionChanged(4, 14)
+ assertEquals(-1, et.text.getSpanStart(AUTOCOMPLETE_SPAN))
+ }
+
+ @Test
+ fun onWindowFocusChangedListenerInvocation() {
+ val et = InlineAutocompleteEditText(testContext, attributes)
+ var invokedWithParams: List<Any>? = null
+ et.setOnWindowsFocusChangeListener { p1 ->
+ invokedWithParams = listOf(p1)
+ }
+ et.onWindowFocusChanged(true)
+ assertEquals(listOf(true), invokedWithParams)
+ }
+
+ @Test
+ fun onCommitListenerInvocation() {
+ val et = InlineAutocompleteEditText(testContext, attributes)
+ var invoked = false
+ et.setOnCommitListener { invoked = true }
+ et.onAttachedToWindow()
+
+ et.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER))
+ assertTrue(invoked)
+ }
+
+ @Test
+ fun onTextChangeListenerInvocation() {
+ val et = InlineAutocompleteEditText(testContext, attributes)
+ var invokedWithParams: List<Any>? = null
+ et.setOnTextChangeListener { p1, p2 ->
+ invokedWithParams = listOf(p1, p2)
+ }
+ et.onAttachedToWindow()
+
+ et.setText("text")
+ assertEquals(listOf("text", "text"), invokedWithParams)
+ }
+
+ @Test
+ fun onSearchStateChangeListenerInvocation() {
+ val et = InlineAutocompleteEditText(testContext, attributes)
+ et.onAttachedToWindow()
+
+ var invokedWithParams: List<Any>? = null
+ et.setOnSearchStateChangeListener { p1 ->
+ invokedWithParams = listOf(p1)
+ }
+
+ et.setText("")
+ assertEquals(listOf(false), invokedWithParams)
+
+ et.setText("text")
+ assertEquals(listOf(true), invokedWithParams)
+ }
+
+ @Test
+ fun onFilterListenerInvocation() {
+ val et = InlineAutocompleteEditText(testContext, attributes)
+ et.onAttachedToWindow()
+
+ var lastInvokedWithText: String? = null
+ var invokedCounter = 0
+ et.setOnFilterListener { p1 ->
+ lastInvokedWithText = p1
+ invokedCounter++
+ }
+
+ // Already have an autocomplete result, and setting a text to the same value as the result.
+ et.applyAutocompleteResult(AutocompleteResult("text", "source", 1))
+ et.setText("text")
+ // Autocomplete filter shouldn't have been called, because we already have a matching result.
+ assertEquals(0, invokedCounter)
+
+ et.setText("text")
+ assertEquals(1, invokedCounter)
+ assertEquals("text", lastInvokedWithText)
+
+ // Test backspace. We don't expect autocomplete to have been called.
+ et.setText("tex")
+ assertEquals(1, invokedCounter)
+
+ // Presence of a space is counted as a 'search query', we don't autocomplete those.
+ et.setText("search term")
+ assertEquals(1, invokedCounter)
+
+ // Empty text isn't autocompleted either.
+ et.setText("")
+ assertEquals(1, invokedCounter)
+
+ // Autocomplete for the first letter
+ et.setText("t")
+ assertEquals(2, invokedCounter)
+ et.applyAutocompleteResult(AutocompleteResult("text", "source", 1))
+
+ // Autocomplete should be called for the next letter that doesn't match the result
+ et.setText("ta")
+ assertEquals(3, invokedCounter)
+ }
+
+ @Test
+ fun `GIVEN an autocomplete listener WHEN asked to refresh autocomplete suggestions THEN restart the autocomplete functionality with the curret text`() {
+ val et = InlineAutocompleteEditText(testContext, attributes)
+ et.onAttachedToWindow()
+ et.setText("Test")
+ var lastInvokedWithText: String? = null
+ var invokedCounter = 0
+ et.setOnFilterListener { p1 ->
+ lastInvokedWithText = p1
+ invokedCounter++
+ }
+
+ et.refreshAutocompleteSuggestions()
+
+ assertEquals("Test", lastInvokedWithText)
+ assertEquals(1, invokedCounter)
+ }
+
+ @Test
+ fun onCreateInputConnection() {
+ val et = spy(InlineAutocompleteEditText(testContext, attributes))
+ val icw = et.onCreateInputConnection(mock(EditorInfo::class.java))
+ doReturn(true).`when`(et).isEnabled
+
+ et.setText("text")
+ et.applyAutocompleteResult(AutocompleteResult("text completed", "source", 1))
+ assertEquals("text completed", et.text.toString())
+
+ icw?.deleteSurroundingText(0, 1)
+ assertNull(et.autocompleteResult)
+ assertEquals("text", et.text.toString())
+
+ et.applyAutocompleteResult(AutocompleteResult("text completed", "source", 1))
+ assertEquals("text completed", et.text.toString())
+
+ BaseInputConnection.setComposingSpans(et.text)
+ icw?.commitText("text", 4)
+ assertNull(et.autocompleteResult)
+ assertEquals("text", et.text.toString())
+
+ et.applyAutocompleteResult(AutocompleteResult("text completed", "source", 1))
+ assertEquals("text completed", et.text.toString())
+
+ BaseInputConnection.setComposingSpans(et.text)
+ icw?.setComposingText("text", 4)
+ assertNull(et.autocompleteResult)
+ assertEquals("text", et.text.toString())
+ }
+
+ @Test
+ fun removeAutocompleteOnComposing() {
+ val et = InlineAutocompleteEditText(testContext, attributes)
+ val ic = et.onCreateInputConnection(mock(EditorInfo::class.java))
+
+ ic?.setComposingText("text", 1)
+ assertEquals("text", et.text.toString())
+
+ et.applyAutocompleteResult(AutocompleteResult("text completed", "source", 1))
+ assertEquals("text completed", et.text.toString())
+
+ // Simulating a backspace which should remove the autocomplete and leave original text
+ ic?.setComposingText("tex", 1)
+ assertEquals("text", et.text.toString())
+
+ // Verify that we finished composing
+ assertEquals(-1, BaseInputConnection.getComposingSpanStart(et.text))
+ assertEquals(-1, BaseInputConnection.getComposingSpanEnd(et.text))
+ }
+
+ @Test
+ fun `GIVEN the current text contains an autocompletion WHEN a new character does not match the autocompletion THEN remove the autocompletion`() {
+ val et = InlineAutocompleteEditText(testContext, attributes)
+ val ic = et.onCreateInputConnection(mock(EditorInfo::class.java))
+
+ ic?.setComposingText("mo", 1)
+ assertEquals("mo", et.text.toString())
+
+ et.applyAutocompleteResult(AutocompleteResult("mozilla", "source", 1))
+ assertEquals("mozilla", et.text.toString())
+
+ // Simulating the user entering a new character which makes the current autocomplete invalid
+ ic?.setComposingText("mod", 1)
+ assertEquals("mod", et.text.toString())
+
+ // Verify that autocompletion works for the new text
+ et.applyAutocompleteResult(AutocompleteResult("moderator", "source", 1))
+ assertEquals("moderator", et.text.toString())
+ }
+
+ @Test
+ fun `GIVEN empty edit field WHEN text 'g' added THEN autocomplete to google`() {
+ val et = InlineAutocompleteEditText(testContext, attributes)
+ et.setText("")
+ et.onAttachedToWindow()
+
+ et.autocompleteResult = AutocompleteResult(
+ text = "google.com",
+ source = "test-source",
+ totalItems = 100,
+ )
+
+ et.setText("g")
+ assertEquals("google.com", "${et.text}")
+ }
+
+ @Test
+ fun `GIVEN empty edit field WHEN text 'g ' added THEN don't autocomplete to google`() {
+ val et = InlineAutocompleteEditText(testContext, attributes)
+ et.setText("")
+ et.onAttachedToWindow()
+
+ et.autocompleteResult = AutocompleteResult(
+ text = "google.com",
+ source = "test-source",
+ totalItems = 100,
+ )
+
+ et.setText("g ")
+ assertEquals("g ", "${et.text}")
+ }
+
+ @Test
+ fun `GIVEN field with 'google' WHEN backspacing THEN doesn't autocomplete`() {
+ val et = InlineAutocompleteEditText(testContext, attributes)
+ et.setText("google")
+ et.onAttachedToWindow()
+
+ et.autocompleteResult = AutocompleteResult(
+ text = "google.com",
+ source = "test-source",
+ totalItems = 100,
+ )
+
+ et.setText("googl")
+ assertEquals("googl", "${et.text}")
+ }
+
+ @Test
+ fun `GIVEN field with selected text WHEN text 'g' added THEN autocomplete to google`() {
+ val et = InlineAutocompleteEditText(testContext, attributes)
+ et.setText("testestest")
+ et.selectAll()
+ et.onAttachedToWindow()
+ et.autocompleteResult = AutocompleteResult(
+ text = "google.com",
+ source = "test-source",
+ totalItems = 100,
+ )
+
+ et.setText("g")
+ assertEquals("google.com", "${et.text}")
+ }
+
+ @Test
+ fun `GIVEN field with selected text 'google ' WHEN text 'g' added THEN autocomplete to google`() {
+ val et = InlineAutocompleteEditText(testContext, attributes)
+ et.setText("https://www.google.com/")
+ et.selectAll()
+ et.onAttachedToWindow()
+ et.autocompleteResult = AutocompleteResult(
+ text = "google.com",
+ source = "test-source",
+ totalItems = 100,
+ )
+
+ et.setText("g")
+ assertEquals("google.com", "${et.text}")
+ }
+
+ @Test
+ fun `WHEN setting text THEN isEnabled is never modified`() {
+ val et = spy(InlineAutocompleteEditText(testContext, attributes))
+ et.setText("", shouldAutoComplete = false)
+ // 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())
+ }
+
+ fun `WHEN committing autocomplete THEN textChangedListener is invoked`() {
+ val et = InlineAutocompleteEditText(testContext, attributes)
+ et.setText("")
+
+ et.onAttachedToWindow()
+ et.autocompleteResult = AutocompleteResult(
+ text = "google.com",
+ source = "test-source",
+ totalItems = 100,
+ )
+ et.setText("g")
+ var callbackInvoked = false
+ et.setOnTextChangeListener { _, _ ->
+ callbackInvoked = true
+ }
+ et.setSelection(3)
+ assertTrue(callbackInvoked)
+ }
+}
diff --git a/mobile/android/android-components/components/ui/autocomplete/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/ui/autocomplete/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 0000000000..cf1c399ea8
--- /dev/null
+++ b/mobile/android/android-components/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/mobile/android/android-components/components/ui/autocomplete/src/test/resources/robolectric.properties b/mobile/android/android-components/components/ui/autocomplete/src/test/resources/robolectric.properties
new file mode 100644
index 0000000000..932b01b9eb
--- /dev/null
+++ b/mobile/android/android-components/components/ui/autocomplete/src/test/resources/robolectric.properties
@@ -0,0 +1 @@
+sdk=28