Commit eb7646f0 authored by Jonathan Almeida's avatar Jonathan Almeida Committed by Jeff Boek
Browse files

Add custom share sheet and send tab support (#2757)

* Closes #2751: Add custom app share sheet

* Closes #2753: Add send tab devices to share sheet

* Closes #2752: Add build flag for send tab

* Replace Context.share with ShareFragment
parent 6057c370
......@@ -237,6 +237,11 @@ android.applicationVariants.all { variant ->
buildConfigField 'String', 'LEANPLUM_TOKEN', 'null'
println("X_X")
}
// -------------------------------------------------------------------------------------------------
// Feature build flags
// -------------------------------------------------------------------------------------------------
buildConfigField 'Boolean', 'SEND_TAB_ENABLED', "false"
}
androidExtensions {
......
......@@ -75,7 +75,6 @@ import org.mozilla.fenix.components.toolbar.ToolbarViewModel
import org.mozilla.fenix.customtabs.CustomTabsIntegration
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.share
import org.mozilla.fenix.ext.urlToTrimmedHost
import org.mozilla.fenix.home.sessioncontrol.Tab
import org.mozilla.fenix.lib.Do
......@@ -439,7 +438,7 @@ class BrowserFragment : Fragment(), BackHandler, CoroutineScope {
is QuickActionAction.SharePressed -> {
requireComponents.analytics.metrics.track(Event.QuickActionSheetShareTapped)
getSessionById()?.let { session ->
session.url.apply { requireContext().share(this) }
shareUrl(session.url)
}
}
is QuickActionAction.DownloadsPressed -> {
......@@ -616,7 +615,7 @@ class BrowserFragment : Fragment(), BackHandler, CoroutineScope {
is ToolbarMenu.Item.RequestDesktop -> sessionUseCases.requestDesktopSite.invoke(action.item.isChecked)
ToolbarMenu.Item.Share -> getSessionById()?.let { session ->
session.url.apply {
requireContext().share(this)
shareUrl(this)
}
}
ToolbarMenu.Item.NewPrivateTab -> {
......@@ -766,6 +765,11 @@ class BrowserFragment : Fragment(), BackHandler, CoroutineScope {
}
}
private fun shareUrl(url: String) {
val directions = BrowserFragmentDirections.actionBrowserFragmentToShareFragment(url)
Navigation.findNavController(view!!).navigate(directions)
}
companion object {
private const val REQUEST_CODE_DOWNLOAD_PERMISSIONS = 1
private const val REQUEST_CODE_PROMPT_PERMISSIONS = 2
......
......@@ -51,6 +51,7 @@ fun Context.getPreferenceKey(@StringRes resourceId: Int): String =
* @param subject of the intent [EXTRA_TEXT]
* @return true it is able to share false otherwise.
*/
@Deprecated("We are replacing the system share sheet with a custom version. See: [ShareFragment]")
fun Context.share(text: String, subject: String = ""): Boolean {
return try {
val intent = Intent(ACTION_SEND).apply {
......
......@@ -45,7 +45,6 @@ import org.mozilla.fenix.collections.SaveCollectionStep
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.allowUndo
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.share
import org.mozilla.fenix.ext.urlToTrimmedHost
import org.mozilla.fenix.home.sessioncontrol.CollectionAction
import org.mozilla.fenix.home.sessioncontrol.Mode
......@@ -302,7 +301,7 @@ class HomeFragment : Fragment(), CoroutineScope, AccountObserver {
invokePendingDeleteJobs()
requireComponents.core.sessionManager.findSessionById(action.sessionId)
?.let { session ->
requireContext().share(session.url)
share(session.url)
}
}
is TabAction.CloseAll -> {
......@@ -326,7 +325,7 @@ class HomeFragment : Fragment(), CoroutineScope, AccountObserver {
val shareText = requireComponents.core.sessionManager.sessions.joinToString("\n") {
it.url
}
requireContext().share(shareText)
share(shareText)
}
}
}
......@@ -397,7 +396,7 @@ class HomeFragment : Fragment(), CoroutineScope, AccountObserver {
val shareText = action.collection.tabs.joinToString("\n") {
it.url
}
requireContext().share(shareText)
share(shareText)
}
is CollectionAction.RemoveTab -> {
launch(Dispatchers.IO) {
......@@ -648,6 +647,11 @@ class HomeFragment : Fragment(), CoroutineScope, AccountObserver {
}
}
private fun share(text: String) {
val directions = HomeFragmentDirections.actionHomeFragmentToShareFragment(text)
Navigation.findNavController(view!!).navigate(directions)
}
private fun currentMode(): Mode = if (!onboarding.userHasBeenOnboarded()) {
val account = requireComponents.backgroundServices.accountManager.authenticatedAccount()
if (account == null) {
......
......@@ -46,7 +46,6 @@ import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.allowUndo
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.share
import org.mozilla.fenix.ext.urlToTrimmedHost
import org.mozilla.fenix.mvi.ActionBusFactory
import org.mozilla.fenix.mvi.getAutoDisposeObservable
......@@ -207,7 +206,10 @@ class BookmarkFragment : Fragment(), CoroutineScope, BackHandler, AccountObserve
}
is BookmarkAction.Share -> {
it.item.url?.apply {
requireContext().share(this)
navigation
.navigate(
BookmarkFragmentDirections.actionBookmarkFragmentToShareFragment(this)
)
requireComponents.analytics.metrics.track(Event.ShareBookmark)
}
}
......
......@@ -38,7 +38,6 @@ import org.mozilla.fenix.R
import org.mozilla.fenix.components.Components
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.share
import org.mozilla.fenix.mvi.ActionBusFactory
import org.mozilla.fenix.mvi.getAutoDisposeObservable
import org.mozilla.fenix.mvi.getManagedEmitter
......@@ -182,12 +181,12 @@ class HistoryFragment : Fragment(), CoroutineScope, BackHandler {
R.id.share_history_multi_select -> {
val selectedHistory = (historyComponent.uiView as HistoryUIView).getSelected()
when {
selectedHistory.size == 1 -> context?.share(selectedHistory.first().url)
selectedHistory.size == 1 -> share(selectedHistory.first().url)
selectedHistory.size > 1 -> {
val shareText = selectedHistory.joinToString("\n") {
it.url
}
requireContext().share(shareText)
share(shareText)
}
}
true
......@@ -283,4 +282,9 @@ class HistoryFragment : Fragment(), CoroutineScope, BackHandler {
components.core.historyStorage.deleteVisit(it.url, it.visitedAt)
}
}
private fun share(text: String) {
val directions = HistoryFragmentDirections.actionHistoryFragmentToShareFragment(text)
Navigation.findNavController(view!!).navigate(directions)
}
}
/* 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 org.mozilla.fenix.share
import android.content.Context
import android.graphics.PorterDuff
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import kotlinx.android.synthetic.main.account_share_list_item.view.*
import mozilla.components.concept.sync.Device
import mozilla.components.concept.sync.DeviceCapability
import mozilla.components.concept.sync.DeviceType
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.components
class AccountDevicesShareRecyclerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr) {
init {
layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
}
}
class AccountDevicesShareAdapter(
private val context: Context,
val actionEmitter: Observer<ShareAction>
) : RecyclerView.Adapter<AccountDeviceViewHolder>() {
private val devices = buildDeviceList()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountDeviceViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(AccountDeviceViewHolder.LAYOUT_ID, parent, false)
return AccountDeviceViewHolder(view, actionEmitter)
}
override fun getItemCount(): Int = devices.size
override fun onBindViewHolder(holder: AccountDeviceViewHolder, position: Int) {
holder.bind(devices[position])
}
private fun buildDeviceList(): List<SyncShareOption> {
val list = mutableListOf<SyncShareOption>()
val accountManager = context.components.backgroundServices.accountManager
if (accountManager.authenticatedAccount() == null) {
list.add(SyncShareOption.SignIn)
return list
}
list.add(SyncShareOption.AddNewDevice)
accountManager.authenticatedAccount()?.deviceConstellation()?.state()?.otherDevices?.let { devices ->
val shareableDevices = devices
.filter {
it.capabilities.contains(DeviceCapability.SEND_TAB)
}
val shareOptions = shareableDevices.map {
when (it.deviceType) {
DeviceType.MOBILE -> SyncShareOption.Mobile(it.displayName, it)
else -> SyncShareOption.Desktop(it.displayName, it)
}
}
list.addAll(shareOptions)
if (shareableDevices.size > 1) {
list.add(SyncShareOption.SendAll(shareableDevices))
}
}
return list
}
}
class AccountDeviceViewHolder(
itemView: View,
actionEmitter: Observer<ShareAction>
) : RecyclerView.ViewHolder(itemView) {
private val context: Context = itemView.context
private var action: ShareAction? = null
init {
itemView.setOnClickListener {
action?.let { actionEmitter.onNext(it) }
}
}
fun bind(option: SyncShareOption) {
val (name, drawableRes, colorRes) = when (option) {
SyncShareOption.SignIn -> {
action = ShareAction.SignInClicked
Triple(
context.getText(R.string.sync_sign_in),
R.drawable.mozac_ic_sync,
R.color.default_share_background
)
}
SyncShareOption.AddNewDevice -> {
action = ShareAction.AddNewDeviceClicked
Triple(
context.getText(R.string.sync_connect_device),
R.drawable.mozac_ic_new,
R.color.default_share_background
)
}
is SyncShareOption.SendAll -> {
action = ShareAction.SendAllClicked(option.devices)
Triple(
context.getText(R.string.sync_send_to_all),
R.drawable.mozac_ic_select_all,
R.color.default_share_background
)
}
is SyncShareOption.Mobile -> {
action = ShareAction.ShareDeviceClicked(option.device)
Triple(
option.name,
R.drawable.mozac_ic_device_mobile,
R.color.device_type_mobile_background
)
}
is SyncShareOption.Desktop -> {
action = ShareAction.ShareDeviceClicked(option.device)
Triple(
option.name,
R.drawable.mozac_ic_device_desktop,
R.color.device_type_desktop_background
)
}
}
itemView.device_icon.apply {
setImageResource(drawableRes)
background.setColorFilter(ContextCompat.getColor(context, colorRes), PorterDuff.Mode.SRC_IN)
drawable.setTint(ContextCompat.getColor(context, R.color.device_foreground))
}
itemView.device_name.text = name
}
companion object {
const val LAYOUT_ID = R.layout.account_share_list_item
}
}
sealed class SyncShareOption {
object SignIn : SyncShareOption()
object AddNewDevice : SyncShareOption()
data class SendAll(val devices: List<Device>) : SyncShareOption()
data class Mobile(val name: String, val device: Device) : SyncShareOption()
data class Desktop(val name: String, val device: Device) : SyncShareOption()
}
/* 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 org.mozilla.fenix.share
import android.content.Context
import android.content.Intent
import android.content.Intent.ACTION_SEND
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import kotlinx.android.synthetic.main.app_share_list_item.view.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.mozilla.fenix.R
import kotlin.coroutines.CoroutineContext
class AppShareRecyclerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr) {
init {
layoutManager = GridLayoutManager(context, 2, GridLayoutManager.HORIZONTAL, false)
}
}
class AppShareAdapter(
private val context: Context,
val actionEmitter: Observer<ShareAction>,
private val intentType: String = "text/plain"
) : RecyclerView.Adapter<AppShareItemViewHolder>(), CoroutineScope {
private var job: Job = Job()
override val coroutineContext: CoroutineContext
get() = Dispatchers.IO + job
private var size: Int = 0
private val shareItems: MutableList<ShareItem> = mutableListOf()
init {
val testIntent = Intent(ACTION_SEND).apply {
type = intentType
flags = FLAG_ACTIVITY_NEW_TASK
}
launch {
val activities = context.packageManager.queryIntentActivities(testIntent, 0)
val items = activities.map { resolveInfo ->
ShareItem(
resolveInfo.loadLabel(context.packageManager).toString(),
resolveInfo.loadIcon(context.packageManager),
resolveInfo.activityInfo.packageName
)
}
size = activities.size
shareItems.addAll(items)
// Notify adapter on the UI thread when the dataset is populated.
withContext(Dispatchers.Main) {
notifyDataSetChanged()
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppShareItemViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(AppShareItemViewHolder.LAYOUT_ID, parent, false)
return AppShareItemViewHolder(view, actionEmitter)
}
override fun getItemCount(): Int = size
override fun onBindViewHolder(holder: AppShareItemViewHolder, position: Int) {
holder.bind(shareItems[position])
}
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
super.onAttachedToRecyclerView(recyclerView)
job = Job()
}
override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
super.onDetachedFromRecyclerView(recyclerView)
job.cancel()
}
}
class AppShareItemViewHolder(
itemView: View,
actionEmitter: Observer<ShareAction>
) : RecyclerView.ViewHolder(itemView) {
private var shareItem: ShareItem? = null
init {
itemView.setOnClickListener {
Log.d("Jonathan", "${shareItem?.name} clicked.")
shareItem?.let {
actionEmitter.onNext(ShareAction.ShareAppClicked(it.packageName))
}
}
}
internal fun bind(item: ShareItem) {
shareItem = item
itemView.app_name.text = item.name
itemView.app_icon.setImageDrawable(item.icon)
}
companion object {
const val LAYOUT_ID = R.layout.app_share_list_item
}
}
data class ShareItem(val name: String, val icon: Drawable, val packageName: String)
package org.mozilla.fenix.share
/* 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/. */
import android.view.ViewGroup
import mozilla.components.concept.sync.Device
import org.mozilla.fenix.mvi.Action
import org.mozilla.fenix.mvi.ActionBusFactory
import org.mozilla.fenix.mvi.Change
import org.mozilla.fenix.mvi.Reducer
import org.mozilla.fenix.mvi.UIComponent
import org.mozilla.fenix.mvi.UIComponentViewModelBase
import org.mozilla.fenix.mvi.UIComponentViewModelProvider
import org.mozilla.fenix.mvi.ViewState
object ShareState : ViewState
sealed class ShareChange : Change
sealed class ShareAction : Action {
object Close : ShareAction()
object SignInClicked : ShareAction()
object AddNewDeviceClicked : ShareAction()
data class ShareDeviceClicked(val device: Device) : ShareAction()
data class SendAllClicked(val devices: List<Device>) : ShareAction()
data class ShareAppClicked(val packageName: String) : ShareAction()
}
class ShareComponent(
private val container: ViewGroup,
bus: ActionBusFactory,
viewModelProvider: UIComponentViewModelProvider<ShareState, ShareChange>
) : UIComponent<ShareState, ShareAction, ShareChange>(
bus.getManagedEmitter(ShareAction::class.java),
bus.getSafeManagedObservable(ShareChange::class.java),
viewModelProvider
) {
override fun initView() = ShareUIView(container, actionEmitter, changesObservable)
init {
bind()
}
}
class ShareUIViewModel(
initialState: ShareState
) : UIComponentViewModelBase<ShareState, ShareChange>(
initialState,
reducer
) {
companion object {
val reducer: Reducer<ShareState, ShareChange> = { _, _ -> ShareState }
}
}
package org.mozilla.fenix.share
/* 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/. */
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.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.DialogFragment
import kotlinx.android.synthetic.main.fragment_share.view.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import org.mozilla.fenix.FenixViewModelProvider
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.mvi.ActionBusFactory
import org.mozilla.fenix.mvi.getAutoDisposeObservable
import kotlin.coroutines.CoroutineContext
class ShareFragment : DialogFragment(), CoroutineScope {
override val coroutineContext: CoroutineContext
get() = Dispatchers.Main + job
private lateinit var job: Job
private lateinit var component: ShareComponent
private lateinit var url: String
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_TITLE, R.style.CreateCollectionDialogStyle)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.fragment_share, container, false)
val args = ShareFragmentArgs.fromBundle(arguments!!)
job = Job()
url = args.url
component = ShareComponent(
view.share_wrapper,
ActionBusFactory.get(this),
FenixViewModelProvider.create(
this,
ShareUIViewModel::class.java
) {
ShareUIViewModel(ShareState)
}
)
return view
}
override fun onResume() {
super.onResume()
subscribeToActions()
}
override fun onDestroyView() {
super.onDestroyView()
job.cancel()
}
private fun subscribeToActions() {
getAutoDisposeObservable<ShareAction>().subscribe {
when (it) {
ShareAction.Close -> {
dismiss()
}
ShareAction.AddNewDeviceClicked -> {