1
0
Fork 0

Fixes #4528 - Prevent share menu from jumping

Plus a bunch of docs and refactoring
master
Tiger Oakes 2019-11-04 18:33:02 -08:00 committed by Emily Kager
parent 6b9a0d027f
commit 333ff8c941
17 changed files with 250 additions and 218 deletions

View File

@ -13,6 +13,9 @@ import kotlinx.android.synthetic.main.fragment_add_new_device.*
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.settings.SupportUtils 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) { class AddNewDeviceFragment : Fragment(R.layout.fragment_add_new_device) {
override fun onResume() { override fun onResume() {

View File

@ -23,6 +23,7 @@ class ShareCloseView(
override val containerView: ViewGroup, override val containerView: ViewGroup,
private val interactor: ShareCloseInteractor private val interactor: ShareCloseInteractor
) : LayoutContainer { ) : LayoutContainer {
val adapter = ShareTabsAdapter() val adapter = ShareTabsAdapter()
init { init {
@ -36,6 +37,6 @@ class ShareCloseView(
} }
fun setTabs(tabs: List<ShareTab>) { fun setTabs(tabs: List<ShareTab>) {
adapter.setTabs(tabs) adapter.submitList(tabs)
} }
} }

View File

@ -26,7 +26,7 @@ import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.getRootView import org.mozilla.fenix.ext.getRootView
import org.mozilla.fenix.ext.metrics import org.mozilla.fenix.ext.metrics
import org.mozilla.fenix.ext.nav import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.share.listadapters.AppShareOption import org.mozilla.fenix.share.listadapters.AndroidShareOption
/** /**
* [ShareFragment] controller. * [ShareFragment] controller.
@ -36,7 +36,7 @@ import org.mozilla.fenix.share.listadapters.AppShareOption
interface ShareController { interface ShareController {
fun handleReauth() fun handleReauth()
fun handleShareClosed() fun handleShareClosed()
fun handleShareToApp(app: AppShareOption) fun handleShareToApp(app: AndroidShareOption.App)
fun handleAddNewDevice() fun handleAddNewDevice()
fun handleShareToDevice(device: Device) fun handleShareToDevice(device: Device)
fun handleShareToAllDevices(devices: List<Device>) fun handleShareToAllDevices(devices: List<Device>)
@ -72,7 +72,7 @@ class DefaultShareController(
dismiss() dismiss()
} }
override fun handleShareToApp(app: AppShareOption) { override fun handleShareToApp(app: AndroidShareOption.App) {
val intent = Intent(ACTION_SEND).apply { val intent = Intent(ACTION_SEND).apply {
putExtra(EXTRA_TEXT, getShareText()) putExtra(EXTRA_TEXT, getShareText())
type = "text/plain" type = "text/plain"
@ -116,9 +116,10 @@ class DefaultShareController(
private fun shareToDevicesWithRetry(shareOperation: () -> Deferred<Boolean>) { private fun shareToDevicesWithRetry(shareOperation: () -> Deferred<Boolean>) {
// Use GlobalScope to allow the continuation of this method even if the share fragment is closed. // Use GlobalScope to allow the continuation of this method even if the share fragment is closed.
GlobalScope.launch(Dispatchers.Main) { GlobalScope.launch(Dispatchers.Main) {
when (shareOperation.invoke().await()) { if (shareOperation.invoke().await()) {
true -> showSuccess() showSuccess()
false -> showFailureWithRetryOption { shareToDevicesWithRetry(shareOperation) } } else {
showFailureWithRetryOption { shareToDevicesWithRetry(shareOperation) }
} }
dismiss() dismiss()
} }

View File

@ -17,17 +17,19 @@ import android.os.Parcelable
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.WorkerThread
import androidx.appcompat.app.AppCompatDialogFragment import androidx.appcompat.app.AppCompatDialogFragment
import androidx.core.content.getSystemService
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import kotlinx.android.parcel.Parcelize import kotlinx.android.parcel.Parcelize
import kotlinx.android.synthetic.main.fragment_share.view.* import kotlinx.android.synthetic.main.fragment_share.view.*
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import mozilla.components.concept.sync.DeviceCapability import mozilla.components.concept.sync.DeviceCapability
import mozilla.components.concept.sync.DeviceType
import mozilla.components.feature.sendtab.SendTabUseCases import mozilla.components.feature.sendtab.SendTabUseCases
import mozilla.components.service.fxa.manager.FxaAccountManager import mozilla.components.service.fxa.manager.FxaAccountManager
import org.mozilla.fenix.R 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.components
import org.mozilla.fenix.ext.getRootView import org.mozilla.fenix.ext.getRootView
import org.mozilla.fenix.ext.requireComponents 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 import org.mozilla.fenix.share.listadapters.SyncShareOption
@Suppress("TooManyFunctions") @Suppress("TooManyFunctions")
@ -44,20 +46,39 @@ class ShareFragment : AppCompatDialogFragment() {
private lateinit var shareCloseView: ShareCloseView private lateinit var shareCloseView: ShareCloseView
private lateinit var shareToAccountDevicesView: ShareToAccountDevicesView private lateinit var shareToAccountDevicesView: ShareToAccountDevicesView
private lateinit var shareToAppsView: ShareToAppsView 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 lateinit var devicesListDeferred: Deferred<List<SyncShareOption>>
private var connectivityManager: ConnectivityManager? = null 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) { override fun onAttach(context: Context) {
super.onAttach(context) super.onAttach(context)
connectivityManager = connectivityManager = context.getSystemService()
context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
val networkRequest = NetworkRequest.Builder().build() val networkRequest = NetworkRequest.Builder().build()
connectivityManager?.registerNetworkCallback(networkRequest, networkCallback) connectivityManager?.registerNetworkCallback(networkRequest, networkCallback)
// Start preparing the data as soon as we have a valid Context // 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 { val shareIntent = Intent(ACTION_SEND).apply {
type = "text/plain" type = "text/plain"
flags = FLAG_ACTIVITY_NEW_TASK flags = FLAG_ACTIVITY_NEW_TASK
@ -66,53 +87,29 @@ class ShareFragment : AppCompatDialogFragment() {
buildAppsList(shareAppsActivities, context) buildAppsList(shareAppsActivities, context)
} }
devicesListDeferred = lifecycleScope.async(Dispatchers.IO) { devicesListDeferred = lifecycleScope.async(IO) {
val fxaAccountManager = context.components.backgroundServices.accountManager val fxaAccountManager = context.components.backgroundServices.accountManager
buildDeviceList(fxaAccountManager) 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() { override fun onDetach() {
connectivityManager?.unregisterNetworkCallback(networkCallback) connectivityManager?.unregisterNetworkCallback(networkCallback)
super.onDetach() super.onDetach()
} }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_TITLE, R.style.ShareDialogStyle)
}
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View? { ): View? {
val view = inflater.inflate(R.layout.fragment_share, container, false) 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." } 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())) 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?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) 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 { lifecycleScope.launch {
val devicesShareOptions = devicesListDeferred.await() val devicesShareOptions = devicesListDeferred.await()
shareToAccountDevicesView.setSharetargets(devicesShareOptions) shareToAccountDevicesView.setShareTargets(devicesShareOptions)
val appsToShareTo = appsListDeferred.await() val appsToShareTo = appsListDeferred.await()
shareToAppsView.setShareTargets(appsToShareTo) shareToAppsView.setShareTargets(appsToShareTo)
} }
} }
@WorkerThread
private fun getIntentActivities(shareIntent: Intent, context: Context): List<ResolveInfo>? { private fun getIntentActivities(shareIntent: Intent, context: Context): List<ResolveInfo>? {
return context.packageManager.queryIntentActivities(shareIntent, 0) 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( private fun buildAppsList(
intentActivities: List<ResolveInfo>?, intentActivities: List<ResolveInfo>?,
context: Context context: Context
): List<AppShareOption> { ): List<AndroidShareOption> {
return intentActivities?.map { resolveInfo -> return intentActivities
AppShareOption( .orEmpty()
resolveInfo.loadLabel(context.packageManager).toString(), .filter { it.activityInfo.packageName != context.packageName }
resolveInfo.loadIcon(context.packageManager), .map { resolveInfo ->
resolveInfo.activityInfo.packageName, AndroidShareOption.App(
resolveInfo.activityInfo.name resolveInfo.loadLabel(context.packageManager).toString(),
) resolveInfo.loadIcon(context.packageManager),
}?.filter { it.packageName != context.packageName }.orEmpty() 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> { private fun buildDeviceList(accountManager: FxaAccountManager): List<SyncShareOption> {
val list = mutableListOf<SyncShareOption>()
val activeNetwork = connectivityManager?.activeNetworkInfo val activeNetwork = connectivityManager?.activeNetworkInfo
if (activeNetwork?.isConnected != true) { val account = accountManager.authenticatedAccount()
list.add(SyncShareOption.Offline)
return list
}
if (accountManager.authenticatedAccount() == null) { return when {
list.add(SyncShareOption.SignIn) // No network
return list 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) }
if (accountManager.accountNeedsReauth()) { val list = mutableListOf<SyncShareOption>()
list.add(SyncShareOption.Reconnect) if (shareableDevices.isEmpty()) {
return list // Show add device button if there are no devices
} list.add(SyncShareOption.AddNewDevice)
accountManager.authenticatedAccount()?.deviceConstellation()?.state()
?.otherDevices?.let { devices ->
val shareableDevices =
devices.filter { it.capabilities.contains(DeviceCapability.SEND_TAB) }
if (shareableDevices.isEmpty()) {
list.add(SyncShareOption.AddNewDevice)
}
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) { shareableDevices.mapTo(list) { SyncShareOption.SingleDevice(it) }
list.add(SyncShareOption.SendAll(shareableDevices))
if (shareableDevices.size > 1) {
// Show send all button if there are multiple devices
list.add(SyncShareOption.SendAll(shareableDevices))
}
list
} }
} }
return list
} }
companion object { companion object {

View File

@ -5,7 +5,7 @@
package org.mozilla.fenix.share package org.mozilla.fenix.share
import mozilla.components.concept.sync.Device 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. * Interactor for the share screen.
@ -37,7 +37,7 @@ class ShareInteractor(
controller.handleShareToAllDevices(devices) controller.handleShareToAllDevices(devices)
} }
override fun onShareToApp(appToShareTo: AppShareOption) { override fun onShareToApp(appToShareTo: AndroidShareOption.App) {
controller.handleShareToApp(appToShareTo) controller.handleShareToApp(appToShareTo)
} }
} }

View File

@ -26,18 +26,19 @@ interface ShareToAccountDevicesInteractor {
class ShareToAccountDevicesView( class ShareToAccountDevicesView(
override val containerView: ViewGroup, override val containerView: ViewGroup,
private val interactor: ShareToAccountDevicesInteractor interactor: ShareToAccountDevicesInteractor
) : LayoutContainer { ) : LayoutContainer {
private val adapter = AccountDevicesShareAdapter(interactor)
init { init {
LayoutInflater.from(containerView.context) LayoutInflater.from(containerView.context)
.inflate(R.layout.share_to_account_devices, containerView, true) .inflate(R.layout.share_to_account_devices, containerView, true)
devicesList.adapter = AccountDevicesShareAdapter(interactor) devicesList.adapter = adapter
} }
fun setSharetargets(targets: List<SyncShareOption>) { fun setShareTargets(targets: List<SyncShareOption>) {
with(devicesList.adapter as AccountDevicesShareAdapter) { adapter.submitList(targets)
updateData(targets)
}
} }
} }

View File

@ -10,14 +10,14 @@ import android.view.ViewGroup
import kotlinx.android.extensions.LayoutContainer import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.share_to_apps.* import kotlinx.android.synthetic.main.share_to_apps.*
import org.mozilla.fenix.R 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.AppShareAdapter
import org.mozilla.fenix.share.listadapters.AppShareOption
/** /**
* Callbacks for possible user interactions on the [ShareCloseView] * Callbacks for possible user interactions on the [ShareCloseView]
*/ */
interface ShareToAppsInteractor { interface ShareToAppsInteractor {
fun onShareToApp(appToShareTo: AppShareOption) fun onShareToApp(appToShareTo: AndroidShareOption.App)
} }
class ShareToAppsView( class ShareToAppsView(
@ -34,7 +34,7 @@ class ShareToAppsView(
appsList.adapter = adapter appsList.adapter = adapter
} }
fun setShareTargets(targets: List<AppShareOption>) { fun setShareTargets(targets: List<AndroidShareOption>) {
progressBar.visibility = View.GONE progressBar.visibility = View.GONE
appsList.visibility = View.VISIBLE appsList.visibility = View.VISIBLE

View File

@ -6,15 +6,19 @@ package org.mozilla.fenix.share.listadapters
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup 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 mozilla.components.concept.sync.Device
import org.mozilla.fenix.share.ShareToAccountDevicesInteractor import org.mozilla.fenix.share.ShareToAccountDevicesInteractor
import org.mozilla.fenix.share.viewholders.AccountDeviceViewHolder 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( class AccountDevicesShareAdapter(
private val interactor: ShareToAccountDevicesInteractor, private val interactor: ShareToAccountDevicesInteractor
private val devices: MutableList<SyncShareOption> = mutableListOf() ) : ListAdapter<SyncShareOption, AccountDeviceViewHolder>(DiffCallback) {
) : RecyclerView.Adapter<AccountDeviceViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountDeviceViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountDeviceViewHolder {
val view = LayoutInflater.from(parent.context) val view = LayoutInflater.from(parent.context)
@ -23,25 +27,33 @@ class AccountDevicesShareAdapter(
return AccountDeviceViewHolder(view, interactor) return AccountDeviceViewHolder(view, interactor)
} }
override fun getItemCount(): Int = devices.size
override fun onBindViewHolder(holder: AccountDeviceViewHolder, position: Int) { override fun onBindViewHolder(holder: AccountDeviceViewHolder, position: Int) {
holder.bind(devices[position]) holder.bind(getItem(position))
} }
fun updateData(deviceOptions: List<SyncShareOption>) { private object DiffCallback : DiffUtil.ItemCallback<SyncShareOption>() {
this.devices.clear() override fun areItemsTheSame(oldItem: SyncShareOption, newItem: SyncShareOption) =
this.devices.addAll(deviceOptions) when (oldItem) {
notifyDataSetChanged() 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 { sealed class SyncShareOption {
object Reconnect : SyncShareOption() object Reconnect : SyncShareOption()
object Offline : SyncShareOption() object Offline : SyncShareOption()
object SignIn : SyncShareOption() object SignIn : SyncShareOption()
object AddNewDevice : SyncShareOption() object AddNewDevice : SyncShareOption()
data class SendAll(val devices: List<Device>) : SyncShareOption() data class SendAll(val devices: List<Device>) : SyncShareOption()
data class Mobile(val name: String, val device: Device) : SyncShareOption() data class SingleDevice(val device: Device) : SyncShareOption()
data class Desktop(val name: String, val device: Device) : SyncShareOption()
} }

View File

@ -12,9 +12,12 @@ import androidx.recyclerview.widget.ListAdapter
import org.mozilla.fenix.share.ShareToAppsInteractor import org.mozilla.fenix.share.ShareToAppsInteractor
import org.mozilla.fenix.share.viewholders.AppViewHolder import org.mozilla.fenix.share.viewholders.AppViewHolder
/**
* Adapter for a list of apps that can be shared to.
*/
class AppShareAdapter( class AppShareAdapter(
private val interactor: ShareToAppsInteractor private val interactor: ShareToAppsInteractor
) : ListAdapter<AppShareOption, AppViewHolder>(DiffCallback) { ) : ListAdapter<AndroidShareOption, AppViewHolder>(DiffCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppViewHolder {
val view = LayoutInflater.from(parent.context) val view = LayoutInflater.from(parent.context)
@ -26,20 +29,38 @@ class AppShareAdapter(
override fun onBindViewHolder(holder: AppViewHolder, position: Int) { override fun onBindViewHolder(holder: AppViewHolder, position: Int) {
holder.bind(getItem(position)) holder.bind(getItem(position))
} }
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
}
@Suppress("DiffUtilEquals")
override fun areContentsTheSame(oldItem: AndroidShareOption, newItem: AndroidShareOption) =
oldItem == newItem
}
} }
private object DiffCallback : DiffUtil.ItemCallback<AppShareOption>() { /**
* Represents an app that can be shared to.
override fun areItemsTheSame(oldItem: AppShareOption, newItem: AppShareOption) = */
oldItem.packageName == newItem.packageName sealed class AndroidShareOption {
object Invisible : AndroidShareOption()
override fun areContentsTheSame(oldItem: AppShareOption, newItem: AppShareOption) = /**
oldItem == newItem * 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()
} }
data class AppShareOption(
val name: String,
val icon: Drawable,
val packageName: String,
val activityName: String
)

View File

@ -16,8 +16,11 @@ import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.loadIntoView import org.mozilla.fenix.ext.loadIntoView
import org.mozilla.fenix.share.ShareTab import org.mozilla.fenix.share.ShareTab
/**
* Adapter for a list of tabs to be shared.
*/
class ShareTabsAdapter : class ShareTabsAdapter :
ListAdapter<ShareTab, ShareTabsAdapter.ShareTabViewHolder>(ShareTabDiffCallback()) { ListAdapter<ShareTab, ShareTabsAdapter.ShareTabViewHolder>(ShareTabDiffCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ShareTabViewHolder( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ShareTabViewHolder(
LayoutInflater.from(parent.context) LayoutInflater.from(parent.context)
@ -27,13 +30,7 @@ class ShareTabsAdapter :
override fun onBindViewHolder(holder: ShareTabViewHolder, position: Int) = override fun onBindViewHolder(holder: ShareTabViewHolder, position: Int) =
holder.bind(getItem(position)) holder.bind(getItem(position))
fun setTabs(tabs: List<ShareTab>) { class ShareTabViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
submitList(tabs.toMutableList())
}
inner class ShareTabViewHolder(
itemView: View
) : RecyclerView.ViewHolder(itemView) {
fun bind(item: ShareTab) = with(itemView) { fun bind(item: ShareTab) = with(itemView) {
context.components.core.icons.loadIntoView(itemView.share_tab_favicon, item.url) context.components.core.icons.loadIntoView(itemView.share_tab_favicon, item.url)
@ -42,19 +39,11 @@ class ShareTabsAdapter :
} }
} }
private class ShareTabDiffCallback : DiffUtil.ItemCallback<ShareTab>() { private object ShareTabDiffCallback : DiffUtil.ItemCallback<ShareTab>() {
override fun areItemsTheSame( override fun areItemsTheSame(oldItem: ShareTab, newItem: ShareTab) =
oldItem: ShareTab, oldItem.url == newItem.url
newItem: ShareTab
): Boolean {
return oldItem.url == newItem.url
}
override fun areContentsTheSame( override fun areContentsTheSame(oldItem: ShareTab, newItem: ShareTab) =
oldItem: ShareTab, oldItem == newItem
newItem: ShareTab
): Boolean {
return oldItem.url == newItem.url && oldItem.title == newItem.title
}
} }
} }

View File

@ -11,6 +11,7 @@ import androidx.annotation.VisibleForTesting
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.account_share_list_item.view.* import kotlinx.android.synthetic.main.account_share_list_item.view.*
import mozilla.components.concept.sync.DeviceType
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.lib.Do import org.mozilla.fenix.lib.Do
import org.mozilla.fenix.share.ShareToAccountDevicesInteractor import org.mozilla.fenix.share.ShareToAccountDevicesInteractor
@ -35,8 +36,7 @@ class AccountDeviceViewHolder(
SyncShareOption.SignIn -> interactor.onSignIn() SyncShareOption.SignIn -> interactor.onSignIn()
SyncShareOption.AddNewDevice -> interactor.onAddNewDevice() SyncShareOption.AddNewDevice -> interactor.onAddNewDevice()
is SyncShareOption.SendAll -> interactor.onShareToAllDevices(option.devices) is SyncShareOption.SendAll -> interactor.onShareToAllDevices(option.devices)
is SyncShareOption.Mobile -> interactor.onShareToDevice(option.device) is SyncShareOption.SingleDevice -> interactor.onShareToDevice(option.device)
is SyncShareOption.Desktop -> interactor.onShareToDevice(option.device)
SyncShareOption.Reconnect -> interactor.onReauth() SyncShareOption.Reconnect -> interactor.onReauth()
SyncShareOption.Offline -> { SyncShareOption.Offline -> {
// nothing we are offline // nothing we are offline
@ -46,7 +46,25 @@ class AccountDeviceViewHolder(
} }
private fun bindView(option: SyncShareOption) { private fun bindView(option: SyncShareOption) {
val (name, drawableRes, colorRes) = when (option) { val (name, drawableRes, colorRes) = getNameIconBackground(context, option)
itemView.deviceIcon.apply {
setImageResource(drawableRes)
background.setColorFilter(ContextCompat.getColor(context, colorRes), PorterDuff.Mode.SRC_IN)
drawable.setTint(ContextCompat.getColor(context, R.color.device_foreground))
}
itemView.isClickable = option != SyncShareOption.Offline
itemView.deviceName.text = name
}
companion object {
const val LAYOUT_ID = R.layout.account_share_list_item
/**
* Returns a triple with the name, icon drawable resource, and background color drawable resource
* corresponding to the given [SyncShareOption].
*/
private fun getNameIconBackground(context: Context, option: SyncShareOption) = when (option) {
SyncShareOption.SignIn -> Triple( SyncShareOption.SignIn -> Triple(
context.getText(R.string.sync_sign_in), context.getText(R.string.sync_sign_in),
R.drawable.mozac_ic_sync, R.drawable.mozac_ic_sync,
@ -72,28 +90,18 @@ class AccountDeviceViewHolder(
R.drawable.mozac_ic_select_all, R.drawable.mozac_ic_select_all,
R.color.default_share_background R.color.default_share_background
) )
is SyncShareOption.Mobile -> Triple( is SyncShareOption.SingleDevice -> when (option.device.deviceType) {
option.name, DeviceType.MOBILE -> Triple(
R.drawable.mozac_ic_device_mobile, option.device.displayName,
R.color.device_type_mobile_background R.drawable.mozac_ic_device_mobile,
) R.color.device_type_mobile_background
is SyncShareOption.Desktop -> Triple( )
option.name, else -> Triple(
R.drawable.mozac_ic_device_desktop, option.device.displayName,
R.color.device_type_desktop_background R.drawable.mozac_ic_device_desktop,
) R.color.device_type_desktop_background
)
}
} }
itemView.deviceIcon.apply {
setImageResource(drawableRes)
background.setColorFilter(ContextCompat.getColor(context, colorRes), PorterDuff.Mode.SRC_IN)
drawable.setTint(ContextCompat.getColor(context, R.color.device_foreground))
}
itemView.isClickable = option != SyncShareOption.Offline
itemView.deviceName.text = name
}
companion object {
const val LAYOUT_ID = R.layout.account_share_list_item
} }
} }

View File

@ -6,32 +6,43 @@ package org.mozilla.fenix.share.viewholders
import android.view.View import android.view.View
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.core.view.isInvisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.app_share_list_item.view.* import kotlinx.android.synthetic.main.app_share_list_item.view.*
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.lib.Do
import org.mozilla.fenix.share.ShareToAppsInteractor import org.mozilla.fenix.share.ShareToAppsInteractor
import org.mozilla.fenix.share.listadapters.AppShareOption import org.mozilla.fenix.share.listadapters.AndroidShareOption
class AppViewHolder( class AppViewHolder(
itemView: View, itemView: View,
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @VisibleForTesting val interactor: ShareToAppsInteractor
val interactor: ShareToAppsInteractor
) : RecyclerView.ViewHolder(itemView) { ) : RecyclerView.ViewHolder(itemView) {
private var application: AppShareOption? = null private var application: AndroidShareOption? = null
init { init {
itemView.setOnClickListener { itemView.setOnClickListener {
application?.let { application -> Do exhaustive when (val app = application) {
interactor.onShareToApp(application) AndroidShareOption.Invisible, null -> { /* no-op */ }
is AndroidShareOption.App -> interactor.onShareToApp(app)
} }
} }
} }
fun bind(item: AppShareOption) { fun bind(item: AndroidShareOption) {
application = item application = item
itemView.appName.text = item.name
itemView.appIcon.setImageDrawable(item.icon) when (item) {
AndroidShareOption.Invisible -> {
itemView.isInvisible = true
}
is AndroidShareOption.App -> {
itemView.isInvisible = false
itemView.appName.text = item.name
itemView.appIcon.setImageDrawable(item.icon)
}
}
} }
companion object { companion object {

View File

@ -65,6 +65,7 @@
android:orientation="vertical"/> android:orientation="vertical"/>
</LinearLayout> </LinearLayout>
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/learn_button" android:id="@+id/learn_button"
style="@style/ThemeIndependentMaterialGreyButton" style="@style/ThemeIndependentMaterialGreyButton"

View File

@ -40,7 +40,7 @@ import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.ext.metrics import org.mozilla.fenix.ext.metrics
import org.mozilla.fenix.ext.nav import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.share.listadapters.AppShareOption import org.mozilla.fenix.share.listadapters.AndroidShareOption
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@ -84,7 +84,7 @@ class ShareControllerTest {
fun `handleShareToApp should start a new sharing activity and close this`() { fun `handleShareToApp should start a new sharing activity and close this`() {
val appPackageName = "package" val appPackageName = "package"
val appClassName = "activity" val appClassName = "activity"
val appShareOption = AppShareOption("app", mockk(), appPackageName, appClassName) val appShareOption = AndroidShareOption.App("app", mockk(), appPackageName, appClassName)
val shareIntent = slot<Intent>() val shareIntent = slot<Intent>()
// Our share Intent uses `FLAG_ACTIVITY_NEW_TASK` but when resolving the startActivity call // 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 // needed for capturing the actual Intent used the `slot` one doesn't have this flag so we

View File

@ -8,7 +8,7 @@ import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import mozilla.components.concept.sync.Device import mozilla.components.concept.sync.Device
import org.junit.Test import org.junit.Test
import org.mozilla.fenix.share.listadapters.AppShareOption import org.mozilla.fenix.share.listadapters.AndroidShareOption
class ShareInteractorTest { class ShareInteractorTest {
private val controller = mockk<ShareController>(relaxed = true) private val controller = mockk<ShareController>(relaxed = true)
@ -62,7 +62,7 @@ class ShareInteractorTest {
@Test @Test
fun onShareToApp() { fun onShareToApp() {
val app = mockk<AppShareOption>() val app = mockk<AndroidShareOption.App>()
interactor.onShareToApp(app) interactor.onShareToApp(app)

View File

@ -13,7 +13,6 @@ import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.spyk import io.mockk.spyk
import io.mockk.verify import io.mockk.verify
import io.mockk.verifyOrder
import mozilla.components.support.test.robolectric.testContext import mozilla.components.support.test.robolectric.testContext
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -26,26 +25,8 @@ import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@Config(application = TestApplication::class) @Config(application = TestApplication::class)
class AccountDevicesShareAdapterTest { class AccountDevicesShareAdapterTest {
private val syncOptions = mutableListOf(SyncShareOption.AddNewDevice, SyncShareOption.SignIn)
private val syncOptionsEmpty = mutableListOf<SyncShareOption>()
private val interactor: ShareInteractor = mockk(relaxed = true) private val interactor: ShareInteractor = mockk(relaxed = true)
@Test
fun `updateData should replace all previous data with argument and call notifyDataSetChanged()`() {
// Used AccountDevicesShareAdapter as a spy to ease testing of notifyDataSetChanged()
// and syncOptionsEmpty to be able to record them being called
val adapter = spyk(AccountDevicesShareAdapter(mockk(), syncOptionsEmpty))
every { adapter.notifyDataSetChanged() } just Runs
adapter.updateData(syncOptions)
verifyOrder {
syncOptionsEmpty.clear()
syncOptionsEmpty.addAll(syncOptions)
adapter.notifyDataSetChanged()
}
}
@Test @Test
fun `getItemCount on a default instantiated Adapter should return 0`() { fun `getItemCount on a default instantiated Adapter should return 0`() {
val adapter = AccountDevicesShareAdapter(mockk()) val adapter = AccountDevicesShareAdapter(mockk())
@ -53,13 +34,6 @@ class AccountDevicesShareAdapterTest {
assertThat(adapter.itemCount).isEqualTo(0) assertThat(adapter.itemCount).isEqualTo(0)
} }
@Test
fun `getItemCount after updateData() call should return the the passed in list's size`() {
val adapter = AccountDevicesShareAdapter(mockk(), syncOptions)
assertThat(adapter.itemCount).isEqualTo(2)
}
@Test @Test
fun `the adapter uses the right ViewHolder`() { fun `the adapter uses the right ViewHolder`() {
val adapter = AccountDevicesShareAdapter(interactor) val adapter = AccountDevicesShareAdapter(interactor)
@ -84,7 +58,9 @@ class AccountDevicesShareAdapterTest {
@Test @Test
fun `the adapter binds the right item to a ViewHolder`() { fun `the adapter binds the right item to a ViewHolder`() {
val adapter = AccountDevicesShareAdapter(interactor, syncOptions) val syncOptions = listOf(SyncShareOption.AddNewDevice, SyncShareOption.SignIn)
val adapter = AccountDevicesShareAdapter(interactor)
adapter.submitList(syncOptions)
val parentView: ViewGroup = mockk(relaxed = true) val parentView: ViewGroup = mockk(relaxed = true)
val itemView: ViewGroup = mockk(relaxed = true) val itemView: ViewGroup = mockk(relaxed = true)
every { parentView.context } returns testContext every { parentView.context } returns testContext

View File

@ -14,6 +14,7 @@ import io.mockk.mockk
import io.mockk.spyk import io.mockk.spyk
import io.mockk.verify import io.mockk.verify
import io.mockk.verifyOrder import io.mockk.verifyOrder
import kotlinx.android.synthetic.main.app_share_list_item.view.*
import mozilla.components.support.test.robolectric.testContext import mozilla.components.support.test.robolectric.testContext
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -27,11 +28,11 @@ import org.robolectric.annotation.Config
@Config(application = TestApplication::class) @Config(application = TestApplication::class)
class AppShareAdapterTest { class AppShareAdapterTest {
private val appOptions = mutableListOf( private val appOptions = mutableListOf<AndroidShareOption>(
AppShareOption("App 0", mockk(), "package 0", "activity 0"), AndroidShareOption.App("App 0", mockk(), "package 0", "activity 0"),
AppShareOption("App 1", mockk(), "package 1", "activity 1") AndroidShareOption.App("App 1", mockk(), "package 1", "activity 1")
) )
private val appOptionsEmpty = emptyList<AppShareOption>() private val appOptionsEmpty = emptyList<AndroidShareOption>()
private val interactor: ShareInteractor = mockk(relaxed = true) private val interactor: ShareInteractor = mockk(relaxed = true)
@Test @Test