diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/BaseBrowserFragment.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/BaseBrowserFragment.kt index ca21d32d..f1483f66 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/BaseBrowserFragment.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/BaseBrowserFragment.kt @@ -10,7 +10,10 @@ import android.view.ViewGroup import android.widget.FrameLayout import androidx.annotation.CallSuper import androidx.fragment.app.Fragment +import eu.lensai.flutter_mozilla_components.addons.WebExtensionActionPopupActivity +import eu.lensai.flutter_mozilla_components.addons.WebExtensionPromptFeature import eu.lensai.flutter_mozilla_components.databinding.FragmentBrowserBinding +import mozilla.components.browser.state.state.WebExtensionState import mozilla.components.concept.engine.EngineView import mozilla.components.feature.app.links.AppLinksFeature import mozilla.components.feature.downloads.DownloadsFeature @@ -29,6 +32,7 @@ import mozilla.components.support.base.log.logger.Logger import mozilla.components.support.ktx.android.arch.lifecycle.addObservers import mozilla.components.support.locale.ActivityContextWrapper import mozilla.components.support.utils.ext.requestInPlacePermissions +import mozilla.components.support.webextensions.WebExtensionPopupObserver /** * Base fragment extended by [BrowserFragment] and [ExternalAppBrowserFragment]. @@ -41,9 +45,11 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit private val downloadsFeature = ViewBoundFeatureWrapper() private val appLinksFeature = ViewBoundFeatureWrapper() private val promptFeature = ViewBoundFeatureWrapper() + private val webExtensionPromptFeature = ViewBoundFeatureWrapper() private val sitePermissionsFeature = ViewBoundFeatureWrapper() private val swipeRefreshFeature = ViewBoundFeatureWrapper() + protected val sessionId: String? get() = arguments?.getString(SESSION_ID_KEY) @@ -215,20 +221,41 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit view = binding.root, ) + webExtensionPromptFeature.set( + feature = WebExtensionPromptFeature( + store = components.store, + context = requireContext(), + fragmentManager = parentFragmentManager, + ), + owner = this, + view = binding.root + ) + val secureWindowFeature = SecureWindowFeature( window = requireActivity().window, store = components.store, customTabId = sessionId, ) + val webExtensionPopupObserver = WebExtensionPopupObserver(components.store, ::openPopup) + // Observe the lifecycle for supported features lifecycle.addObservers( secureWindowFeature, + webExtensionPopupObserver, ) return binding.root } + private fun openPopup(webExtensionState: WebExtensionState) { + val intent = Intent(requireContext().applicationContext, WebExtensionActionPopupActivity::class.java) + intent.putExtra("web_extension_id", webExtensionState.id) + intent.putExtra("web_extension_name", webExtensionState.name) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + startActivity(intent) + } + @CallSuper override fun onBackPressed(): Boolean = listOf(sessionFeature).any { it.onBackPressed() } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt index c961d4e0..214f6969 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt @@ -58,9 +58,13 @@ class Components( ) { private val runtime by lazy { // Allow for exfiltrating Gecko metrics through the Glean SDK. - val builder = GeckoRuntimeSettings.Builder().aboutConfigEnabled(true) + val builder = GeckoRuntimeSettings.Builder() + .aboutConfigEnabled(true) + .extensionsWebAPIEnabled(true) + builder.experimentDelegate(NimbusExperimentDelegate()) builder.crashHandler(CrashHandlerService::class.java) + GeckoRuntime.create(applicationContext, builder.build()) } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/NotificationActivity.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/NotificationActivity.kt index 99c7c7c5..fbd50f46 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/NotificationActivity.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/NotificationActivity.kt @@ -2,7 +2,6 @@ package eu.lensai.flutter_mozilla_components import android.os.Bundle import androidx.appcompat.app.AppCompatActivity -import eu.lensai.flutter_mozilla_components.GlobalComponents class NotificationActivity: AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/addons/AddonDetailsActivity.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/addons/AddonDetailsActivity.kt new file mode 100644 index 00000000..5efa58da --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/addons/AddonDetailsActivity.kt @@ -0,0 +1,107 @@ +/* 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 eu.lensai.flutter_mozilla_components.addons + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.text.method.LinkMovementMethod +import android.view.View +import android.widget.RatingBar +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.core.text.HtmlCompat +import mozilla.components.feature.addons.R as MozComp +import mozilla.components.feature.addons.Addon +import mozilla.components.feature.addons.ui.translateDescription +import mozilla.components.feature.addons.ui.translateName +import mozilla.components.support.utils.ext.getParcelableExtraCompat +import eu.lensai.flutter_mozilla_components.R +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Locale + +/** + * An activity to show the details of an add-on. + */ +class AddonDetailsActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_add_on_details) + val addon = requireNotNull( + intent.getParcelableExtraCompat("add_on", Addon::class.java), + ) + bind(addon) + } + + private fun bind(addon: Addon) { + title = addon.translateName(this) + + bindDetails(addon) + + bindAuthor(addon) + + bindVersion(addon) + + bindLastUpdated(addon) + + bindWebsite(addon) + + bindRating(addon) + } + + private fun bindRating(addon: Addon) { + addon.rating?.let { + val ratingView = findViewById(R.id.rating_view) + val userCountView = findViewById(R.id.users_count) + + val ratingContentDescription = getString(MozComp.string.mozac_feature_addons_rating_content_description_2) + ratingView.contentDescription = String.format(ratingContentDescription, it.average) + ratingView.rating = it.average + + userCountView.text = getFormattedAmount(it.reviews) + } + } + + private fun bindWebsite(addon: Addon) { + findViewById(R.id.home_page_text).setOnClickListener { + val intent = + Intent(Intent.ACTION_VIEW).setData(Uri.parse(addon.homepageUrl)) + startActivity(intent) + } + } + + private fun bindLastUpdated(addon: Addon) { + val lastUpdatedView = findViewById(R.id.last_updated_text) + lastUpdatedView.text = formatDate(addon.updatedAt) + } + + private fun bindVersion(addon: Addon) { + val versionView = findViewById(R.id.version_text) + versionView.text = addon.version + } + + private fun bindAuthor(addon: Addon) { + val authorsView = findViewById(R.id.author_text) + + authorsView.text = addon.author?.name.orEmpty() + } + + private fun bindDetails(addon: Addon) { + val detailsView = findViewById(R.id.details) + val detailsText = addon.translateDescription(this) + + 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/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/addons/AddonSettingsActivity.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/addons/AddonSettingsActivity.kt new file mode 100644 index 00000000..b66fad80 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/addons/AddonSettingsActivity.kt @@ -0,0 +1,100 @@ +/* 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 eu.lensai.flutter_mozilla_components.addons + +import android.content.Context +import android.os.Bundle +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import eu.lensai.flutter_mozilla_components.Components +import eu.lensai.flutter_mozilla_components.GlobalComponents +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.EngineView +import mozilla.components.feature.addons.Addon +import mozilla.components.feature.addons.ui.translateName +import mozilla.components.support.utils.ext.getParcelableCompat +import mozilla.components.support.utils.ext.getParcelableExtraCompat +import eu.lensai.flutter_mozilla_components.R +import mozilla.components.browser.state.store.BrowserStore + +/** + * An activity to show the settings of an add-on. + */ +class AddonSettingsActivity : AppCompatActivity() { + private val components: Components by lazy { GlobalComponents.components!! } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_add_on_settings) + + val addon = requireNotNull( + intent.getParcelableExtraCompat("add_on", Addon::class.java), + ) + + title = addon.translateName(this) + + supportFragmentManager + .beginTransaction() + .replace(R.id.addonSettingsContainer, AddonSettingsFragment.create(addon)) + .commit() + } + + override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet): View? = + when (name) { + EngineView::class.java.name -> components.engine.createView(context, attrs).asView() + else -> super.onCreateView(parent, name, context, attrs) + } + + /** + * A fragment to show the settings of an add-on with [EngineView]. + */ + class AddonSettingsFragment : Fragment() { + private val components: Components by lazy { GlobalComponents.components!! } + + private lateinit var optionsPageUrl: String + private lateinit var engineSession: EngineSession + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + optionsPageUrl = requireNotNull( + arguments?.getParcelableCompat( + "add_on", + Addon::class.java, + )?.installedState?.optionsPageUrl, + ) + + engineSession = components.engine.createSession() + + return inflater.inflate(R.layout.fragment_add_on_settings, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val addonSettingsEngineView = view.findViewById(R.id.addonSettingsEngineView) as EngineView + addonSettingsEngineView.render(engineSession) + engineSession.loadUrl(optionsPageUrl) + } + + override fun onDestroyView() { + engineSession.close() + super.onDestroyView() + } + + companion object { + /** + * Create an [AddonSettingsFragment] with add_on as a required parameter. + */ + fun create(addon: Addon) = AddonSettingsFragment().apply { + arguments = Bundle().apply { + putParcelable("add_on", addon) + } + } + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/addons/AddonsActivity.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/addons/AddonsActivity.kt new file mode 100644 index 00000000..61bc1299 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/addons/AddonsActivity.kt @@ -0,0 +1,26 @@ +/* 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 eu.lensai.flutter_mozilla_components.addons + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import eu.lensai.flutter_mozilla_components.R + +/** + * An activity to manage add-ons. + */ +class AddonsActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_add_on_main) + + if (savedInstanceState == null) { + supportFragmentManager.beginTransaction().apply { + replace(R.id.container, AddonsFragment()) + commit() + } + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/addons/AddonsFragment.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/addons/AddonsFragment.kt new file mode 100644 index 00000000..b3bb7cef --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/addons/AddonsFragment.kt @@ -0,0 +1,149 @@ +/* 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 eu.lensai.flutter_mozilla_components.addons + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import eu.lensai.flutter_mozilla_components.Components +import eu.lensai.flutter_mozilla_components.GlobalComponents +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.support.base.feature.ViewBoundFeatureWrapper +import eu.lensai.flutter_mozilla_components.R +import mozilla.components.feature.addons.R as MozComp + +/** + * Fragment use for managing add-ons. + */ +class AddonsFragment : Fragment(), AddonsManagerAdapterDelegate { + private val components: Components by lazy { GlobalComponents.components!! } + + private val webExtensionPromptFeature = ViewBoundFeatureWrapper() + private lateinit var recyclerView: RecyclerView + private val scope = CoroutineScope(Dispatchers.IO) + private lateinit var addons: List + private var adapter: AddonsManagerAdapter? = null + + private val addonProgressOverlay: View + get() = requireView().findViewById(R.id.addonProgressOverlay) + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + return inflater.inflate(R.layout.fragment_add_ons, container, false) + } + + override fun onViewCreated(rootView: View, savedInstanceState: Bundle?) { + super.onViewCreated(rootView, savedInstanceState) + bindRecyclerView(rootView) + webExtensionPromptFeature.set( + feature = WebExtensionPromptFeature( + store = components.store, + context = requireContext(), + fragmentManager = parentFragmentManager, + ), + owner = this, + view = rootView, + ) + } + + override fun onStart() { + super.onStart() + + this@AddonsFragment.view?.let { view -> + bindRecyclerView(view) + } + + addonProgressOverlay.visibility = View.GONE + } + + private fun bindRecyclerView(rootView: View) { + recyclerView = rootView.findViewById(R.id.add_ons_list) + recyclerView.layoutManager = LinearLayoutManager(requireContext()) + scope.launch { + try { + addons = components.addonManager.getAddons() + + scope.launch(Dispatchers.Main) { + adapter = AddonsManagerAdapter( + this@AddonsFragment, + addons, + store = components.store, + ) + recyclerView.adapter = adapter + } + } catch (e: AddonManagerException) { + scope.launch(Dispatchers.Main) { + Toast.makeText( + activity, + MozComp.string.mozac_feature_addons_failed_to_query_extensions, + Toast.LENGTH_SHORT, + ).show() + } + } + } + } + + override fun onAddonItemClicked(addon: Addon) { + if (addon.isInstalled()) { + val intent = Intent(context, InstalledAddonDetailsActivity::class.java) + intent.putExtra("add_on", addon) + startActivity(intent) + } else { + val intent = Intent(context, AddonDetailsActivity::class.java) + intent.putExtra("add_on", addon) + startActivity(intent) + } + } + + override fun onInstallAddonButtonClicked(addon: Addon) { + if (isInstallationInProgress) { + return + } + installAddon(addon) + } + + private val installAddon: ((Addon) -> Unit) = { addon -> + addonProgressOverlay.visibility = View.VISIBLE + isInstallationInProgress = true + components.addonManager.installAddon( + url = addon.downloadUrl, + onSuccess = { + runIfFragmentIsAttached { + isInstallationInProgress = false + this@AddonsFragment.view?.let { view -> + bindRecyclerView(view) + } + addonProgressOverlay.visibility = View.GONE + } + }, + onError = { _ -> + runIfFragmentIsAttached { + addonProgressOverlay.visibility = View.GONE + isInstallationInProgress = false + } + }, + ) + } + + /** + * Whether or not an add-on installation is in progress. + */ + private var isInstallationInProgress = false +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/addons/Extensions.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/addons/Extensions.kt new file mode 100644 index 00000000..047b4539 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/addons/Extensions.kt @@ -0,0 +1,24 @@ +/* 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 eu.lensai.flutter_mozilla_components.addons + +import androidx.fragment.app.Fragment +import java.text.NumberFormat +import java.util.Locale + +internal fun getFormattedAmount(amount: Int): String { + return NumberFormat.getNumberInstance(Locale.getDefault()).format(amount) +} + +/** + * Run the [block] only if the [Fragment] is attached. + * + * @param block A callback to be executed if the container [Fragment] is attached. + */ +internal inline fun Fragment.runIfFragmentIsAttached(block: () -> Unit) { + context?.let { + block() + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/addons/InstalledAddonDetailsActivity.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/addons/InstalledAddonDetailsActivity.kt new file mode 100644 index 00000000..f0ee1635 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/addons/InstalledAddonDetailsActivity.kt @@ -0,0 +1,202 @@ +/* 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 eu.lensai.flutter_mozilla_components.addons + +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.SwitchCompat +import androidx.core.view.isVisible +import eu.lensai.flutter_mozilla_components.Components +import eu.lensai.flutter_mozilla_components.GlobalComponents +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.translateName +import mozilla.components.support.utils.ext.getParcelableExtraCompat +import eu.lensai.flutter_mozilla_components.R +import mozilla.components.feature.addons.R as MozComp + +/** + * An activity to show the details of a installed add-on. + */ +class InstalledAddonDetailsActivity : AppCompatActivity() { + private val components: Components by lazy { GlobalComponents.components!! } + + private val scope = CoroutineScope(Dispatchers.IO) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_installed_add_on_details) + val addon = requireNotNull( + intent.getParcelableExtraCompat("add_on", Addon::class.java), + ).also { + bindUI(it) + } + + bindAddon(addon) + } + + private fun bindAddon(addon: Addon) { + scope.launch { + try { + val addons = components.addonManager.getAddons() + scope.launch(Dispatchers.Main) { + addons.find { addon.id == it.id }.let { + if (it == null) { + throw AddonManagerException(Exception("Addon ${addon.id} not found")) + } else { + bindUI(it) + } + } + } + } catch (e: AddonManagerException) { + scope.launch(Dispatchers.Main) { + Toast.makeText( + baseContext, + MozComp.string.mozac_feature_addons_failed_to_query_extensions, + Toast.LENGTH_SHORT, + ).show() + } + } + } + } + + private fun bindUI(addon: Addon) { + title = addon.translateName(this) + + bindEnableSwitch(addon) + + bindSettings(addon) + + bindDetails(addon) + + bindPermissions(addon) + + bindAllowInPrivateBrowsingSwitch(addon) + + bindRemoveButton(addon) + } + + private fun bindEnableSwitch(addon: Addon) { + val switch = findViewById(R.id.enable_switch) + switch.setState(addon.isEnabled()) + switch.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) { + components.addonManager.enableAddon( + addon, + onSuccess = { + switch.setState(true) + Toast.makeText( + this, + getString(MozComp.string.mozac_feature_addons_successfully_enabled, addon.translateName(this)), + Toast.LENGTH_SHORT, + ).show() + }, + onError = { + Toast.makeText( + this, + getString(MozComp.string.mozac_feature_addons_failed_to_enable, addon.translateName(this)), + Toast.LENGTH_SHORT, + ).show() + }, + ) + } else { + components.addonManager.disableAddon( + addon, + onSuccess = { + switch.setState(false) + Toast.makeText( + this, + getString(MozComp.string.mozac_feature_addons_successfully_disabled, addon.translateName(this)), + Toast.LENGTH_SHORT, + ).show() + }, + onError = { + Toast.makeText( + this, + getString(MozComp.string.mozac_feature_addons_failed_to_disable, addon.translateName(this)), + Toast.LENGTH_SHORT, + ).show() + }, + ) + } + } + } + + private fun bindSettings(addon: Addon) { + val view = findViewById(R.id.settings) + view.isVisible = shouldSettingsBeVisible(addon) + view.isEnabled = shouldSettingsBeVisible(addon) + view.setOnClickListener { + val intent = Intent(this, AddonSettingsActivity::class.java) + intent.putExtra("add_on", addon) + this.startActivity(intent) + } + } + + private fun bindDetails(addon: Addon) { + findViewById(R.id.details).setOnClickListener { + val intent = Intent(this, AddonDetailsActivity::class.java) + intent.putExtra("add_on", addon) + this.startActivity(intent) + } + } + + private fun bindPermissions(addon: Addon) { + findViewById(R.id.permissions).setOnClickListener { + val intent = Intent(this, PermissionsDetailsActivity::class.java) + intent.putExtra("add_on", addon) + this.startActivity(intent) + } + } + + private fun bindAllowInPrivateBrowsingSwitch(addon: Addon) { + val switch = findViewById(R.id.allow_in_private_browsing_switch) + switch.isChecked = addon.isAllowedInPrivateBrowsing() + switch.setOnCheckedChangeListener { _, isChecked -> + components.addonManager.setAddonAllowedInPrivateBrowsing( + addon, + isChecked, + onSuccess = { + switch.isChecked = isChecked + }, + ) + } + } + + private fun bindRemoveButton(addon: Addon) { + findViewById(R.id.remove_add_on).setOnClickListener { + components.addonManager.uninstallAddon( + addon, + onSuccess = { + Toast.makeText( + this, + getString(MozComp.string.mozac_feature_addons_successfully_uninstalled, addon.translateName(this)), + Toast.LENGTH_SHORT, + ).show() + finish() + }, + onError = { _, _ -> + Toast.makeText( + this, + getString(MozComp.string.mozac_feature_addons_failed_to_uninstall, addon.translateName(this)), + Toast.LENGTH_SHORT, + ).show() + }, + ) + } + } + + private fun SwitchCompat.setState(checked: Boolean) { + isChecked = checked + } + + private fun shouldSettingsBeVisible(addon: Addon) = !addon.installedState?.optionsPageUrl.isNullOrEmpty() +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/addons/PermissionsDetailsActivity.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/addons/PermissionsDetailsActivity.kt new file mode 100644 index 00000000..208bdc96 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/addons/PermissionsDetailsActivity.kt @@ -0,0 +1,93 @@ +/* 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 eu.lensai.flutter_mozilla_components.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 android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import mozilla.components.feature.addons.Addon +import mozilla.components.feature.addons.ui.translateName +import mozilla.components.support.utils.ext.getParcelableExtraCompat +import eu.lensai.flutter_mozilla_components.R + +private const val LEARN_MORE_URL = + "https://support.mozilla.org/kb/permission-request-messages-firefox-extensions" + +/** + * An activity to show the permissions of an add-on. + */ +class PermissionsDetailsActivity : AppCompatActivity(), View.OnClickListener { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_add_on_permissions) + val addon = requireNotNull( + intent.getParcelableExtraCompat("add_on", Addon::class.java), + ) + + title = addon.translateName(this) + + bindPermissions(addon) + + bindLearnMore() + } + + private fun bindPermissions(addon: Addon) { + val recyclerView = findViewById(R.id.add_ons_permissions) + recyclerView.layoutManager = LinearLayoutManager(this) + val sortedPermissions = addon.translatePermissions(this).sorted() + recyclerView.adapter = PermissionsAdapter(sortedPermissions) + } + + private fun bindLearnMore() { + findViewById(R.id.learn_more_label).setOnClickListener(this) + } + + /** + * An adapter for displaying the permissions of an add-on. + */ + class PermissionsAdapter( + private val permissions: List, + ) : + RecyclerView.Adapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PermissionViewHolder { + val context = parent.context + val inflater = LayoutInflater.from(context) + val view = inflater.inflate(R.layout.add_ons_permission_item, parent, false) + val titleView = view.findViewById(R.id.permission) + return PermissionViewHolder( + view, + titleView, + ) + } + + override fun getItemCount() = permissions.size + + override fun onBindViewHolder(holder: PermissionViewHolder, position: Int) { + val permission = permissions[position] + holder.textView.text = permission + } + } + + /** + * A view holder for displaying the permissions of an add-on. + */ + class PermissionViewHolder( + val view: View, + val textView: TextView, + ) : RecyclerView.ViewHolder(view) + + override fun onClick(v: View?) { + val intent = + Intent(Intent.ACTION_VIEW).setData(Uri.parse(LEARN_MORE_URL)) + startActivity(intent) + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/addons/WebExtensionActionPopupActivity.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/addons/WebExtensionActionPopupActivity.kt new file mode 100644 index 00000000..73a9efa8 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/addons/WebExtensionActionPopupActivity.kt @@ -0,0 +1,127 @@ +/* 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 eu.lensai.flutter_mozilla_components.addons + +import android.content.Context +import android.os.Bundle +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import eu.lensai.flutter_mozilla_components.Components +import eu.lensai.flutter_mozilla_components.GlobalComponents +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 eu.lensai.flutter_mozilla_components.R + +/** + * An activity to show the pop up action of a web extension. + */ +class WebExtensionActionPopupActivity : AppCompatActivity() { + private val components: Components by lazy { GlobalComponents.components!! } + + private lateinit var webExtensionId: String + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_add_on_settings) + + webExtensionId = requireNotNull(intent.getStringExtra("web_extension_id")) + intent.getStringExtra("web_extension_name")?.let { + title = it + } + + supportFragmentManager + .beginTransaction() + .replace(R.id.addonSettingsContainer, WebExtensionActionPopupFragment.create(webExtensionId)) + .commit() + } + + override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet): View? = + when (name) { + EngineView::class.java.name -> components.engine.createView(context, attrs).asView() + else -> super.onCreateView(parent, name, context, attrs) + } + + /** + * A fragment to show the web extension action popup with [EngineView]. + */ + class WebExtensionActionPopupFragment : Fragment(), EngineSession.Observer { + private val components: Components by lazy { GlobalComponents.components!! } + + private var engineSession: EngineSession? = null + private lateinit var webExtensionId: String + + private val addonSettingsEngineView: EngineView + get() = requireView().findViewById(R.id.addonSettingsEngineView) as EngineView + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + webExtensionId = requireNotNull(arguments?.getString("web_extension_id")) + engineSession = components.store.state.extensions[webExtensionId]?.popupSession + + return inflater.inflate(R.layout.fragment_add_on_settings, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val session = engineSession + if (session != null) { + addonSettingsEngineView.render(session) + consumePopupSession() + } else { + consumeFrom(components.store) { state -> + state.extensions[webExtensionId]?.let { extState -> + extState.popupSession?.let { + if (engineSession == null) { + addonSettingsEngineView.render(it) + consumePopupSession() + engineSession = it + } + } + } + } + } + } + + 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?.onBackPressedDispatcher?.onBackPressed() + } + } + + private fun consumePopupSession() { + components.store.dispatch( + WebExtensionAction.UpdatePopupSessionAction(webExtensionId, popupSession = null), + ) + } + + companion object { + /** + * Create an [WebExtensionActionPopupFragment] with webExtensionId as a required parameter. + */ + fun create(webExtensionId: String) = WebExtensionActionPopupFragment().apply { + arguments = Bundle().apply { + putString("web_extension_id", webExtensionId) + } + } + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/addons/WebExtensionPromptFeature.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/addons/WebExtensionPromptFeature.kt new file mode 100644 index 00000000..3b4e7070 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/addons/WebExtensionPromptFeature.kt @@ -0,0 +1,365 @@ +/* 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 eu.lensai.flutter_mozilla_components.addons + +import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.FragmentManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.mapNotNull +import mozilla.components.browser.state.action.WebExtensionAction +import mozilla.components.browser.state.state.extension.WebExtensionPromptRequest +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.engine.webextension.PermissionPromptResponse +import mozilla.components.concept.engine.webextension.WebExtensionInstallException +import mozilla.components.feature.addons.Addon +import mozilla.components.feature.addons.ui.AddonInstallationDialogFragment +import mozilla.components.feature.addons.ui.PermissionsDialogFragment +import mozilla.components.lib.state.ext.flowScoped +import mozilla.components.support.base.feature.LifecycleAwareFeature +import mozilla.components.support.ktx.android.content.appVersionName +import mozilla.components.ui.widgets.withCenterAlignedButtons +import eu.lensai.flutter_mozilla_components.R +import mozilla.components.feature.addons.R as MozComp + +/** + * Feature implementation for handling [WebExtensionPromptRequest] and showing the respective UI. + */ +class WebExtensionPromptFeature( + private val store: BrowserStore, + private val context: Context, + private val fragmentManager: FragmentManager, +) : LifecycleAwareFeature { + + /** + * Whether or not an add-on installation is in progress. + */ + private var isInstallationInProgress = false + private var scope: CoroutineScope? = null + + /** + * Starts observing the selected session to listen for window requests + * and opens / closes tabs as needed. + */ + override fun start() { + scope = store.flowScoped { flow -> + flow.mapNotNull { state -> + state.webExtensionPromptRequest + }.distinctUntilChanged().collect { promptRequest -> + when (promptRequest) { + is WebExtensionPromptRequest.AfterInstallation -> { + handleAfterInstallationRequest(promptRequest) + } + + is WebExtensionPromptRequest.BeforeInstallation.InstallationFailed -> { + handleBeforeInstallationRequest(promptRequest) + consumePromptRequest() + } + } + } + } + tryToReAttachButtonHandlersToPreviousDialog() + } + + private fun handleAfterInstallationRequest(promptRequest: WebExtensionPromptRequest.AfterInstallation) { + // The install flow in Fenix relies on an [Addon] object so let's convert the (GeckoView) + // extension into a minimal add-on. The missing metadata will be fetched when the user + // opens the add-ons manager. + val addon = Addon.newFromWebExtension(promptRequest.extension) + when (promptRequest) { + is WebExtensionPromptRequest.AfterInstallation.Permissions.Required -> handlePermissionRequest( + addon, + promptRequest, + ) + + is WebExtensionPromptRequest.AfterInstallation.PostInstallation -> handlePostInstallationRequest( + addon, + ) + + is WebExtensionPromptRequest.AfterInstallation.Permissions.Optional -> handleOptionalPermissionsRequest( + addon, + promptRequest, + ) + } + } + + private fun handlePostInstallationRequest( + addon: Addon, + ) { + showPostInstallationDialog(addon) + } + + private fun handlePermissionRequest( + addon: Addon, + promptRequest: WebExtensionPromptRequest.AfterInstallation.Permissions.Required, + ) { + if (hasExistingPermissionDialogFragment()) return + showPermissionDialog( + addon = addon, + promptRequest = promptRequest, + permissions = promptRequest.permissions, + ) + } + + private fun handleOptionalPermissionsRequest( + addon: Addon, + promptRequest: WebExtensionPromptRequest.AfterInstallation.Permissions.Optional, + ) { + val shouldGrantWithoutPrompt = Addon.localizePermissions(promptRequest.permissions, context).isEmpty() + + // If we don't have any promptable permissions, just proceed. + if (shouldGrantWithoutPrompt) { + promptRequest.onConfirm(true) + consumePromptRequest() + return + } + + showPermissionDialog( + // This is a bit of a hack so that the permission prompt only lists + // the optional permissions that are requested. + addon = addon.copy(permissions = promptRequest.permissions), + promptRequest = promptRequest, + permissions = promptRequest.permissions, + forOptionalPermissions = true, + ) + } + + private fun showPostInstallationDialog(addon: Addon) { + if (!isInstallationInProgress && !hasExistingAddonPostInstallationDialogFragment()) { + val dialog = AddonInstallationDialogFragment.newInstance( + addon = addon, + onDismissed = { + consumePromptRequest() + }, + onConfirmButtonClicked = { _ -> + consumePromptRequest() + }, + ) + dialog.show(fragmentManager, POST_INSTALLATION_DIALOG_FRAGMENT_TAG) + } + } + + /** + * Stops observing the selected session for incoming window requests. + */ + override fun stop() { + scope?.cancel() + } + + @VisibleForTesting + internal fun showPermissionDialog( + addon: Addon, + promptRequest: WebExtensionPromptRequest.AfterInstallation.Permissions, + permissions: List = emptyList(), + forOptionalPermissions: Boolean = false, + ) { + if (!isInstallationInProgress && !hasExistingPermissionDialogFragment()) { + val dialog = PermissionsDialogFragment.newInstance( + addon = addon, + permissions = permissions, + forOptionalPermissions = forOptionalPermissions, + onPositiveButtonClicked = { _, privateBrowsingAllowed -> + handlePermissions( + promptRequest, + granted = true, + privateBrowsingAllowed = privateBrowsingAllowed, + ) + }, + onNegativeButtonClicked = { + when (promptRequest) { + is WebExtensionPromptRequest.AfterInstallation.Permissions.Optional -> { + promptRequest.onConfirm(false) + } + + is WebExtensionPromptRequest.AfterInstallation.Permissions.Required -> { + promptRequest.onConfirm(PermissionPromptResponse(isPermissionsGranted = false)) + } + } + consumePromptRequest() + }, + ) + dialog.show( + fragmentManager, + PERMISSIONS_DIALOG_FRAGMENT_TAG, + ) + } + } + + private fun tryToReAttachButtonHandlersToPreviousDialog() { + findPreviousDialogFragment()?.let { dialog -> + dialog.onPositiveButtonClicked = { addon, privateBrowsingAllowed -> + store.state.webExtensionPromptRequest?.let { promptRequest -> + if (promptRequest is WebExtensionPromptRequest.AfterInstallation.Permissions && + addon.id == promptRequest.extension.id + ) { + handlePermissions( + promptRequest, + granted = true, + privateBrowsingAllowed = privateBrowsingAllowed, + ) + } + } + } + dialog.onNegativeButtonClicked = { + store.state.webExtensionPromptRequest?.let { promptRequest -> + handlePermissions( + promptRequest, + granted = false, + privateBrowsingAllowed = false, + ) + } + } + } + } + + private fun handlePermissions( + promptRequest: WebExtensionPromptRequest, + granted: Boolean, + privateBrowsingAllowed: Boolean, + ) { + when (promptRequest) { + is WebExtensionPromptRequest.AfterInstallation.Permissions.Optional -> { + promptRequest.onConfirm(granted) + } + + is WebExtensionPromptRequest.AfterInstallation.Permissions.Required -> { + val response = PermissionPromptResponse( + isPermissionsGranted = granted, + isPrivateModeGranted = privateBrowsingAllowed, + ) + promptRequest.onConfirm(response) + } + + is WebExtensionPromptRequest.AfterInstallation.PostInstallation -> { + // opt-out + } + + is WebExtensionPromptRequest.BeforeInstallation.InstallationFailed -> { + // opt-out + } + } + consumePromptRequest() + } + + private fun consumePromptRequest() { + store.dispatch(WebExtensionAction.ConsumePromptRequestWebExtensionAction) + } + + private fun hasExistingPermissionDialogFragment(): Boolean { + return findPreviousDialogFragment() != null + } + + private fun findPreviousDialogFragment(): PermissionsDialogFragment? { + return fragmentManager.findFragmentByTag(PERMISSIONS_DIALOG_FRAGMENT_TAG) as? PermissionsDialogFragment + } + + private fun hasExistingAddonPostInstallationDialogFragment(): Boolean { + return fragmentManager.findFragmentByTag(POST_INSTALLATION_DIALOG_FRAGMENT_TAG) + as? AddonInstallationDialogFragment != null + } + + private fun handleBeforeInstallationRequest(promptRequest: WebExtensionPromptRequest.BeforeInstallation) { + when (promptRequest) { + is WebExtensionPromptRequest.BeforeInstallation.InstallationFailed -> { + handleInstallationFailedRequest( + exception = promptRequest.exception, + ) + consumePromptRequest() + } + } + } + + @VisibleForTesting + internal fun handleInstallationFailedRequest( + exception: WebExtensionInstallException, + ) { + val addonName = exception.extensionName ?: "" + var title = context.getString(MozComp.string.mozac_feature_addons_cant_install_extension, "") + val message = when (exception) { + is WebExtensionInstallException.Blocklisted -> { + context.getString(MozComp.string.mozac_feature_addons_blocklisted_1, addonName) + } + + is WebExtensionInstallException.SoftBlocked -> { + context.getString(MozComp.string.mozac_feature_addons_soft_blocked, addonName) + } + + is WebExtensionInstallException.UserCancelled -> { + // We don't want to show an error message when users cancel installation. + return + } + + is WebExtensionInstallException.UnsupportedAddonType, + is WebExtensionInstallException.Unknown, + -> { + // Making sure we don't have a + // Title = Can't install extension + // Message = Failed to install $addonName + title = "" + if (addonName.isNotEmpty()) { + context.getString(MozComp.string.mozac_feature_addons_failed_to_install, addonName) + } else { + context.getString(MozComp.string.mozac_feature_addons_extension_failed_to_install) + } + } + + is WebExtensionInstallException.NetworkFailure -> { + context.getString(MozComp.string.mozac_feature_addons_extension_failed_to_install_network_error) + } + + is WebExtensionInstallException.CorruptFile -> { + context.getString(MozComp.string.mozac_feature_addons_extension_failed_to_install_corrupt_error) + } + + is WebExtensionInstallException.NotSigned -> { + context.getString( + MozComp.string.mozac_feature_addons_extension_failed_to_install_not_signed_error, + ) + } + + is WebExtensionInstallException.Incompatible -> { + val appName = "Lensai" + val version = context.appVersionName + context.getString( + MozComp.string.mozac_feature_addons_failed_to_install_incompatible_error, + addonName, + appName, + version, + ) + } + + is WebExtensionInstallException.AdminInstallOnly -> { + context.getString(MozComp.string.mozac_feature_addons_admin_install_only, addonName) + } + } + + showDialog( + title = title, + message = message, + ) + } + + @VisibleForTesting + internal fun showDialog( + title: String, + message: String, + ) { + context.let { + AlertDialog.Builder(it).setTitle(title) + .setPositiveButton(android.R.string.ok) { _, _ -> }.setCancelable(false).setMessage( + message, + ).show().withCenterAlignedButtons() + } + } + + companion object { + private const val PERMISSIONS_DIALOG_FRAGMENT_TAG = "ADDONS_PERMISSIONS_DIALOG_FRAGMENT" + private const val POST_INSTALLATION_DIALOG_FRAGMENT_TAG = + "ADDONS_INSTALLATION_DIALOG_FRAGMENT" + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/middleware/FlutterEventMiddleware.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/middleware/FlutterEventMiddleware.kt index 23e6acdd..353aa717 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/middleware/FlutterEventMiddleware.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/middleware/FlutterEventMiddleware.kt @@ -14,6 +14,7 @@ import mozilla.components.browser.state.action.ContentAction import mozilla.components.browser.state.action.LastAccessAction import mozilla.components.browser.state.action.ReaderAction import mozilla.components.browser.state.action.TabListAction +import mozilla.components.browser.state.action.WebExtensionAction import mozilla.components.browser.state.state.BrowserState import mozilla.components.feature.addons.logger import mozilla.components.lib.state.Middleware @@ -65,8 +66,11 @@ class FlutterEventMiddleware(private val flutterEvents: GeckoStateEvents) : Midd ) { _ -> } } } + is WebExtensionAction.UpdatePromptRequestWebExtensionAction -> { + logger.debug("Event fired: " + action.javaClass.name) + } else -> { - //logger.debug("Event fired: " + action.javaClass.name) + logger.debug("Event fired: " + action.javaClass.name) } } next(action) diff --git a/packages/flutter_mozilla_components/android/src/main/res/drawable/addon_textview_selector.xml b/packages/flutter_mozilla_components/android/src/main/res/drawable/addon_textview_selector.xml new file mode 100644 index 00000000..8e89011a --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/res/drawable/addon_textview_selector.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/res/drawable/mozac_ic_extensions_black.xml b/packages/flutter_mozilla_components/android/src/main/res/drawable/mozac_ic_extensions_black.xml new file mode 100644 index 00000000..673ffa98 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/res/drawable/mozac_ic_extensions_black.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/packages/flutter_mozilla_components/android/src/main/res/drawable/mozac_ic_permissions.xml b/packages/flutter_mozilla_components/android/src/main/res/drawable/mozac_ic_permissions.xml new file mode 100644 index 00000000..cf50e31d --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/res/drawable/mozac_ic_permissions.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_details.xml b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_details.xml new file mode 100644 index 00000000..012d8223 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_details.xml @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_main.xml b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_main.xml new file mode 100644 index 00000000..05ab3e02 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_main.xml @@ -0,0 +1,11 @@ + + + + diff --git a/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_permissions.xml b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_permissions.xml new file mode 100644 index 00000000..1bc20285 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_permissions.xml @@ -0,0 +1,32 @@ + + + + + + + + + + diff --git a/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_settings.xml b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_settings.xml new file mode 100644 index 00000000..f2a64eda --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_settings.xml @@ -0,0 +1,11 @@ + + + + diff --git a/packages/flutter_mozilla_components/android/src/main/res/layout/activity_installed_add_on_details.xml b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_installed_add_on_details.xml new file mode 100644 index 00000000..2e4db542 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_installed_add_on_details.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + +