Commit 333ff8c9 authored by Tiger Oakes's avatar Tiger Oakes Committed by Emily Kager
Browse files

Fixes #4528 - Prevent share menu from jumping

Plus a bunch of docs and refactoring
parent 6b9a0d02
......@@ -13,6 +13,9 @@ import kotlinx.android.synthetic.main.fragment_add_new_device.*
import org.mozilla.fenix.R
import org.mozilla.fenix.settings.SupportUtils
/**
* Fragment to add a new device. Tabs can be shared to devices after they are added.
*/
class AddNewDeviceFragment : Fragment(R.layout.fragment_add_new_device) {
override fun onResume() {
......
......@@ -23,6 +23,7 @@ class ShareCloseView(
override val containerView: ViewGroup,
private val interactor: ShareCloseInteractor
) : LayoutContainer {
val adapter = ShareTabsAdapter()
init {
......@@ -36,6 +37,6 @@ class ShareCloseView(
}
fun setTabs(tabs: List<ShareTab>) {
adapter.setTabs(tabs)
adapter.submitList(tabs)
}
}
......@@ -26,7 +26,7 @@ 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
import org.mozilla.fenix.share.listadapters.AndroidShareOption
/**
* [ShareFragment] controller.
......@@ -36,7 +36,7 @@ import org.mozilla.fenix.share.listadapters.AppShareOption
interface ShareController {
fun handleReauth()
fun handleShareClosed()
fun handleShareToApp(app: AppShareOption)
fun handleShareToApp(app: AndroidShareOption.App)
fun handleAddNewDevice()
fun handleShareToDevice(device: Device)
fun handleShareToAllDevices(devices: List<Device>)
......@@ -72,7 +72,7 @@ class DefaultShareController(
dismiss()
}
override fun handleShareToApp(app: AppShareOption) {
override fun handleShareToApp(app: AndroidShareOption.App) {
val intent = Intent(ACTION_SEND).apply {
putExtra(EXTRA_TEXT, getShareText())
type = "text/plain"
......@@ -116,9 +116,10 @@ class DefaultShareController(
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) {
when (shareOperation.invoke().await()) {
true -> showSuccess()
false -> showFailureWithRetryOption { shareToDevicesWithRetry(shareOperation) }
if (shareOperation.invoke().await()) {
showSuccess()
} else {
showFailureWithRetryOption { shareToDevicesWithRetry(shareOperation) }
}
dismiss()
}
......
......@@ -17,17 +17,19 @@ import android.os.Parcelable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.WorkerThread
import androidx.appcompat.app.AppCompatDialogFragment
import androidx.core.content.getSystemService
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import kotlinx.android.parcel.Parcelize
import kotlinx.android.synthetic.main.fragment_share.view.*
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import mozilla.components.concept.sync.DeviceCapability
import mozilla.components.concept.sync.DeviceType
import mozilla.components.feature.sendtab.SendTabUseCases
import mozilla.components.service.fxa.manager.FxaAccountManager
import org.mozilla.fenix.R
......@@ -35,7 +37,7 @@ import org.mozilla.fenix.components.FenixSnackbarPresenter
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.getRootView
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.share.listadapters.AppShareOption
import org.mozilla.fenix.share.listadapters.AndroidShareOption
import org.mozilla.fenix.share.listadapters.SyncShareOption
@Suppress("TooManyFunctions")
......@@ -44,20 +46,39 @@ class ShareFragment : AppCompatDialogFragment() {
private lateinit var shareCloseView: ShareCloseView
private lateinit var shareToAccountDevicesView: ShareToAccountDevicesView
private lateinit var shareToAppsView: ShareToAppsView
private lateinit var appsListDeferred: Deferred<List<AppShareOption>>
private lateinit var appsListDeferred: Deferred<List<AndroidShareOption>>
private lateinit var devicesListDeferred: Deferred<List<SyncShareOption>>
private var connectivityManager: ConnectivityManager? = null
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onLost(network: Network?) = reloadDevices()
override fun onAvailable(network: Network?) = reloadDevices()
private fun reloadDevices() {
context?.let { context ->
val fxaAccountManager = context.components.backgroundServices.accountManager
lifecycleScope.launch {
fxaAccountManager.authenticatedAccount()
?.deviceConstellation()
?.refreshDevicesAsync()
?.await()
val devicesShareOptions = buildDeviceList(fxaAccountManager)
shareToAccountDevicesView.setShareTargets(devicesShareOptions)
}
}
}
}
override fun onAttach(context: Context) {
super.onAttach(context)
connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
connectivityManager = context.getSystemService()
val networkRequest = NetworkRequest.Builder().build()
connectivityManager?.registerNetworkCallback(networkRequest, networkCallback)
// Start preparing the data as soon as we have a valid Context
appsListDeferred = lifecycleScope.async(Dispatchers.IO) {
appsListDeferred = lifecycleScope.async(IO) {
val shareIntent = Intent(ACTION_SEND).apply {
type = "text/plain"
flags = FLAG_ACTIVITY_NEW_TASK
......@@ -66,53 +87,29 @@ class ShareFragment : AppCompatDialogFragment() {
buildAppsList(shareAppsActivities, context)
}
devicesListDeferred = lifecycleScope.async(Dispatchers.IO) {
devicesListDeferred = lifecycleScope.async(IO) {
val fxaAccountManager = context.components.backgroundServices.accountManager
buildDeviceList(fxaAccountManager)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_TITLE, R.style.ShareDialogStyle)
}
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onLost(network: Network?) {
reloadDevices()
}
override fun onAvailable(network: Network?) {
reloadDevices()
}
}
private fun reloadDevices() {
context?.let {
val fxaAccountManager = it.components.backgroundServices.accountManager
lifecycleScope.launch {
val refreshDevicesAsync =
fxaAccountManager.authenticatedAccount()?.deviceConstellation()
?.refreshDevicesAsync()
refreshDevicesAsync?.await()
val devicesShareOptions = buildDeviceList(fxaAccountManager)
shareToAccountDevicesView.setSharetargets(devicesShareOptions)
}
}
}
override fun onDetach() {
connectivityManager?.unregisterNetworkCallback(networkCallback)
super.onDetach()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_TITLE, R.style.ShareDialogStyle)
}
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!!)
val args by navArgs<ShareFragmentArgs>()
check(!(args.url == null && args.tabs.isNullOrEmpty())) { "URL and tabs cannot both be null." }
val tabs = args.tabs?.toList() ?: listOf(ShareTab(args.url!!, args.title.orEmpty()))
......@@ -153,74 +150,84 @@ class ShareFragment : AppCompatDialogFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Start with some invisible views so the share menu height doesn't jump later
shareToAppsView.setShareTargets(
listOf(AndroidShareOption.Invisible, AndroidShareOption.Invisible)
)
lifecycleScope.launch {
val devicesShareOptions = devicesListDeferred.await()
shareToAccountDevicesView.setSharetargets(devicesShareOptions)
shareToAccountDevicesView.setShareTargets(devicesShareOptions)
val appsToShareTo = appsListDeferred.await()
shareToAppsView.setShareTargets(appsToShareTo)
}
}
@WorkerThread
private fun getIntentActivities(shareIntent: Intent, context: Context): List<ResolveInfo>? {
return context.packageManager.queryIntentActivities(shareIntent, 0)
}
/**
* Returns a list of apps that can be shared to.
* @param intentActivities List of activities from [getIntentActivities].
*/
@WorkerThread
private fun buildAppsList(
intentActivities: List<ResolveInfo>?,
context: Context
): List<AppShareOption> {
return intentActivities?.map { resolveInfo ->
AppShareOption(
resolveInfo.loadLabel(context.packageManager).toString(),
resolveInfo.loadIcon(context.packageManager),
resolveInfo.activityInfo.packageName,
resolveInfo.activityInfo.name
)
}?.filter { it.packageName != context.packageName }.orEmpty()
): List<AndroidShareOption> {
return intentActivities
.orEmpty()
.filter { it.activityInfo.packageName != context.packageName }
.map { resolveInfo ->
AndroidShareOption.App(
resolveInfo.loadLabel(context.packageManager).toString(),
resolveInfo.loadIcon(context.packageManager),
resolveInfo.activityInfo.packageName,
resolveInfo.activityInfo.name
)
}
}
@Suppress("ReturnCount")
/**
* Builds list of options to display in the top row of the share sheet.
* This will primarily include devices that tabs can be sent to, but also options
* for reconnecting the account or sending to all devices.
*/
private fun buildDeviceList(accountManager: FxaAccountManager): List<SyncShareOption> {
val list = mutableListOf<SyncShareOption>()
val activeNetwork = connectivityManager?.activeNetworkInfo
if (activeNetwork?.isConnected != true) {
list.add(SyncShareOption.Offline)
return list
}
if (accountManager.authenticatedAccount() == null) {
list.add(SyncShareOption.SignIn)
return list
}
if (accountManager.accountNeedsReauth()) {
list.add(SyncShareOption.Reconnect)
return list
}
accountManager.authenticatedAccount()?.deviceConstellation()?.state()
?.otherDevices?.let { devices ->
val shareableDevices =
devices.filter { it.capabilities.contains(DeviceCapability.SEND_TAB) }
val account = accountManager.authenticatedAccount()
return when {
// No network
activeNetwork?.isConnected != true -> listOf(SyncShareOption.Offline)
// No account signed in
account == null -> listOf(SyncShareOption.SignIn)
// Account needs to be re-authenticated
accountManager.accountNeedsReauth() -> listOf(SyncShareOption.Reconnect)
// Signed in
else -> {
val shareableDevices = account.deviceConstellation().state()
?.otherDevices
.orEmpty()
.filter { it.capabilities.contains(DeviceCapability.SEND_TAB) }
val list = mutableListOf<SyncShareOption>()
if (shareableDevices.isEmpty()) {
// Show add device button if there are no devices
list.add(SyncShareOption.AddNewDevice)
}
if (shareableDevices.isEmpty()) {
list.add(SyncShareOption.AddNewDevice)
}
shareableDevices.mapTo(list) { SyncShareOption.SingleDevice(it) }
val shareOptions = shareableDevices.map {
when (it.deviceType) {
DeviceType.MOBILE -> SyncShareOption.Mobile(it.displayName, it)
else -> SyncShareOption.Desktop(it.displayName, it)
if (shareableDevices.size > 1) {
// Show send all button if there are multiple devices
list.add(SyncShareOption.SendAll(shareableDevices))
}
}
list.addAll(shareOptions)
if (shareableDevices.size > 1) {
list.add(SyncShareOption.SendAll(shareableDevices))
list
}
}
return list
}
companion object {
......
......@@ -5,7 +5,7 @@
package org.mozilla.fenix.share
import mozilla.components.concept.sync.Device
import org.mozilla.fenix.share.listadapters.AppShareOption
import org.mozilla.fenix.share.listadapters.AndroidShareOption
/**
* Interactor for the share screen.
......@@ -37,7 +37,7 @@ class ShareInteractor(
controller.handleShareToAllDevices(devices)
}
override fun onShareToApp(appToShareTo: AppShareOption) {
override fun onShareToApp(appToShareTo: AndroidShareOption.App) {
controller.handleShareToApp(appToShareTo)
}
}
......@@ -26,18 +26,19 @@ interface ShareToAccountDevicesInteractor {
class ShareToAccountDevicesView(
override val containerView: ViewGroup,
private val interactor: ShareToAccountDevicesInteractor
interactor: ShareToAccountDevicesInteractor
) : LayoutContainer {
private val adapter = AccountDevicesShareAdapter(interactor)
init {
LayoutInflater.from(containerView.context)
.inflate(R.layout.share_to_account_devices, containerView, true)
devicesList.adapter = AccountDevicesShareAdapter(interactor)
devicesList.adapter = adapter
}
fun setSharetargets(targets: List<SyncShareOption>) {
with(devicesList.adapter as AccountDevicesShareAdapter) {
updateData(targets)
}
fun setShareTargets(targets: List<SyncShareOption>) {
adapter.submitList(targets)
}
}
......@@ -10,14 +10,14 @@ import android.view.ViewGroup
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.share_to_apps.*
import org.mozilla.fenix.R
import org.mozilla.fenix.share.listadapters.AndroidShareOption
import org.mozilla.fenix.share.listadapters.AppShareAdapter
import org.mozilla.fenix.share.listadapters.AppShareOption
/**
* Callbacks for possible user interactions on the [ShareCloseView]
*/
interface ShareToAppsInteractor {
fun onShareToApp(appToShareTo: AppShareOption)
fun onShareToApp(appToShareTo: AndroidShareOption.App)
}
class ShareToAppsView(
......@@ -34,7 +34,7 @@ class ShareToAppsView(
appsList.adapter = adapter
}
fun setShareTargets(targets: List<AppShareOption>) {
fun setShareTargets(targets: List<AndroidShareOption>) {
progressBar.visibility = View.GONE
appsList.visibility = View.VISIBLE
......
......@@ -6,15 +6,19 @@ package org.mozilla.fenix.share.listadapters
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import mozilla.components.concept.sync.Device
import org.mozilla.fenix.share.ShareToAccountDevicesInteractor
import org.mozilla.fenix.share.viewholders.AccountDeviceViewHolder
/**
* Adapter for a list of devices that can be shared to.
* May also display buttons to reconnect, add a device, or send to all devices.
*/
class AccountDevicesShareAdapter(
private val interactor: ShareToAccountDevicesInteractor,
private val devices: MutableList<SyncShareOption> = mutableListOf()
) : RecyclerView.Adapter<AccountDeviceViewHolder>() {
private val interactor: ShareToAccountDevicesInteractor
) : ListAdapter<SyncShareOption, AccountDeviceViewHolder>(DiffCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountDeviceViewHolder {
val view = LayoutInflater.from(parent.context)
......@@ -23,25 +27,33 @@ class AccountDevicesShareAdapter(
return AccountDeviceViewHolder(view, interactor)
}
override fun getItemCount(): Int = devices.size
override fun onBindViewHolder(holder: AccountDeviceViewHolder, position: Int) {
holder.bind(devices[position])
holder.bind(getItem(position))
}
fun updateData(deviceOptions: List<SyncShareOption>) {
this.devices.clear()
this.devices.addAll(deviceOptions)
notifyDataSetChanged()
private object DiffCallback : DiffUtil.ItemCallback<SyncShareOption>() {
override fun areItemsTheSame(oldItem: SyncShareOption, newItem: SyncShareOption) =
when (oldItem) {
is SyncShareOption.SendAll -> newItem is SyncShareOption.SendAll
is SyncShareOption.SingleDevice ->
newItem is SyncShareOption.SingleDevice && oldItem.device.id == newItem.device.id
else -> oldItem === newItem
}
@Suppress("DiffUtilEquals")
override fun areContentsTheSame(oldItem: SyncShareOption, newItem: SyncShareOption) =
oldItem == newItem
}
}
/**
* Different options to be displayed by [AccountDevicesShareAdapter].
*/
sealed class SyncShareOption {
object Reconnect : SyncShareOption()
object Offline : 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()
data class SingleDevice(val device: Device) : SyncShareOption()
}
......@@ -12,9 +12,12 @@ import androidx.recyclerview.widget.ListAdapter
import org.mozilla.fenix.share.ShareToAppsInteractor
import org.mozilla.fenix.share.viewholders.AppViewHolder
/**
* Adapter for a list of apps that can be shared to.
*/
class AppShareAdapter(
private val interactor: ShareToAppsInteractor
) : ListAdapter<AppShareOption, AppViewHolder>(DiffCallback) {
) : ListAdapter<AndroidShareOption, AppViewHolder>(DiffCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppViewHolder {
val view = LayoutInflater.from(parent.context)
......@@ -26,20 +29,38 @@ class AppShareAdapter(
override fun onBindViewHolder(holder: AppViewHolder, position: Int) {
holder.bind(getItem(position))
}
}
private object DiffCallback : DiffUtil.ItemCallback<AppShareOption>() {
override fun areItemsTheSame(oldItem: AppShareOption, newItem: AppShareOption) =
oldItem.packageName == newItem.packageName
private object DiffCallback : DiffUtil.ItemCallback<AndroidShareOption>() {
override fun areItemsTheSame(oldItem: AndroidShareOption, newItem: AndroidShareOption) =
when (oldItem) {
AndroidShareOption.Invisible -> oldItem === newItem
is AndroidShareOption.App ->
newItem is AndroidShareOption.App && oldItem.packageName == newItem.packageName
}
override fun areContentsTheSame(oldItem: AppShareOption, newItem: AppShareOption) =
oldItem == newItem
@Suppress("DiffUtilEquals")
override fun areContentsTheSame(oldItem: AndroidShareOption, newItem: AndroidShareOption) =
oldItem == newItem
}
}
data class AppShareOption(
val name: String,
val icon: Drawable,
val packageName: String,
val activityName: String
)
/**
* Represents an app that can be shared to.
*/
sealed class AndroidShareOption {
object Invisible : AndroidShareOption()
/**
* Represents an app that can be shared to.
*
* @property name Name of the app.
* @property icon Icon representing the share target.
* @property packageName Package of the app.
* @property activityName Activity that will be shared to.
*/
data class App(
val name: String,
val icon: Drawable,
val packageName: String,
val activityName: String
) : AndroidShareOption()
}
......@@ -16,8 +16,11 @@ import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.loadIntoView
import org.mozilla.fenix.share.ShareTab
/**
* Adapter for a list of tabs to be shared.
*/
class ShareTabsAdapter :
ListAdapter<ShareTab, ShareTabsAdapter.ShareTabViewHolder>(ShareTabDiffCallback()) {
ListAdapter<ShareTab, ShareTabsAdapter.ShareTabViewHolder>(ShareTabDiffCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ShareTabViewHolder(
LayoutInflater.from(parent.context)
......@@ -27,13 +30,7 @@ class ShareTabsAdapter :
override fun onBindViewHolder(holder: ShareTabViewHolder, position: Int) =
holder.bind(getItem(position))
fun setTabs(tabs: List<ShareTab>) {
submitList(tabs.toMutableList())
}
inner class ShareTabViewHolder(
itemView: View
) : RecyclerView.ViewHolder(itemView) {
class ShareTabViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun bind(item: ShareTab) = with(itemView) {
context.components.core.icons.loadIntoView(itemView.share_tab_favicon, item.url)
......@@ -42,19 +39,11 @@ class ShareTabsAdapter :
}
}
private class ShareTabDiffCallback : DiffUtil.ItemCallback<ShareTab>() {
override fun areItemsTheSame(
oldItem: ShareTab,
newItem: ShareTab
): Boolean {
return oldItem.url == newItem.url
}
private object ShareTabDiffCallback : DiffUtil.ItemCallback<ShareTab>() {
override fun areItemsTheSame(oldItem: ShareTab, newItem: ShareTab) =
oldItem.url == newItem.url
override fun areContentsTheSame(
oldItem: ShareTab,
newItem: ShareTab
): Boolean {