Commit 12243a70 authored by MozLando's avatar MozLando
Browse files

Merge #7587



7587: For #7586: Add email & call actions to context menu r=Amejia481 a=sblatz
Co-authored-by: default avatarSawyer Blatz <sdblatz@gmail.com>
parents 0e3db53a a1e401ac
......@@ -41,8 +41,10 @@ open class GeckoSelectionActionDelegate(
}
override fun isActionAvailable(id: String): Boolean {
val customActionIsAvailable = customDelegate.isActionAvailable(id) &&
!mSelection?.text.isNullOrEmpty()
val selectedText = mSelection?.text
val customActionIsAvailable = !selectedText.isNullOrEmpty() &&
customDelegate.isActionAvailable(id, selectedText)
return customActionIsAvailable ||
super.isActionAvailable(id)
......
......@@ -39,7 +39,7 @@ class GeckoSelectionActionDelegateTest {
val customActions = arrayOf("1", "2", "3")
val customDelegate = object : SelectionActionDelegate {
override fun getAllActions(): Array<String> = customActions
override fun isActionAvailable(id: String): Boolean = false
override fun isActionAvailable(id: String, selectedText: String): Boolean = false
override fun getActionTitle(id: String): CharSequence? = ""
override fun performAction(id: String, selectedText: String): Boolean = false
override fun sortedActions(actions: Array<String>): Array<String> {
......
......@@ -41,8 +41,10 @@ open class GeckoSelectionActionDelegate(
}
override fun isActionAvailable(id: String): Boolean {
val customActionIsAvailable = customDelegate.isActionAvailable(id) &&
!mSelection?.text.isNullOrEmpty()
val selectedText = mSelection?.text
val customActionIsAvailable = !selectedText.isNullOrEmpty() &&
customDelegate.isActionAvailable(id, selectedText)
return customActionIsAvailable ||
super.isActionAvailable(id)
......
......@@ -39,7 +39,7 @@ class GeckoSelectionActionDelegateTest {
val customActions = arrayOf("1", "2", "3")
val customDelegate = object : SelectionActionDelegate {
override fun getAllActions(): Array<String> = customActions
override fun isActionAvailable(id: String): Boolean = false
override fun isActionAvailable(id: String, selectedText: String): Boolean = false
override fun getActionTitle(id: String): CharSequence? = ""
override fun performAction(id: String, selectedText: String): Boolean = false
override fun sortedActions(actions: Array<String>): Array<String> {
......
......@@ -41,8 +41,10 @@ open class GeckoSelectionActionDelegate(
}
override fun isActionAvailable(id: String): Boolean {
val customActionIsAvailable = customDelegate.isActionAvailable(id) &&
!mSelection?.text.isNullOrEmpty()
val selectedText = mSelection?.text
val customActionIsAvailable = !selectedText.isNullOrEmpty() &&
customDelegate.isActionAvailable(id, selectedText)
return customActionIsAvailable ||
super.isActionAvailable(id)
......
......@@ -39,7 +39,7 @@ class GeckoSelectionActionDelegateTest {
val customActions = arrayOf("1", "2", "3")
val customDelegate = object : SelectionActionDelegate {
override fun getAllActions(): Array<String> = customActions
override fun isActionAvailable(id: String): Boolean = false
override fun isActionAvailable(id: String, selectedText: String): Boolean = false
override fun getActionTitle(id: String): CharSequence? = ""
override fun performAction(id: String, selectedText: String): Boolean = false
override fun sortedActions(actions: Array<String>): Array<String> {
......
......@@ -19,9 +19,10 @@ interface SelectionActionDelegate {
/**
* Checks if an action can be shown on a new selection context menu.
*
* @returns whether or not the the custom action with the id of [id] is currently available.
* @returns whether or not the the custom action with the id of [id] is currently available
* which may be informed by [selectedText].
*/
fun isActionAvailable(id: String): Boolean
fun isActionAvailable(id: String, selectedText: String): Boolean
/**
* Gets a title to be shown in the selection context menu.
......
......@@ -5,6 +5,7 @@
package mozilla.components.feature.contextmenu
import android.content.res.Resources
import android.util.Patterns
import androidx.annotation.VisibleForTesting
import mozilla.components.feature.search.SearchAdapter
import mozilla.components.concept.engine.selection.SelectionActionDelegate
......@@ -15,16 +16,24 @@ internal const val SEARCH = "CUSTOM_CONTEXT_MENU_SEARCH"
internal const val SEARCH_PRIVATELY = "CUSTOM_CONTEXT_MENU_SEARCH_PRIVATELY"
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal const val SHARE = "CUSTOM_CONTEXT_MENU_SHARE"
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal const val EMAIL = "CUSTOM_CONTEXT_MENU_EMAIL"
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal const val CALL = "CUSTOM_CONTEXT_MENU_CALL"
private val customActions = arrayOf(SEARCH, SEARCH_PRIVATELY, SHARE)
private val customActions = arrayOf(CALL, EMAIL, SEARCH, SEARCH_PRIVATELY, SHARE)
/**
* Adds normal and private search buttons to text selection context menus.
* Also adds share, email, and call actions which are optionally displayed.
*/
@Suppress("LongParameterList")
class DefaultSelectionActionDelegate(
private val searchAdapter: SearchAdapter,
resources: Resources,
private val shareTextClicked: ((String) -> Unit)? = null,
private val emailTextClicked: ((String) -> Unit)? = null,
private val callTextClicked: ((String) -> Unit)? = null,
private val actionSorter: ((Array<String>) -> Array<String>)? = null
) : SelectionActionDelegate {
......@@ -33,12 +42,17 @@ class DefaultSelectionActionDelegate(
private val privateSearchText =
resources.getString(R.string.mozac_selection_context_menu_search_privately_2)
private val shareText = resources.getString(R.string.mozac_selection_context_menu_share)
private val emailText = resources.getString(R.string.mozac_selection_context_menu_email)
private val callText = resources.getString(R.string.mozac_selection_context_menu_call)
override fun getAllActions(): Array<String> = customActions
override fun isActionAvailable(id: String): Boolean {
@SuppressWarnings("ComplexMethod")
override fun isActionAvailable(id: String, selectedText: String): Boolean {
val isPrivate = searchAdapter.isPrivateSession()
return (id == SHARE && shareTextClicked != null) ||
(id == EMAIL && emailTextClicked != null && Patterns.EMAIL_ADDRESS.matcher(selectedText).matches()) ||
(id == CALL && callTextClicked != null && Patterns.PHONE.matcher(selectedText).matches()) ||
(id == SEARCH && !isPrivate) ||
(id == SEARCH_PRIVATELY && isPrivate)
}
......@@ -47,6 +61,8 @@ class DefaultSelectionActionDelegate(
SEARCH -> normalSearchText
SEARCH_PRIVATELY -> privateSearchText
SHARE -> shareText
EMAIL -> emailText
CALL -> callText
else -> null
}
......@@ -63,6 +79,14 @@ class DefaultSelectionActionDelegate(
shareTextClicked?.invoke(selectedText)
true
}
EMAIL -> {
emailTextClicked?.invoke(selectedText)
true
}
CALL -> {
callTextClicked?.invoke(selectedText)
true
}
else -> false
}
......
......@@ -8,6 +8,9 @@ import android.content.Context
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.feature.contextmenu.DefaultSelectionActionDelegate
import mozilla.components.feature.search.BrowserStoreSearchAdapter
import mozilla.components.support.ktx.android.content.call
import mozilla.components.support.ktx.android.content.email
import mozilla.components.support.ktx.android.content.share
/**
* More convenient secondary constructor for creating a [DefaultSelectionActionDelegate].
......@@ -16,10 +19,14 @@ import mozilla.components.feature.search.BrowserStoreSearchAdapter
fun DefaultSelectionActionDelegate(
store: BrowserStore,
context: Context,
shareTextClicked: ((String) -> Unit)? = null
shareTextClicked: ((String) -> Unit)? = { context.share(it) },
emailTextClicked: ((String) -> Unit)? = { context.email(it) },
callTextClicked: ((String) -> Unit)? = { context.call(it) }
) =
DefaultSelectionActionDelegate(
BrowserStoreSearchAdapter(store),
context.resources,
shareTextClicked
shareTextClicked,
emailTextClicked,
callTextClicked
)
......@@ -39,4 +39,8 @@
<string name="mozac_selection_context_menu_search_privately_2">Private Search</string>
<!-- Action shown in a text selection context menu. This will prompt a share of the selected text. -->
<string name="mozac_selection_context_menu_share">Share</string>
<!-- Action shown in a text selection context menu. This will prompt a new email from the selected text. -->
<string name="mozac_selection_context_menu_email">Email</string>
<!-- Action shown in a text selection context menu. This will prompt a new call from the selected text. -->
<string name="mozac_selection_context_menu_call">Call</string>
</resources>
\ No newline at end of file
package mozilla.components.feature.contextmenu
import android.content.res.Resources
import androidx.test.ext.junit.runners.AndroidJUnit4
import mozilla.components.feature.search.SearchAdapter
import mozilla.components.support.test.mock
import mozilla.components.support.test.whenever
......@@ -8,30 +9,41 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.anyBoolean
import org.mockito.Mockito.anyString
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
@RunWith(AndroidJUnit4::class)
class DefaultSelectionActionDelegateTest {
val selectedRegularText = "mozilla"
val selectedEmailText = "test@mozilla.org"
val selectedPhoneText = "555-5555"
var lambdaValue: String? = null
val shareClicked: (String) -> Unit = { lambdaValue = it }
val emailClicked: (String) -> Unit = { lambdaValue = it }
val phoneClicked: (String) -> Unit = { lambdaValue = it }
@Test
fun `are non-private actions available`() {
fun `are non-private regular actions available`() {
val searchAdapter = mock<SearchAdapter> {
whenever(isPrivateSession()).thenReturn(false)
}
val delegate = DefaultSelectionActionDelegate(
searchAdapter,
getTestResources(),
shareClicked
shareClicked,
emailClicked,
phoneClicked
)
assertTrue(delegate.isActionAvailable(SEARCH))
assertTrue(delegate.isActionAvailable(SHARE))
assertFalse(delegate.isActionAvailable(SEARCH_PRIVATELY))
assertTrue(delegate.isActionAvailable(SEARCH, selectedRegularText))
assertTrue(delegate.isActionAvailable(SHARE, selectedRegularText))
assertFalse(delegate.isActionAvailable(SEARCH_PRIVATELY, selectedRegularText))
assertFalse(delegate.isActionAvailable(EMAIL, selectedRegularText))
assertFalse(delegate.isActionAvailable(CALL, selectedRegularText))
}
@Test
......@@ -44,9 +56,41 @@ class DefaultSelectionActionDelegateTest {
getTestResources()
)
assertTrue(delegate.isActionAvailable(SEARCH))
assertFalse(delegate.isActionAvailable(SHARE))
assertFalse(delegate.isActionAvailable(SEARCH_PRIVATELY))
assertTrue(delegate.isActionAvailable(SEARCH, selectedRegularText))
assertFalse(delegate.isActionAvailable(SHARE, selectedRegularText))
assertFalse(delegate.isActionAvailable(SEARCH_PRIVATELY, selectedRegularText))
}
@Test
fun `is email available when passed in and email text selected`() {
val searchAdapter = mock<SearchAdapter> {
whenever(isPrivateSession()).thenReturn(false)
}
val delegate = DefaultSelectionActionDelegate(
searchAdapter,
getTestResources(),
emailTextClicked = emailClicked
)
assertTrue(delegate.isActionAvailable(EMAIL, selectedEmailText))
assertFalse(delegate.isActionAvailable(EMAIL, selectedRegularText))
assertFalse(delegate.isActionAvailable(EMAIL, selectedPhoneText))
}
@Test
fun `is call available when passed in and call text selected`() {
val searchAdapter = mock<SearchAdapter> {
whenever(isPrivateSession()).thenReturn(false)
}
val delegate = DefaultSelectionActionDelegate(
searchAdapter,
getTestResources(),
callTextClicked = phoneClicked
)
assertTrue(delegate.isActionAvailable(CALL, selectedPhoneText))
assertFalse(delegate.isActionAvailable(CALL, selectedRegularText))
assertFalse(delegate.isActionAvailable(CALL, selectedEmailText))
}
@Test
......@@ -60,9 +104,9 @@ class DefaultSelectionActionDelegateTest {
shareClicked
)
assertTrue(delegate.isActionAvailable(SEARCH_PRIVATELY))
assertTrue(delegate.isActionAvailable(SHARE))
assertFalse(delegate.isActionAvailable(SEARCH))
assertTrue(delegate.isActionAvailable(SEARCH_PRIVATELY, selectedRegularText))
assertTrue(delegate.isActionAvailable(SHARE, selectedRegularText))
assertFalse(delegate.isActionAvailable(SEARCH, selectedRegularText))
}
@Test
......@@ -76,6 +120,28 @@ class DefaultSelectionActionDelegateTest {
assertEquals(lambdaValue, "some selected text")
}
@Test
fun `when email ID is passed to perform action it should invoke the lambda`() {
val adapter = mock<SearchAdapter>()
val delegate =
DefaultSelectionActionDelegate(adapter, getTestResources(), emailTextClicked = emailClicked)
delegate.performAction(EMAIL, selectedEmailText)
assertEquals(lambdaValue, selectedEmailText)
}
@Test
fun `when call ID is passed to perform action it should invoke the lambda`() {
val adapter = mock<SearchAdapter>()
val delegate =
DefaultSelectionActionDelegate(adapter, getTestResources(), callTextClicked = phoneClicked)
delegate.performAction(CALL, selectedPhoneText)
assertEquals(lambdaValue, selectedPhoneText)
}
@Test
fun `when unknown ID is passed to performAction it should not perform a search`() {
val adapter = mock<SearchAdapter>()
......@@ -148,4 +214,6 @@ fun getTestResources() = mock<Resources> {
whenever(getString(R.string.mozac_selection_context_menu_search_privately_2))
.thenReturn("search privately")
whenever(getString(R.string.mozac_selection_context_menu_share)).thenReturn("share")
whenever(getString(R.string.mozac_selection_context_menu_email)).thenReturn("email")
whenever(getString(R.string.mozac_selection_context_menu_call)).thenReturn("call")
}
......@@ -2,18 +2,24 @@
* 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/. */
@file:Suppress("TooManyFunctions")
package mozilla.components.support.ktx.android.content
import android.app.ActivityManager
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.Intent.ACTION_DIAL
import android.content.Intent.ACTION_SEND
import android.content.Intent.ACTION_SENDTO
import android.content.Intent.EXTRA_EMAIL
import android.content.Intent.EXTRA_SUBJECT
import android.content.Intent.EXTRA_TEXT
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.hardware.camera2.CameraManager
import android.net.Uri
import android.os.Process
import android.view.accessibility.AccessibilityManager
import androidx.annotation.AttrRes
......@@ -25,6 +31,7 @@ import androidx.core.content.ContextCompat
import androidx.core.content.ContextCompat.checkSelfPermission
import androidx.core.content.getSystemService
import mozilla.components.support.base.log.Log
import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.ktx.R
import mozilla.components.support.ktx.android.content.res.resolveAttribute
......@@ -100,6 +107,68 @@ fun Context.share(text: String, subject: String = getString(R.string.mozac_suppo
}
}
/**
* Emails content via [ACTION_SENDTO] intent.
*
* @param address the email address to send to [EXTRA_EMAIL]
* @param subject of the intent [EXTRA_TEXT]
* @return true it is able to share email false otherwise.
*/
@SuppressWarnings("TooManyFunctions")
fun Context.email(
address: String,
subject: String = getString(R.string.mozac_support_ktx_share_dialog_title)
): Boolean {
return try {
val intent = Intent(ACTION_SENDTO, Uri.parse("mailto:$address"))
intent.putExtra(EXTRA_SUBJECT, subject)
val emailIntent = Intent.createChooser(
intent,
getString(R.string.mozac_support_ktx_menu_email_with)
).apply {
flags = FLAG_ACTIVITY_NEW_TASK
}
startActivity(emailIntent)
true
} catch (e: ActivityNotFoundException) {
Logger.warn("No activity found to handle email intent", throwable = e)
false
}
}
/**
* Calls phone number via [ACTION_DIAL] intent.
*
* Note: we purposely use ACTION_DIAL rather than ACTION_CALL as the latter requires user permission
* @param phoneNumber the phone number to send to [ACTION_DIAL]
* @param subject of the intent [EXTRA_TEXT]
* @return true it is able to share phone call false otherwise.
*/
fun Context.call(
phoneNumber: String,
subject: String = getString(R.string.mozac_support_ktx_share_dialog_title)
): Boolean {
return try {
val intent = Intent(ACTION_DIAL, Uri.parse("tel:$phoneNumber"))
intent.putExtra(EXTRA_SUBJECT, subject)
val callIntent = Intent.createChooser(
intent,
getString(R.string.mozac_support_ktx_menu_call_with)
).apply {
flags = FLAG_ACTIVITY_NEW_TASK
}
startActivity(callIntent)
true
} catch (e: ActivityNotFoundException) {
Logger.warn("No activity found to handle dial intent", throwable = e)
false
}
}
/**
* Check if TalkBack service is enabled.
*
......
......@@ -6,6 +6,10 @@
-->
<resources>
<!-- Text displayed when choosing which app to call with after selecting a phone number-->
<string name="mozac_support_ktx_menu_call_with">Call with…</string>
<!-- Text displayed when choosing which app to email with after selecting an email address-->
<string name="mozac_support_ktx_menu_email_with">Email with…</string>
<string name="mozac_support_ktx_menu_share_with">Share with…</string>
<string name="mozac_support_ktx_share_dialog_title">Share via</string>
</resources>
\ No newline at end of file
......@@ -79,6 +79,32 @@ class ContextTest {
assertEquals(FLAG_ACTIVITY_NEW_TASK, argCaptor.value.flags)
}
@Test
fun `email invokes startActivity`() {
val context = spy(testContext)
val argCaptor = argumentCaptor<Intent>()
val result = context.email("test@mozilla.org")
verify(context).startActivity(argCaptor.capture())
assertTrue(result)
assertEquals(FLAG_ACTIVITY_NEW_TASK, argCaptor.value.flags)
}
@Test
fun `call invokes startActivity`() {
val context = spy(testContext)
val argCaptor = argumentCaptor<Intent>()
val result = context.call("555-5555")
verify(context).startActivity(argCaptor.capture())
assertTrue(result)
assertEquals(FLAG_ACTIVITY_NEW_TASK, argCaptor.value.flags)
}
@Test
fun `isMainProcess must only return true if we are in the main process`() {
val myPid = Int.MAX_VALUE
......
......@@ -24,7 +24,6 @@ import mozilla.components.concept.tabstray.TabsTray
import mozilla.components.feature.intent.ext.getSessionId
import mozilla.components.feature.contextmenu.ext.DefaultSelectionActionDelegate
import mozilla.components.support.base.feature.UserInteractionHandler
import mozilla.components.support.ktx.android.content.share
import mozilla.components.support.utils.SafeIntent
import mozilla.components.support.webextensions.WebExtensionPopupFeature
import org.mozilla.samples.browser.addons.WebExtensionActionPopupActivity
......@@ -73,11 +72,9 @@ open class BrowserActivity : AppCompatActivity(), ComponentCallbacks2 {
when (name) {
EngineView::class.java.name -> components.engine.createView(context, attrs).apply {
selectionActionDelegate = DefaultSelectionActionDelegate(
components.store,
context
) {
share(it)
}
store = components.store,
context = context
)
}.asView()
TabsTray::class.java.name -> createTabsTray(context, attrs)
else -> super.onCreateView(parent, name, context, attrs)
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment