diff --git a/app/build.gradle b/app/build.gradle index 929247027..53f96cf77 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -396,6 +396,9 @@ dependencies { implementation Deps.mozilla_browser_storage_sync implementation Deps.mozilla_browser_toolbar + implementation Deps.mozilla_support_extensions + implementation Deps.mozilla_feature_addons + implementation Deps.mozilla_feature_accounts implementation Deps.mozilla_feature_app_links implementation Deps.mozilla_feature_awesomebar diff --git a/app/metrics.yaml b/app/metrics.yaml index 00b93cc00..16b6f8637 100644 --- a/app/metrics.yaml +++ b/app/metrics.yaml @@ -82,7 +82,7 @@ events: A string containing the name of the item the user tapped. These items include: Settings, Library, Help, Desktop Site toggle on/off, Find in Page, New Tab, Private Tab, Share, Report Site Issue, Back/Forward button, Reload Button, Quit, - Reader Mode On, Reader Mode Off, Open In App, Add to Firefox Home + Reader Mode On, Reader Mode Off, Open In App, Add to Firefox Home, Add-ons Manager bugs: - https://github.com/mozilla-mobile/fenix/issues/1024 data_reviews: diff --git a/app/src/main/java/org/mozilla/fenix/FenixApplication.kt b/app/src/main/java/org/mozilla/fenix/FenixApplication.kt index 3cda7d960..c181a8a34 100644 --- a/app/src/main/java/org/mozilla/fenix/FenixApplication.kt +++ b/app/src/main/java/org/mozilla/fenix/FenixApplication.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import mozilla.appservices.Megazord +import mozilla.components.browser.session.Session import mozilla.components.concept.push.PushProcessor import mozilla.components.service.experiments.Experiments import mozilla.components.service.glean.Glean @@ -31,6 +32,7 @@ import mozilla.components.support.ktx.android.content.runOnlyInMainProcess import mozilla.components.support.locale.LocaleAwareApplication import mozilla.components.support.rusthttp.RustHttpConfig import mozilla.components.support.rustlog.RustLog +import mozilla.components.support.webextensions.WebExtensionSupport import org.mozilla.fenix.components.Components import org.mozilla.fenix.ext.settings import org.mozilla.fenix.session.NotificationSessionObserver @@ -116,6 +118,8 @@ open class FenixApplication : LocaleAwareApplication() { // Make sure the engine is initialized and ready to use. components.core.engine.warmUp() + initializeWebExtensionSupport() + // Just to make sure it is impossible for any application-services pieces // to invoke parts of itself that require complete megazord initialization // before that process completes, we wait here, if necessary. @@ -277,4 +281,29 @@ open class FenixApplication : LocaleAwareApplication() { StrictMode.setVmPolicy(builder.build()) } } + + private fun initializeWebExtensionSupport() { + try { + WebExtensionSupport.initialize( + components.core.engine, + components.core.store, + onNewTabOverride = { + _, engineSession, url -> + val session = Session(url) + components.core.sessionManager.add(session, true, engineSession) + session.id + }, + onCloseTabOverride = { + _, sessionId -> components.tabsUseCases.removeTab(sessionId) + }, + onSelectTabOverride = { + _, sessionId -> + val selected = components.core.sessionManager.findSessionById(sessionId) + selected?.let { components.tabsUseCases.selectTab(it) } + } + ) + } catch (e: UnsupportedOperationException) { + Logger.error("Failed to initialize web extension support", e) + } + } } diff --git a/app/src/main/java/org/mozilla/fenix/HomeActivity.kt b/app/src/main/java/org/mozilla/fenix/HomeActivity.kt index 4df8affcd..1db13313d 100644 --- a/app/src/main/java/org/mozilla/fenix/HomeActivity.kt +++ b/app/src/main/java/org/mozilla/fenix/HomeActivity.kt @@ -23,10 +23,12 @@ import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.AppBarConfiguration import androidx.navigation.ui.NavigationUI import kotlinx.android.synthetic.main.activity_home.navigationToolbarStub +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import mozilla.components.browser.search.SearchEngine import mozilla.components.browser.session.Session import mozilla.components.browser.session.SessionManager +import mozilla.components.browser.state.state.WebExtensionState import mozilla.components.concept.engine.EngineView import mozilla.components.service.fxa.sync.SyncReason import mozilla.components.support.base.feature.UserInteractionHandler @@ -35,6 +37,7 @@ import mozilla.components.support.ktx.kotlin.toNormalizedUrl import mozilla.components.support.locale.LocaleAwareAppCompatActivity import mozilla.components.support.utils.SafeIntent import mozilla.components.support.utils.toSafeIntent +import mozilla.components.support.webextensions.WebExtensionPopupFeature import org.mozilla.fenix.browser.UriOpenedObserver import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager @@ -70,6 +73,7 @@ import org.mozilla.fenix.utils.BrowsersCache @SuppressWarnings("TooManyFunctions", "LargeClass") open class HomeActivity : LocaleAwareAppCompatActivity() { + private var webExtScope: CoroutineScope? = null lateinit var themeManager: ThemeManager lateinit var browsingModeManager: BrowsingModeManager @@ -79,6 +83,10 @@ open class HomeActivity : LocaleAwareAppCompatActivity() { private var isToolbarInflated = false + private val webExtensionPopupFeature by lazy { + WebExtensionPopupFeature(components.core.store, ::openPopup) + } + private val navHost by lazy { supportFragmentManager.findFragmentById(R.id.container) as NavHostFragment } @@ -126,6 +134,8 @@ open class HomeActivity : LocaleAwareAppCompatActivity() { ?.also { components.analytics.metrics.track(Event.OpenedApp(it)) } } supportActionBar?.hide() + + lifecycle.addObserver(webExtensionPopupFeature) } @CallSuper @@ -377,6 +387,14 @@ open class HomeActivity : LocaleAwareAppCompatActivity() { return DefaultThemeManager(browsingModeManager.mode, this) } + private fun openPopup(webExtensionState: WebExtensionState) { + val action = NavGraphDirections.actionGlobalWebExtensionActionPopupFragment( + webExtensionId = webExtensionState.id, + webExtensionTitle = webExtensionState.name + ) + navHost.navController.navigate(action) + } + companion object { const val OPEN_TO_BROWSER = "open_to_browser" const val OPEN_TO_BROWSER_AND_LOAD = "open_to_browser_and_load" diff --git a/app/src/main/java/org/mozilla/fenix/addons/AddonDetailsFragment.kt b/app/src/main/java/org/mozilla/fenix/addons/AddonDetailsFragment.kt new file mode 100644 index 000000000..57ff50878 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/addons/AddonDetailsFragment.kt @@ -0,0 +1,109 @@ +/* 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.addons + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.text.method.LinkMovementMethod +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.text.HtmlCompat +import androidx.fragment.app.Fragment +import kotlinx.android.synthetic.main.fragment_add_on_details.view.* +import mozilla.components.feature.addons.Addon +import mozilla.components.feature.addons.ui.translate +import org.mozilla.fenix.R +import org.mozilla.fenix.ext.showToolbar +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Locale + +/** + * A fragment to show the details of an add-on. + */ +class AddonDetailsFragment : Fragment() { + private val addon: Addon by lazy { + AddonDetailsFragmentArgs.fromBundle(requireNotNull(arguments)).addon + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return inflater.inflate(R.layout.fragment_add_on_details, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + bind(addon, view) + } + + private fun bind(addon: Addon, view: View) { + val title = addon.translatableName.translate() + showToolbar(title) + + bindDetails(addon, view) + bindAuthors(addon, view) + bindVersion(addon, view) + bindLastUpdated(addon, view) + bindWebsite(addon, view) + bindRating(addon, view) + } + + private fun bindRating(addon: Addon, view: View) { + addon.rating?.let { + val ratingView = view.rating_view + val userCountView = view.users_count + + val ratingContentDescription = + getString(R.string.mozac_feature_addons_rating_content_description) + ratingView.contentDescription = String.format(ratingContentDescription, it.average) + ratingView.rating = it.average + + userCountView.text = getFormattedAmount(it.reviews) + } + } + + private fun bindWebsite(addon: Addon, view: View) { + view.home_page_text.setOnClickListener { + val intent = + Intent(Intent.ACTION_VIEW).setData(Uri.parse(addon.siteUrl)) + startActivity(intent) + } + } + + private fun bindLastUpdated(addon: Addon, view: View) { + view.last_updated_text.text = formatDate(addon.updatedAt) + } + + private fun bindVersion(addon: Addon, view: View) { + view.version_text.text = addon.version + } + + private fun bindAuthors(addon: Addon, view: View) { + view.author_text.text = addon.authors.joinToString { author -> + author.name + " \n" + } + } + + private fun bindDetails(addon: Addon, view: View) { + val detailsView = view.details + val detailsText = addon.translatableDescription.translate() + + val parsedText = detailsText.replace("\n", "
") + val text = HtmlCompat.fromHtml(parsedText, HtmlCompat.FROM_HTML_MODE_COMPACT) + + detailsView.text = text + detailsView.movementMethod = LinkMovementMethod.getInstance() + } + + private fun formatDate(text: String): String { + val formatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.getDefault()) + return DateFormat.getDateInstance().format(formatter.parse(text)!!) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/addons/AddonInternalSettingsFragment.kt b/app/src/main/java/org/mozilla/fenix/addons/AddonInternalSettingsFragment.kt new file mode 100644 index 000000000..48a6feb23 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/addons/AddonInternalSettingsFragment.kt @@ -0,0 +1,55 @@ +/* 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.addons + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import kotlinx.android.synthetic.main.fragment_add_on_internal_settings.* +import mozilla.components.concept.engine.EngineSession +import mozilla.components.feature.addons.Addon +import mozilla.components.feature.addons.ui.translate +import org.mozilla.fenix.R +import org.mozilla.fenix.ext.requireComponents +import org.mozilla.fenix.ext.showToolbar + +/** + * A fragment to show the internal settings of an add-on. + */ +class AddonInternalSettingsFragment : Fragment() { + private val addon: Addon by lazy { + AddonDetailsFragmentArgs.fromBundle(requireNotNull(arguments)).addon + } + private lateinit var engineSession: EngineSession + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + engineSession = requireComponents.core.engine.createSession() + + return inflater.inflate(R.layout.fragment_add_on_internal_settings, container, false) + } + + override fun onResume() { + super.onResume() + showToolbar(addon.translatableName.translate()) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + addonSettingsEngineView.render(engineSession) + engineSession.loadUrl(addon.installedState!!.optionsPageUrl) + } + + override fun onDestroyView() { + engineSession.close() + super.onDestroyView() + } +} diff --git a/app/src/main/java/org/mozilla/fenix/addons/AddonPermissionsDetailsFragment.kt b/app/src/main/java/org/mozilla/fenix/addons/AddonPermissionsDetailsFragment.kt new file mode 100644 index 000000000..191a7ed49 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/addons/AddonPermissionsDetailsFragment.kt @@ -0,0 +1,70 @@ +/* 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.addons + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.StringRes +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import kotlinx.android.synthetic.main.fragment_add_on_permissions.view.* +import mozilla.components.feature.addons.Addon +import mozilla.components.feature.addons.ui.AddonPermissionsAdapter +import mozilla.components.feature.addons.ui.translate +import org.mozilla.fenix.R +import org.mozilla.fenix.ext.showToolbar + +private const val LEARN_MORE_URL = + "https://support.mozilla.org/kb/permission-request-messages-firefox-extensions" + +/** + * A fragment to show the permissions of an add-on. + */ +class AddonPermissionsDetailsFragment : Fragment(), View.OnClickListener { + private val addon: Addon by lazy { + AddonDetailsFragmentArgs.fromBundle(requireNotNull(arguments)).addon + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return inflater.inflate(R.layout.fragment_add_on_permissions, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + showToolbar(addon.translatableName.translate()) + + bindPermissions(addon, view) + bindLearnMore(view) + } + + private fun bindPermissions(addon: Addon, view: View) { + view.add_ons_permissions.apply { + layoutManager = LinearLayoutManager(requireContext()) + val sortedPermissions = addon.translatePermissions().map { + @StringRes val stringId = it + getString(stringId) + }.sorted() + adapter = AddonPermissionsAdapter(sortedPermissions) + } + } + + private fun bindLearnMore(view: View) { + view.learn_more_label.setOnClickListener(this) + } + + override fun onClick(v: View?) { + val intent = + Intent(Intent.ACTION_VIEW).setData(Uri.parse(LEARN_MORE_URL)) + startActivity(intent) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/addons/AddonsManagementFragment.kt b/app/src/main/java/org/mozilla/fenix/addons/AddonsManagementFragment.kt new file mode 100644 index 000000000..ead13842d --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/addons/AddonsManagementFragment.kt @@ -0,0 +1,172 @@ +/* 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.addons + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.navigation.Navigation +import androidx.recyclerview.widget.LinearLayoutManager +import kotlinx.android.synthetic.main.fragment_add_ons_management.* +import kotlinx.android.synthetic.main.fragment_add_ons_management.view.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import mozilla.components.feature.addons.Addon +import mozilla.components.feature.addons.AddonManagerException +import mozilla.components.feature.addons.ui.AddonsManagerAdapter +import mozilla.components.feature.addons.ui.AddonsManagerAdapterDelegate +import mozilla.components.feature.addons.ui.PermissionsDialogFragment +import mozilla.components.feature.addons.ui.translatedName +import org.mozilla.fenix.R +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.showToolbar + +/** + * Fragment use for managing add-ons. + */ +@Suppress("TooManyFunctions") +class AddonsManagementFragment : Fragment(), AddonsManagerAdapterDelegate { + private val scope = CoroutineScope(Dispatchers.IO) + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return inflater.inflate(R.layout.fragment_add_ons_management, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + bindRecyclerView(view) + } + + override fun onResume() { + super.onResume() + showToolbar(getString(R.string.preferences_addons)) + } + + override fun onStart() { + super.onStart() + findPreviousDialogFragment()?.let { dialog -> + dialog.onPositiveButtonClicked = onPositiveButtonClicked + } + } + + override fun onAddonItemClicked(addon: Addon) { + if (addon.isInstalled()) { + showInstalledAddonDetailsFragment(addon) + } else { + showDetailsFragment(addon) + } + } + + override fun onInstallAddonButtonClicked(addon: Addon) { + showPermissionDialog(addon) + } + + override fun onNotYetSupportedSectionClicked(unsupportedAddons: ArrayList) { + showNotYetSupportedAddonFragment(unsupportedAddons) + } + + private fun bindRecyclerView(view: View) { + val recyclerView = view.add_ons_list + recyclerView.layoutManager = LinearLayoutManager(requireContext()) + scope.launch { + try { + val addons = requireContext().components.addonManager.getAddons() + + scope.launch(Dispatchers.Main) { + val adapter = AddonsManagerAdapter( + requireContext().components.addonCollectionProvider, + this@AddonsManagementFragment, + addons + ) + recyclerView.adapter = adapter + } + } catch (e: AddonManagerException) { + scope.launch(Dispatchers.Main) { + showSnackBar(view, getString(R.string.mozac_feature_addons_failed_to_query_add_ons)) + } + } + } + } + + private fun showInstalledAddonDetailsFragment(addon: Addon) { + val directions = + AddonsManagementFragmentDirections.actionAddonsManagementFragmentToInstalledAddonDetails( + addon + ) + Navigation.findNavController(requireView()).navigate(directions) + } + + private fun showDetailsFragment(addon: Addon) { + val directions = + AddonsManagementFragmentDirections.actionAddonsManagementFragmentToAddonDetailsFragment( + addon + ) + Navigation.findNavController(requireView()).navigate(directions) + } + + private fun showNotYetSupportedAddonFragment(unsupportedAddons: ArrayList) { + val directions = + AddonsManagementFragmentDirections.actionAddonsManagementFragmentToNotYetSupportedAddonFragment( + unsupportedAddons.toTypedArray() + ) + Navigation.findNavController(requireView()).navigate(directions) + } + + private fun findPreviousDialogFragment(): PermissionsDialogFragment? { + return parentFragmentManager.findFragmentByTag(PERMISSIONS_DIALOG_FRAGMENT_TAG) as? PermissionsDialogFragment + } + + private fun hasExistingPermissionDialogFragment(): Boolean { + return findPreviousDialogFragment() != null + } + + private fun showPermissionDialog(addon: Addon) { + if (!hasExistingPermissionDialogFragment()) { + val dialog = PermissionsDialogFragment.newInstance( + addon = addon, + onPositiveButtonClicked = onPositiveButtonClicked + ) + dialog.show(parentFragmentManager, PERMISSIONS_DIALOG_FRAGMENT_TAG) + } + } + + private val onPositiveButtonClicked: ((Addon) -> Unit) = { addon -> + addonProgressOverlay.visibility = View.VISIBLE + requireContext().components.addonManager.installAddon( + addon, + onSuccess = { + this@AddonsManagementFragment.view?.let { view -> + showSnackBar( + view, + getString( + R.string.mozac_feature_addons_successfully_installed, + it.translatedName + ) + ) + bindRecyclerView(view) + } + + addonProgressOverlay?.visibility = View.GONE + }, + onError = { _, _ -> + this@AddonsManagementFragment.view?.let { view -> + showSnackBar(view, getString(R.string.mozac_feature_addons_failed_to_install, addon.translatedName)) + } + addonProgressOverlay?.visibility = View.GONE + } + ) + } + + companion object { + private const val PERMISSIONS_DIALOG_FRAGMENT_TAG = "ADDONS_PERMISSIONS_DIALOG_FRAGMENT" + } +} diff --git a/app/src/main/java/org/mozilla/fenix/addons/Extensions.kt b/app/src/main/java/org/mozilla/fenix/addons/Extensions.kt new file mode 100644 index 000000000..306a6d29e --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/addons/Extensions.kt @@ -0,0 +1,31 @@ +/* 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.addons + +import android.view.View +import org.mozilla.fenix.components.FenixSnackbar +import java.text.NumberFormat +import java.util.Locale + +/** + * Get the formatted number amount for the current default locale. + * + * @param amount The number of addons to be formatted for the current default locale.. + */ +internal fun getFormattedAmount(amount: Int): String { + return NumberFormat.getNumberInstance(Locale.getDefault()).format(amount) +} + +/** + * Shows the Fenix Snackbar in the given view along with the provided text. + * + * @param view A [View] used to determine a parent for the [FenixSnackbar]. + * @param text The text to display in the [FenixSnackbar]. + */ +internal fun showSnackBar(view: View, text: String) { + FenixSnackbar.make(view, FenixSnackbar.LENGTH_SHORT) + .setText(text) + .show() +} diff --git a/app/src/main/java/org/mozilla/fenix/addons/InstalledAddonDetailsFragment.kt b/app/src/main/java/org/mozilla/fenix/addons/InstalledAddonDetailsFragment.kt new file mode 100644 index 000000000..e8dde6b9f --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/addons/InstalledAddonDetailsFragment.kt @@ -0,0 +1,167 @@ +/* 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.addons + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Switch +import androidx.fragment.app.Fragment +import androidx.navigation.Navigation +import androidx.navigation.findNavController +import kotlinx.android.synthetic.main.fragment_installed_add_on_details.view.* +import mozilla.components.feature.addons.Addon +import mozilla.components.feature.addons.ui.translate +import org.mozilla.fenix.R +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.showToolbar +import mozilla.components.feature.addons.ui.translatedName + +/** + * An activity to show the details of a installed add-on. + */ +class InstalledAddonDetailsFragment : Fragment() { + private lateinit var addon: Addon + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + if (!::addon.isInitialized) { + addon = AddonDetailsFragmentArgs.fromBundle(requireNotNull(arguments)).addon + } + + return inflater.inflate(R.layout.fragment_installed_add_on_details, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + bind(view) + } + + private fun bind(view: View) { + val title = addon.translatableName.translate() + showToolbar(title) + + bindEnableSwitch(view) + bindSettings(view) + bindDetails(view) + bindPermissions(view) + bindRemoveButton(view) + } + + private fun bindEnableSwitch(view: View) { + val switch = view.enable_switch + switch.setState(addon.isEnabled()) + switch.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) { + requireContext().components.addonManager.enableAddon( + addon, + onSuccess = { + switch.setState(true) + this.addon = it + showSnackBar( + view, + getString(R.string.mozac_feature_addons_successfully_enabled, addon.translatedName) + ) + }, + onError = { + showSnackBar( + view, + getString(R.string.mozac_feature_addons_failed_to_enable, addon.translatedName) + ) + } + ) + } else { + requireContext().components.addonManager.disableAddon( + addon, + onSuccess = { + switch.setState(false) + this.addon = it + showSnackBar( + view, + getString(R.string.mozac_feature_addons_successfully_disabled, addon.translatedName) + ) + }, + onError = { + showSnackBar( + view, + getString(R.string.mozac_feature_addons_failed_to_disable, addon.translatedName) + ) + } + ) + } + } + } + + private fun bindSettings(view: View) { + view.settings.apply { + isEnabled = addon.installedState?.optionsPageUrl != null + setOnClickListener { + val directions = + InstalledAddonDetailsFragmentDirections.actionInstalledAddonFragmentToAddonInternalSettingsFragment( + addon + ) + Navigation.findNavController(this).navigate(directions) + } + } + } + + private fun bindDetails(view: View) { + view.details.setOnClickListener { + val directions = + InstalledAddonDetailsFragmentDirections.actionInstalledAddonFragmentToAddonDetailsFragment( + addon + ) + Navigation.findNavController(view).navigate(directions) + } + } + + private fun bindPermissions(view: View) { + view.permissions.setOnClickListener { + val directions = + InstalledAddonDetailsFragmentDirections.actionInstalledAddonFragmentToAddonPermissionsDetailsFragment( + addon + ) + Navigation.findNavController(view).navigate(directions) + } + } + + private fun bindRemoveButton(view: View) { + view.remove_add_on.setOnClickListener { + requireContext().components.addonManager.uninstallAddon( + addon, + onSuccess = { + showSnackBar( + view, + getString(R.string.mozac_feature_addons_successfully_uninstalled, addon.translatedName) + ) + view.findNavController().popBackStack() + }, + onError = { _, _ -> + showSnackBar( + view, + getString( + R.string.mozac_feature_addons_failed_to_uninstall, + addon.translatedName + ) + ) + } + ) + } + } + + private fun Switch.setState(checked: Boolean) { + val text = if (checked) { + R.string.mozac_feature_addons_settings_on + } else { + R.string.mozac_feature_addons_settings_off + } + setText(text) + isChecked = checked + } +} diff --git a/app/src/main/java/org/mozilla/fenix/addons/NotYetSupportedAddonFragment.kt b/app/src/main/java/org/mozilla/fenix/addons/NotYetSupportedAddonFragment.kt new file mode 100644 index 000000000..4b95308b3 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/addons/NotYetSupportedAddonFragment.kt @@ -0,0 +1,76 @@ +/* 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.addons + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import kotlinx.android.synthetic.main.fragment_not_yet_supported_addons.view.* +import mozilla.components.feature.addons.Addon +import mozilla.components.feature.addons.ui.UnsupportedAddonsAdapter +import mozilla.components.feature.addons.ui.UnsupportedAddonsAdapterDelegate +import org.mozilla.fenix.R +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.showToolbar + +private const val LEARN_MORE_URL = + "https://support.mozilla.org/kb/add-compatibility-firefox-preview" + +/** + * Fragment for displaying and managing add-ons that are not yet supported by the browser. + */ +class NotYetSupportedAddonFragment : Fragment(), UnsupportedAddonsAdapterDelegate { + private val addons: List by lazy { + NotYetSupportedAddonFragmentArgs.fromBundle(requireNotNull(arguments)).addons.toList() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_not_yet_supported_addons, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + view.unsupported_add_ons_list.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = UnsupportedAddonsAdapter( + addonManager = requireContext().components.addonManager, + unsupportedAddonsAdapterDelegate = this@NotYetSupportedAddonFragment, + unsupportedAddons = addons + ) + } + + view.learn_more_label.setOnClickListener { + val intent = Intent(Intent.ACTION_VIEW).setData(Uri.parse(LEARN_MORE_URL)) + startActivity(intent) + } + } + + override fun onResume() { + super.onResume() + showToolbar(getString(R.string.mozac_feature_addons_unsupported_section)) + } + + override fun onUninstallError(addonId: String, throwable: Throwable) { + this@NotYetSupportedAddonFragment.view?.let { view -> + showSnackBar(view, getString(R.string.mozac_feature_addons_failed_to_remove, "")) + } + } + + override fun onUninstallSuccess() { + this@NotYetSupportedAddonFragment.view?.let { view -> + showSnackBar(view, getString(R.string.mozac_feature_addons_successfully_removed, "")) + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/addons/WebExtensionActionPopupFragment.kt b/app/src/main/java/org/mozilla/fenix/addons/WebExtensionActionPopupFragment.kt new file mode 100644 index 000000000..33b2af952 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/addons/WebExtensionActionPopupFragment.kt @@ -0,0 +1,101 @@ +/* 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.addons + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import kotlinx.android.synthetic.main.fragment_add_on_internal_settings.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import mozilla.components.browser.state.action.WebExtensionAction +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.EngineView +import mozilla.components.concept.engine.window.WindowRequest +import mozilla.components.lib.state.ext.consumeFrom +import org.mozilla.fenix.R +import org.mozilla.fenix.ext.requireComponents +import org.mozilla.fenix.ext.showToolbar + +/** + * A fragment to show the web extension action popup with [EngineView]. + */ +class WebExtensionActionPopupFragment : Fragment(), EngineSession.Observer { + private val webExtensionTitle: String? by lazy { + WebExtensionActionPopupFragmentArgs.fromBundle(requireNotNull(arguments)).webExtensionTitle + } + private val webExtensionId: String by lazy { + WebExtensionActionPopupFragmentArgs.fromBundle(requireNotNull(arguments)).webExtensionId + } + private var engineSession: EngineSession? = null + private val coreComponents by lazy { requireComponents.core } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + // Grab the [EngineSession] from the store when the view is created if it is available. + if (engineSession == null) { + engineSession = coreComponents.store.state.extensions[webExtensionId]?.popupSession + } + + return inflater.inflate(R.layout.fragment_add_on_internal_settings, container, false) + } + + override fun onResume() { + super.onResume() + val title = webExtensionTitle ?: webExtensionId + showToolbar(title) + } + + override fun onStart() { + super.onStart() + engineSession?.register(this) + } + + override fun onStop() { + super.onStop() + engineSession?.unregister(this) + } + + override fun onWindowRequest(windowRequest: WindowRequest) { + if (windowRequest.type == WindowRequest.Type.CLOSE) { + activity?.onBackPressed() + } + } + + @ExperimentalCoroutinesApi + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val session = engineSession + // If we have the session, render it otherwise consume it from the store. + if (session != null) { + addonSettingsEngineView.render(session) + consumePopupSession() + } else { + consumeFrom(coreComponents.store) { state -> + state.extensions[webExtensionId]?.let { extState -> + extState.popupSession?.let { + if (engineSession == null) { + addonSettingsEngineView.render(it) + it.register(this) + consumePopupSession() + engineSession = it + } + } + } + } + } + } + + private fun consumePopupSession() { + coreComponents.store.dispatch( + WebExtensionAction.UpdatePopupSessionAction(webExtensionId, popupSession = null) + ) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/components/Components.kt b/app/src/main/java/org/mozilla/fenix/components/Components.kt index 93f2edcc0..2294d2689 100644 --- a/app/src/main/java/org/mozilla/fenix/components/Components.kt +++ b/app/src/main/java/org/mozilla/fenix/components/Components.kt @@ -5,10 +5,18 @@ package org.mozilla.fenix.components import android.content.Context +import mozilla.components.feature.addons.AddonManager +import mozilla.components.feature.addons.amo.AddonCollectionProvider +import mozilla.components.feature.addons.update.AddonUpdater +import mozilla.components.feature.addons.update.DefaultAddonUpdater +import mozilla.components.feature.tabs.TabsUseCases import mozilla.components.lib.publicsuffixlist.PublicSuffixList import mozilla.components.support.migration.state.MigrationStore import org.mozilla.fenix.test.Mockable import org.mozilla.fenix.utils.ClipboardHandler +import java.util.concurrent.TimeUnit + +private const val DAY_IN_MINUTES = 24 * 60L /** * Provides access to all components. @@ -49,6 +57,24 @@ class Components(private val context: Context) { migrationStore ) } + + /** + * Add-on + */ + val addonCollectionProvider by lazy { + AddonCollectionProvider(context, core.client, maxCacheAgeInMinutes = DAY_IN_MINUTES) + } + + val addonUpdater by lazy { + DefaultAddonUpdater(context, AddonUpdater.Frequency(1, TimeUnit.DAYS)) + } + + val addonManager by lazy { + AddonManager(core.store, core.engine, addonCollectionProvider, addonUpdater) + } + + val tabsUseCases: TabsUseCases by lazy { TabsUseCases(core.sessionManager) } + val analytics by lazy { Analytics(context) } val publicSuffixList by lazy { PublicSuffixList(context) } val clipboardHandler by lazy { ClipboardHandler(context) } diff --git a/app/src/main/java/org/mozilla/fenix/components/metrics/Metrics.kt b/app/src/main/java/org/mozilla/fenix/components/metrics/Metrics.kt index 024b88abf..65489e4de 100644 --- a/app/src/main/java/org/mozilla/fenix/components/metrics/Metrics.kt +++ b/app/src/main/java/org/mozilla/fenix/components/metrics/Metrics.kt @@ -353,7 +353,7 @@ sealed class Event { SETTINGS, LIBRARY, HELP, DESKTOP_VIEW_ON, DESKTOP_VIEW_OFF, FIND_IN_PAGE, NEW_TAB, NEW_PRIVATE_TAB, SHARE, REPORT_SITE_ISSUE, BACK, FORWARD, RELOAD, STOP, OPEN_IN_FENIX, SAVE_TO_COLLECTION, ADD_TO_FIREFOX_HOME, ADD_TO_HOMESCREEN, QUIT, READER_MODE_ON, - READER_MODE_OFF, OPEN_IN_APP, BOOKMARK, READER_MODE_APPEARANCE + READER_MODE_OFF, OPEN_IN_APP, BOOKMARK, READER_MODE_APPEARANCE, ADDONS_MANAGER } override val extras: Map? diff --git a/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarController.kt b/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarController.kt index 77ad05f06..1775ebfa5 100644 --- a/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarController.kt +++ b/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarController.kt @@ -207,6 +207,13 @@ class DefaultBrowserToolbarController( ToolbarMenu.Item.Help -> { activity.components.useCases.tabsUseCases.addTab.invoke(getSupportUrl()) } + ToolbarMenu.Item.AddonsManager -> { + navController.nav( + R.id.browserFragment, + BrowserFragmentDirections + .actionBrowserFragmentToAddonsManagementFragment() + ) + } ToolbarMenu.Item.SaveToCollection -> { activity.components.analytics.metrics .track(Event.CollectionSaveButtonPressed(TELEMETRY_BROWSER_IDENTIFIER)) @@ -340,6 +347,7 @@ class DefaultBrowserToolbarController( Event.BrowserMenuItemTapped.Item.READER_MODE_APPEARANCE ToolbarMenu.Item.OpenInApp -> Event.BrowserMenuItemTapped.Item.OPEN_IN_APP ToolbarMenu.Item.Bookmark -> Event.BrowserMenuItemTapped.Item.BOOKMARK + ToolbarMenu.Item.AddonsManager -> Event.BrowserMenuItemTapped.Item.ADDONS_MANAGER } activity.components.analytics.metrics.track(Event.BrowserMenuItemTapped(eventItem)) diff --git a/app/src/main/java/org/mozilla/fenix/components/toolbar/DefaultToolbarMenu.kt b/app/src/main/java/org/mozilla/fenix/components/toolbar/DefaultToolbarMenu.kt index ea28de186..3a016c41f 100644 --- a/app/src/main/java/org/mozilla/fenix/components/toolbar/DefaultToolbarMenu.kt +++ b/app/src/main/java/org/mozilla/fenix/components/toolbar/DefaultToolbarMenu.kt @@ -10,8 +10,8 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch -import mozilla.components.browser.menu.BrowserMenuBuilder import mozilla.components.browser.menu.BrowserMenuHighlight +import mozilla.components.browser.menu.WebExtensionBrowserMenuBuilder import mozilla.components.browser.menu.item.BrowserMenuDivider import mozilla.components.browser.menu.item.BrowserMenuHighlightableItem import mozilla.components.browser.menu.item.BrowserMenuHighlightableSwitch @@ -45,7 +45,14 @@ class DefaultToolbarMenu( private var currentUrlIsBookmarked = false private var isBookmarkedJob: Job? = null - override val menuBuilder by lazy { BrowserMenuBuilder(menuItems, endOfMenuAlwaysVisible = true) } + override val menuBuilder by lazy { + WebExtensionBrowserMenuBuilder( + menuItems, + endOfMenuAlwaysVisible = true, + store = context.components.core.store, + appendExtensionActionAtStart = true + ) + } override val menuToolbar by lazy { val forward = BrowserMenuItemToolbar.TwoStateButton( @@ -157,6 +164,7 @@ class DefaultToolbarMenu( desktopMode, addToFirefoxHome, addToHomescreen.apply { visible = ::shouldShowAddToHomescreen }, + addons, findInPage, privateTab, newTab, @@ -173,6 +181,14 @@ class DefaultToolbarMenu( if (shouldReverseItems) { menuItems.reversed() } else { menuItems } } + private val addons = BrowserMenuImageText( + context.getString(R.string.browser_menu_addon_manager), + R.drawable.mozac_ic_extensions, + primaryTextColor() + ) { + onItemTapped.invoke(ToolbarMenu.Item.AddonsManager) + } + private val help = BrowserMenuImageText( context.getString(R.string.browser_menu_help), R.drawable.ic_help, diff --git a/app/src/main/java/org/mozilla/fenix/components/toolbar/ToolbarMenu.kt b/app/src/main/java/org/mozilla/fenix/components/toolbar/ToolbarMenu.kt index ced4a10ce..66325eebc 100644 --- a/app/src/main/java/org/mozilla/fenix/components/toolbar/ToolbarMenu.kt +++ b/app/src/main/java/org/mozilla/fenix/components/toolbar/ToolbarMenu.kt @@ -26,6 +26,7 @@ interface ToolbarMenu { object SaveToCollection : Item() object AddToFirefoxHome : Item() object AddToHomeScreen : Item() + object AddonsManager : Item() object Quit : Item() data class ReaderMode(val isChecked: Boolean) : Item() object OpenInApp : Item() diff --git a/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt index a247e0b2b..9fc970eef 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt @@ -51,6 +51,7 @@ import org.mozilla.fenix.R.string.pref_key_theme import org.mozilla.fenix.R.string.pref_key_toolbar import org.mozilla.fenix.R.string.pref_key_tracking_protection_settings import org.mozilla.fenix.R.string.pref_key_your_rights +import org.mozilla.fenix.R.string.pref_key_addons import org.mozilla.fenix.components.PrivateShortcutCreateManager import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.ext.application @@ -185,7 +186,7 @@ class SettingsFragment : PreferenceFragmentCompat() { findPreference(getPreferenceKey(pref_key_passwords))?.apply { isVisible = FeatureFlags.logins } - findPreference(getPreferenceKey(R.string.pref_key_advanced))?.apply { + findPreference(getPreferenceKey(pref_key_language))?.apply { isVisible = FeatureFlags.fenixLanguagePicker } } @@ -214,6 +215,10 @@ class SettingsFragment : PreferenceFragmentCompat() { resources.getString(pref_key_language) -> { SettingsFragmentDirections.actionSettingsFragmentToLocaleSettingsFragment() } + resources.getString(pref_key_addons) -> { + SettingsFragmentDirections.actionSettingsFragmentToAddonsFragment() + } + resources.getString(pref_key_make_default_browser) -> { SettingsFragmentDirections.actionSettingsFragmentToDefaultBrowserSettingsFragment() } diff --git a/app/src/main/res/drawable/addon_textview_selector.xml b/app/src/main/res/drawable/addon_textview_selector.xml new file mode 100644 index 000000000..9caeefd35 --- /dev/null +++ b/app/src/main/res/drawable/addon_textview_selector.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/mozac_ic_extensions_black.xml b/app/src/main/res/drawable/mozac_ic_extensions_black.xml new file mode 100644 index 000000000..097a4a094 --- /dev/null +++ b/app/src/main/res/drawable/mozac_ic_extensions_black.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/mozac_ic_permissions.xml b/app/src/main/res/drawable/mozac_ic_permissions.xml new file mode 100644 index 000000000..6817c3819 --- /dev/null +++ b/app/src/main/res/drawable/mozac_ic_permissions.xml @@ -0,0 +1,20 @@ + + + + + + + diff --git a/app/src/main/res/layout/activity_addons.xml b/app/src/main/res/layout/activity_addons.xml new file mode 100644 index 000000000..e8b4ef833 --- /dev/null +++ b/app/src/main/res/layout/activity_addons.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/fragment_add_on_details.xml b/app/src/main/res/layout/fragment_add_on_details.xml new file mode 100644 index 000000000..d4a39db92 --- /dev/null +++ b/app/src/main/res/layout/fragment_add_on_details.xml @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_add_on_internal_settings.xml b/app/src/main/res/layout/fragment_add_on_internal_settings.xml new file mode 100644 index 000000000..0479ab618 --- /dev/null +++ b/app/src/main/res/layout/fragment_add_on_internal_settings.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/app/src/main/res/layout/fragment_add_on_permissions.xml b/app/src/main/res/layout/fragment_add_on_permissions.xml new file mode 100644 index 000000000..70f72538c --- /dev/null +++ b/app/src/main/res/layout/fragment_add_on_permissions.xml @@ -0,0 +1,28 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_add_ons_management.xml b/app/src/main/res/layout/fragment_add_ons_management.xml new file mode 100644 index 000000000..a7cd5a6b1 --- /dev/null +++ b/app/src/main/res/layout/fragment_add_ons_management.xml @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_installed_add_on_details.xml b/app/src/main/res/layout/fragment_installed_add_on_details.xml new file mode 100644 index 000000000..57f9055dd --- /dev/null +++ b/app/src/main/res/layout/fragment_installed_add_on_details.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + +