1
0
Fork 0

For #5783 - Web Share with Fenix share sheet (#6883)

master
Tiger Oakes 2019-12-10 10:57:06 -08:00 committed by GitHub
parent f5f0cb8d9c
commit fe034226a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 137 additions and 28 deletions

View File

@ -32,6 +32,7 @@ import kotlinx.coroutines.withContext
import mozilla.appservices.places.BookmarkRoot
import mozilla.components.browser.session.Session
import mozilla.components.browser.session.SessionManager
import mozilla.components.concept.engine.prompt.ShareData
import mozilla.components.feature.accounts.FxaCapability
import mozilla.components.feature.accounts.FxaWebChannelFeature
import mozilla.components.feature.app.links.AppLinksFeature
@ -42,6 +43,7 @@ import mozilla.components.feature.downloads.DownloadsFeature
import mozilla.components.feature.downloads.manager.FetchDownloadManager
import mozilla.components.feature.intent.ext.EXTRA_SESSION_ID
import mozilla.components.feature.prompts.PromptFeature
import mozilla.components.feature.prompts.share.ShareDelegate
import mozilla.components.feature.readerview.ReaderViewFeature
import mozilla.components.feature.session.FullScreenFeature
import mozilla.components.feature.session.SessionFeature
@ -58,6 +60,7 @@ import org.mozilla.fenix.Experiments
import org.mozilla.fenix.FeatureFlags
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.IntentReceiverActivity
import org.mozilla.fenix.NavGraphDirections
import org.mozilla.fenix.R
import org.mozilla.fenix.browser.readermode.DefaultReaderModeController
import org.mozilla.fenix.components.FenixSnackbar
@ -314,6 +317,21 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Session
store = store,
customTabId = customTabSessionId,
fragmentManager = parentFragmentManager,
shareDelegate = object : ShareDelegate {
override fun showShareSheet(
context: Context,
shareData: ShareData,
onDismiss: () -> Unit,
onSuccess: () -> Unit
) {
val directions = NavGraphDirections.actionGlobalShareFragment(
data = arrayOf(shareData),
showPage = true,
sessionId = getSessionById()?.id
)
findNavController().navigate(directions)
}
},
onNeedToRequestPermissions = { permissions ->
requestPermissions(permissions, REQUEST_CODE_PROMPT_PERMISSIONS)
}),

View File

@ -9,6 +9,7 @@ import android.content.Intent
import android.content.Intent.ACTION_SEND
import android.content.Intent.EXTRA_TEXT
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.net.Uri
import androidx.annotation.VisibleForTesting
import androidx.navigation.NavController
import com.google.android.material.snackbar.Snackbar
@ -21,10 +22,8 @@ import mozilla.components.concept.sync.Device
import mozilla.components.concept.sync.TabData
import mozilla.components.feature.sendtab.SendTabUseCases
import org.mozilla.fenix.R
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.FenixSnackbarPresenter
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.getRootView
import org.mozilla.fenix.ext.metrics
import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.share.listadapters.AppShareOption
@ -42,6 +41,10 @@ interface ShareController {
fun handleShareToDevice(device: Device)
fun handleShareToAllDevices(devices: List<Device>)
fun handleSignIn()
enum class Result {
DISMISSED, SHARE_ERROR, SUCCESS
}
}
/**
@ -61,17 +64,17 @@ class DefaultShareController(
private val sendTabUseCases: SendTabUseCases,
private val snackbarPresenter: FenixSnackbarPresenter,
private val navController: NavController,
private val dismiss: () -> Unit
private val dismiss: (ShareController.Result) -> Unit
) : ShareController {
override fun handleReauth() {
val directions = ShareFragmentDirections.actionShareFragmentToAccountProblemFragment()
navController.nav(R.id.shareFragment, directions)
dismiss()
dismiss(ShareController.Result.DISMISSED)
}
override fun handleShareClosed() {
dismiss()
dismiss(ShareController.Result.DISMISSED)
}
override fun handleShareToApp(app: AppShareOption) {
@ -82,16 +85,14 @@ class DefaultShareController(
setClassName(app.packageName, app.activityName)
}
try {
val result = try {
context.startActivity(intent)
ShareController.Result.SUCCESS
} catch (e: SecurityException) {
context.getRootView()?.let {
FenixSnackbar.make(it, Snackbar.LENGTH_LONG)
.setText(context.getString(R.string.share_error_snackbar))
.show()
}
snackbarPresenter.present(context.getString(R.string.share_error_snackbar))
ShareController.Result.SHARE_ERROR
}
dismiss()
dismiss(result)
}
override fun handleAddNewDevice() {
@ -112,18 +113,20 @@ class DefaultShareController(
context.metrics.track(Event.SignInToSendTab)
val directions = ShareFragmentDirections.actionShareFragmentToTurnOnSyncFragment()
navController.nav(R.id.shareFragment, directions)
dismiss()
dismiss(ShareController.Result.DISMISSED)
}
private fun shareToDevicesWithRetry(shareOperation: () -> Deferred<Boolean>) {
// Use GlobalScope to allow the continuation of this method even if the share fragment is closed.
GlobalScope.launch(Dispatchers.Main) {
if (shareOperation.invoke().await()) {
val result = if (shareOperation.invoke().await()) {
showSuccess()
ShareController.Result.SUCCESS
} else {
showFailureWithRetryOption { shareToDevicesWithRetry(shareOperation) }
ShareController.Result.DISMISSED
}
dismiss()
dismiss(result)
}
}
@ -161,7 +164,11 @@ class DefaultShareController(
// Navigation between app fragments uses ShareTab as arguments. SendTabUseCases uses TabData.
@VisibleForTesting
fun List<ShareData>.toTabData() = map { data ->
TabData(data.title.orEmpty(), data.url.orEmpty())
internal fun List<ShareData>.toTabData() = map { data ->
TabData(title = data.title.orEmpty(), url = data.url ?: data.text?.toDataUri().orEmpty())
}
private fun String.toDataUri(): String {
return "data:,${Uri.encode(this)}"
}
}

View File

@ -16,6 +16,9 @@ import androidx.lifecycle.observe
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import kotlinx.android.synthetic.main.fragment_share.view.*
import mozilla.components.browser.state.action.ContentAction
import mozilla.components.browser.state.selector.findTabOrCustomTab
import mozilla.components.concept.engine.prompt.PromptRequest
import mozilla.components.feature.sendtab.SendTabUseCases
import org.mozilla.fenix.R
import org.mozilla.fenix.components.FenixSnackbarPresenter
@ -24,6 +27,7 @@ import org.mozilla.fenix.ext.requireComponents
class ShareFragment : AppCompatDialogFragment() {
private val args by navArgs<ShareFragmentArgs>()
private val viewModel: ShareViewModel by viewModels {
AndroidViewModelFactory(requireActivity().application)
}
@ -37,6 +41,11 @@ class ShareFragment : AppCompatDialogFragment() {
viewModel.loadDevicesAndApps()
}
override fun dismiss() {
consumePrompt { onDismiss() }
super.dismiss()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_TITLE, R.style.ShareDialogStyle)
@ -48,7 +57,6 @@ class ShareFragment : AppCompatDialogFragment() {
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_share, container, false)
val args by navArgs<ShareFragmentArgs>()
val shareData = args.data.toList()
val accountManager = requireComponents.backgroundServices.accountManager
@ -59,9 +67,17 @@ class ShareFragment : AppCompatDialogFragment() {
shareData = shareData,
snackbarPresenter = FenixSnackbarPresenter(activity!!.getRootView()!!),
navController = findNavController(),
sendTabUseCases = SendTabUseCases(accountManager),
dismiss = ::dismiss
)
sendTabUseCases = SendTabUseCases(accountManager)
) { result ->
consumePrompt {
when (result) {
ShareController.Result.DISMISSED -> onDismiss()
ShareController.Result.SHARE_ERROR -> onFailure()
ShareController.Result.SUCCESS -> onSuccess()
}
}
super.dismiss()
}
)
view.shareWrapper.setOnClickListener { shareInteractor.onShareClosed() }
@ -94,6 +110,25 @@ class ShareFragment : AppCompatDialogFragment() {
}
}
/**
* If [ShareFragmentArgs.sessionId] is set and the session has a pending Web Share
* prompt request, call [consume] then clean up the prompt.
*/
private fun consumePrompt(
consume: PromptRequest.Share.() -> Unit
) {
val browserStore = requireComponents.core.store
args.sessionId
?.let { sessionId -> browserStore.state.findTabOrCustomTab(sessionId) }
?.let { tab ->
val promptRequest = tab.content.promptRequest
if (promptRequest is PromptRequest.Share) {
consume(promptRequest)
browserStore.dispatch(ContentAction.ConsumePromptRequestAction(tab.id))
}
}
}
companion object {
const val SHOW_PAGE_ALPHA = 0.6f
}

View File

@ -63,7 +63,7 @@ class ShareViewModel(application: Application) : AndroidViewModel(application) {
/**
* Load a list of devices and apps into [devicesList] and [appsList].
* Should be called when a fragment is attached so the data can be fetched early.
* Should be called when the fragment is attached so the data can be fetched early.
*/
fun loadDevicesAndApps() {
val networkRequest = NetworkRequest.Builder().build()
@ -86,6 +86,9 @@ class ShareViewModel(application: Application) : AndroidViewModel(application) {
}
}
/**
* Unregisters the network callback and cleans up.
*/
override fun onCleared() {
connectivityManager?.unregisterNetworkCallback(networkCallback)
}

View File

@ -562,6 +562,11 @@
<action
android:id="@+id/action_shareFragment_to_addNewDeviceFragment"
app:destination="@id/addNewDeviceFragment" />
<argument
android:name="sessionId"
app:argType="string"
app:nullable="true"
android:defaultValue="null" />
</dialog>
<dialog
android:id="@+id/quickSettingsSheetDialogFragment"

View File

@ -63,7 +63,7 @@ class ShareControllerTest {
private val sendTabUseCases = mockk<SendTabUseCases>(relaxed = true)
private val snackbarPresenter = mockk<FenixSnackbarPresenter>(relaxed = true)
private val navController = mockk<NavController>(relaxed = true)
private val dismiss = mockk<() -> Unit>(relaxed = true)
private val dismiss = mockk<(ShareController.Result) -> Unit>(relaxed = true)
private val controller = DefaultShareController(
context, shareData, sendTabUseCases, snackbarPresenter, navController, dismiss
)
@ -77,7 +77,7 @@ class ShareControllerTest {
fun `handleShareClosed should call a passed in delegate to close this`() {
controller.handleShareClosed()
verify { dismiss() }
verify { dismiss(ShareController.Result.DISMISSED) }
}
@Test
@ -95,7 +95,7 @@ class ShareControllerTest {
testController.handleShareToApp(appShareOption)
// Check that the Intent used for querying apps has the expected structre
// Check that the Intent used for querying apps has the expected structure
assertAll {
assertThat(shareIntent.isCaptured).isTrue()
assertThat(shareIntent.captured.action).isEqualTo(Intent.ACTION_SEND)
@ -107,7 +107,30 @@ class ShareControllerTest {
}
verifyOrder {
activityContext.startActivity(shareIntent.captured)
dismiss()
dismiss(ShareController.Result.SUCCESS)
}
}
@Test
fun `handleShareToApp should dismiss with an error start when a security exception occurs`() {
val appPackageName = "package"
val appClassName = "activity"
val appShareOption = AppShareOption("app", mockk(), appPackageName, appClassName)
val shareIntent = slot<Intent>()
// Our share Intent uses `FLAG_ACTIVITY_NEW_TASK` but when resolving the startActivity call
// needed for capturing the actual Intent used the `slot` one doesn't have this flag so we
// need to use an Activity Context.
val activityContext: Context = mockk<Activity>()
val testController = DefaultShareController(activityContext, shareData, mockk(), snackbarPresenter, mockk(), dismiss)
every { activityContext.startActivity(capture(shareIntent)) } throws SecurityException()
every { activityContext.getString(R.string.share_error_snackbar) } returns "Cannot share to this app"
testController.handleShareToApp(appShareOption)
verifyOrder {
activityContext.startActivity(shareIntent.captured)
snackbarPresenter.present("Cannot share to this app")
dismiss(ShareController.Result.SHARE_ERROR)
}
}
@ -167,7 +190,7 @@ class ShareControllerTest {
R.id.shareFragment,
ShareFragmentDirections.actionShareFragmentToTurnOnSyncFragment()
)
dismiss()
dismiss(ShareController.Result.DISMISSED)
}
}
@ -180,7 +203,7 @@ class ShareControllerTest {
R.id.shareFragment,
ShareFragmentDirections.actionShareFragmentToAccountProblemFragment()
)
dismiss()
dismiss(ShareController.Result.DISMISSED)
}
}
@ -276,4 +299,22 @@ class ShareControllerTest {
assertThat(tabData).isEqualTo(tabsData)
}
@Test
fun `ShareTab#toTabData creates a data url from text if no url is specified`() {
var tabData: List<TabData>
val expected = listOf(
TabData(title = "title0", url = ""),
TabData(title = "title1", url = "data:,Hello%2C%20World!")
)
with(controller) {
tabData = listOf(
ShareData(title = "title0"),
ShareData(title = "title1", text = "Hello, World!")
).toTabData()
}
assertThat(tabData).isEqualTo(expected)
}
}