new extension management

This commit is contained in:
Fabian Freund
2026-04-17 19:09:27 +02:00
parent 8217472f59
commit f9bcd076f2
49 changed files with 7827 additions and 4533 deletions
@@ -0,0 +1,104 @@
/*
* 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 https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components
import android.app.Activity
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.fragment.app.FragmentActivity
import eu.weblibre.flutter_mozilla_components.addons.FlutterAddonSettingsFragment
import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory
private const val OPTIONS_PAGE_URL_KEY = "optionsPageUrl"
class AddonSettingsViewFactory(
private val activityProvider: () -> Activity?,
) : PlatformViewFactory(StandardMessageCodec.INSTANCE) {
override fun create(context: Context?, id: Int, args: Any?): PlatformView {
val activity = activityProvider()
?: throw IllegalStateException("No activity available when creating AddonSettingsView")
val optionsPageUrl = (args as? Map<*, *>)?.get(OPTIONS_PAGE_URL_KEY) as? String
?: throw IllegalArgumentException("Missing optionsPageUrl creation param")
return NativeAddonSettingsView(activity, optionsPageUrl)
}
}
private class NativeAddonSettingsView(
activity: Activity,
private val optionsPageUrl: String,
) : PlatformView {
private val fragmentActivity = activity as? FragmentActivity
?: throw IllegalStateException("Addon settings view requires a FragmentActivity host")
private val containerId = View.generateViewId()
private val fragmentTag = "addon_settings_$containerId"
private val container: FrameLayout = FrameLayout(activity).apply {
id = containerId
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
}
private val attachStateListener = object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(view: View) {
container.removeOnAttachStateChangeListener(this)
container.post { attachFragment() }
}
override fun onViewDetachedFromWindow(view: View) = Unit
}
override fun onFlutterViewAttached(flutterView: View) {
super.onFlutterViewAttached(flutterView)
if (container.isAttachedToWindow) {
container.post { attachFragment() }
} else {
container.removeOnAttachStateChangeListener(attachStateListener)
container.addOnAttachStateChangeListener(attachStateListener)
}
}
override fun getView(): View = container
override fun dispose() {
val fm = fragmentActivity.supportFragmentManager
if (!fragmentActivity.isFinishing && !fragmentActivity.isDestroyed && !fm.isStateSaved) {
fm.findFragmentByTag(fragmentTag)?.let { fragment ->
fm.beginTransaction().remove(fragment).commitNowAllowingStateLoss()
}
}
}
private fun attachFragment() {
if (fragmentActivity.isFinishing || fragmentActivity.isDestroyed) {
return
}
val fm = fragmentActivity.supportFragmentManager
if (fm.isStateSaved) {
return
}
if (fragmentActivity.findViewById<View>(containerId) == null) {
return
}
val existingFragment = fm.findFragmentByTag(fragmentTag)
if (existingFragment is FlutterAddonSettingsFragment) {
return
}
fm.beginTransaction()
.replace(containerId, FlutterAddonSettingsFragment.create(optionsPageUrl), fragmentTag)
.commitNow()
}
}
@@ -0,0 +1,104 @@
/*
* 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 https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components
import android.app.Activity
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.fragment.app.FragmentActivity
import eu.weblibre.flutter_mozilla_components.addons.FlutterAddonPopupFragment
import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory
private const val EXTENSION_ID_KEY = "extensionId"
class AddonPopupViewFactory(
private val activityProvider: () -> Activity?,
) : PlatformViewFactory(StandardMessageCodec.INSTANCE) {
override fun create(context: Context?, id: Int, args: Any?): PlatformView {
val activity = activityProvider()
?: throw IllegalStateException("No activity available when creating AddonPopupView")
val extensionId = (args as? Map<*, *>)?.get(EXTENSION_ID_KEY) as? String
?: throw IllegalArgumentException("Missing extensionId creation param")
return NativeAddonPopupView(activity, extensionId)
}
}
private class NativeAddonPopupView(
activity: Activity,
private val extensionId: String,
) : PlatformView {
private val fragmentActivity = activity as? FragmentActivity
?: throw IllegalStateException("Addon popup view requires a FragmentActivity host")
private val containerId = View.generateViewId()
private val fragmentTag = "addon_popup_$containerId"
private val container: FrameLayout = FrameLayout(activity).apply {
id = containerId
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
}
private val attachStateListener = object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(view: View) {
container.removeOnAttachStateChangeListener(this)
container.post { attachFragment() }
}
override fun onViewDetachedFromWindow(view: View) = Unit
}
override fun onFlutterViewAttached(flutterView: View) {
super.onFlutterViewAttached(flutterView)
if (container.isAttachedToWindow) {
container.post { attachFragment() }
} else {
container.removeOnAttachStateChangeListener(attachStateListener)
container.addOnAttachStateChangeListener(attachStateListener)
}
}
override fun getView(): View = container
override fun dispose() {
val fm = fragmentActivity.supportFragmentManager
if (!fragmentActivity.isFinishing && !fragmentActivity.isDestroyed && !fm.isStateSaved) {
fm.findFragmentByTag(fragmentTag)?.let { fragment ->
fm.beginTransaction().remove(fragment).commitNowAllowingStateLoss()
}
}
}
private fun attachFragment() {
if (fragmentActivity.isFinishing || fragmentActivity.isDestroyed) {
return
}
val fm = fragmentActivity.supportFragmentManager
if (fm.isStateSaved) {
return
}
if (fragmentActivity.findViewById<View>(containerId) == null) {
return
}
val existingFragment = fm.findFragmentByTag(fragmentTag)
if (existingFragment is FlutterAddonPopupFragment) {
return
}
fm.beginTransaction()
.replace(containerId, FlutterAddonPopupFragment.create(extensionId), fragmentTag)
.commitNow()
}
}
@@ -22,7 +22,6 @@ import androidx.annotation.CallSuper
import androidx.core.content.edit
import androidx.fragment.app.Fragment
import androidx.preference.PreferenceManager
import eu.weblibre.flutter_mozilla_components.addons.WebExtensionActionPopupActivity
import eu.weblibre.flutter_mozilla_components.addons.WebExtensionPromptFeature
import eu.weblibre.flutter_mozilla_components.databinding.FragmentBrowserBinding
import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey
@@ -555,14 +554,10 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
protected open fun onEngineSetupComplete() {}
private fun openPopup(webExtensionState: WebExtensionState) {
val intent = Intent(
components.profileApplicationContext,
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)
components.addonEvents.onWebExtensionPopupRequested(
webExtensionState.id,
webExtensionState.name ?: "",
) {}
}
@CallSuper
@@ -21,6 +21,7 @@ import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTabContentEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoViewportEvents
import eu.weblibre.flutter_mozilla_components.pigeons.QueryParameterStripping
import eu.weblibre.flutter_mozilla_components.pigeons.ReaderViewController
import eu.weblibre.flutter_mozilla_components.addons.AddonPrefs
import eu.weblibre.flutter_mozilla_components.api.GeckoViewportApiImpl
import eu.weblibre.flutter_mozilla_components.api.GeckoEngineSettingsApiImpl
import eu.weblibre.flutter_mozilla_components.feature.DefaultSelectionActionDelegate
@@ -49,7 +50,7 @@ import java.util.concurrent.TimeUnit
private const val HISTORY_METADATA_MAX_AGE_IN_MS = 14L * 24 * 60 * 60 * 1000 // 14 days
private const val DEFAULT_QUERY_PARAMETER_STRIPPING_STRIP_LIST =
"__hsfp __hssc __hstc __s _bhlid _branch_match_id _branch_referrer _gl _hsenc _kx _openstat at_recipient_id at_recipient_list bbeml bsft_clkid bsft_uid dclid et_rid fb_action_ids fb_comment_id fbclid gbraid gclid guce_referrer guce_referrer_sig hsCtaTracking igshid irclickid mc_eid mkt_tok ml_subscriber ml_subscriber_hash msclkid mtm_cid oft_c oft_ck oft_d oft_id oft_ids oft_k oft_lk oft_sk oly_anon_id oly_enc_id pk_cid rb_clickid s_cid sc_customer sc_eh sc_uid sms_click sms_source sms_uph srsltid ss_email_id syclid ttclid twclid unicorn_click_id vero_conv vero_id vgo_ee wbraid wickedid yclid ymclid ysclid"
object GlobalComponents {
private var _components: Components? = null
private var currentMode: ComponentsMode? = null
@@ -268,7 +269,23 @@ object GlobalComponents {
},
onUpdatePermissionRequest = newComponents.core.addonUpdater::onUpdatePermissionRequest,
onExtensionsLoaded = { extensions ->
newComponents.core.addonUpdater.registerForFutureUpdates(extensions)
val addonPrefs = AddonPrefs.get(applicationContext)
val autoUpdateEnabled =
addonPrefs.getBoolean(AddonPrefs.PREF_AUTO_UPDATE_ENABLED, true)
val autoUpdateDisabledAddonIds =
addonPrefs.getStringSet(AddonPrefs.PREF_AUTO_UPDATE_DISABLED_IDS, emptySet())
?: emptySet()
val localFileAddonIds =
addonPrefs.getStringSet(AddonPrefs.PREF_LOCAL_FILE_ADDON_IDS, emptySet())
?: emptySet()
if (autoUpdateEnabled) {
newComponents.core.addonUpdater.registerForFutureUpdates(
extensions.filterNot { extension ->
autoUpdateDisabledAddonIds.contains(extension.id) ||
localFileAddonIds.contains(extension.id)
},
)
}
newComponents.core.supportedAddonsChecker.registerForChecks()
},
)
@@ -1,107 +0,0 @@
/* 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.weblibre.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.weblibre.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<RatingBar>(R.id.rating_view)
val userCountView = findViewById<TextView>(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<View>(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<TextView>(R.id.last_updated_text)
lastUpdatedView.text = formatDate(addon.updatedAt)
}
private fun bindVersion(addon: Addon) {
val versionView = findViewById<TextView>(R.id.version_text)
versionView.text = addon.version
}
private fun bindAuthor(addon: Addon) {
val authorsView = findViewById<TextView>(R.id.author_text)
authorsView.text = addon.author?.name.orEmpty()
}
private fun bindDetails(addon: Addon) {
val detailsView = findViewById<TextView>(R.id.details)
val detailsText = addon.translateDescription(this)
val parsedText = detailsText.replace("\n", "<br/>")
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)!!)
}
}
@@ -1,110 +0,0 @@
/* 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.weblibre.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.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.R
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
/**
* An activity to show the internal settings of an add-on with [EngineView].
*
* Used when the addon's manifest specifies `openOptionsPageInTab = false`,
* rendering the settings page inside an [AddonPopupBaseFragment] with proper
* prompt and download support.
*/
class AddonInternalSettingsActivity : AppCompatActivity() {
private val components by lazy {
requireNotNull(GlobalComponents.components) { "Components not initialized" }
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_add_on_settings)
val addon = requireNotNull(
intent.getParcelableExtra<Addon>("add_on"),
)
title = addon.translateName(this)
val fragment = AddonInternalSettingsFragment.create(addon)
supportFragmentManager
.beginTransaction()
.replace(R.id.addonSettingsContainer, fragment)
.commit()
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (!fragment.onBackPressed()) {
finish()
}
}
})
}
override fun onSupportNavigateUp(): Boolean {
onBackPressedDispatcher.onBackPressed()
return true
}
override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet): View? =
when (name) {
EngineView::class.java.name -> components.core.engine.createView(context, attrs).asView()
else -> super.onCreateView(parent, name, context, attrs)
}
/**
* A fragment to show the internal settings of an add-on with [EngineView].
*
* Creates a fresh engine session and loads the addon's options page URL into it.
*/
class AddonInternalSettingsFragment : AddonPopupBaseFragment() {
private val addonSettingsEngineView: EngineView
get() = requireView().findViewById<View>(R.id.addonSettingsEngineView) as EngineView
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
initializeSession()
return inflater.inflate(R.layout.fragment_add_on_settings, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val optionsPageUrl = arguments?.getParcelableCompat("add_on", Addon::class.java)
?.installedState?.optionsPageUrl
if (optionsPageUrl != null) {
engineSession?.let { session ->
addonSettingsEngineView.render(session)
session.loadUrl(optionsPageUrl)
}
} else {
activity?.finish()
}
}
companion object {
fun create(addon: Addon) = AddonInternalSettingsFragment().apply {
arguments = Bundle().apply {
putParcelable("add_on", addon)
}
}
}
}
}
@@ -0,0 +1,22 @@
/*
* 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 https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.addons
import android.content.Context
import android.content.SharedPreferences
object AddonPrefs {
const val PREFS_NAME = "addon_prefs"
const val PREF_AUTO_UPDATE_ENABLED = "addon_auto_update_enabled"
// Legacy key name — kept to preserve previously pinned local-install preferences.
const val PREF_AUTO_UPDATE_DISABLED_IDS = "pinned_local_addon_ids"
const val PREF_LOCAL_FILE_ADDON_IDS = "local_file_addon_ids"
const val PREF_MANUAL_UPDATE_ATTEMPT_PREFIX = "manual_addon_update_attempt."
fun get(context: Context): SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
@@ -1,36 +0,0 @@
/* 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.weblibre.flutter_mozilla_components.addons
import android.os.Bundle
import androidx.activity.addCallback
import androidx.appcompat.app.AppCompatActivity
import eu.weblibre.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)
onBackPressedDispatcher.addCallback(this) {
finishAndRemoveTask()
}
if (savedInstanceState == null) {
supportFragmentManager.beginTransaction().apply {
replace(R.id.container, AddonsFragment())
commit()
}
}
}
override fun onSupportNavigateUp(): Boolean {
onBackPressedDispatcher.onBackPressed()
return true
}
}
@@ -1,155 +0,0 @@
/* 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.weblibre.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.weblibre.flutter_mozilla_components.Components
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.ProfileContext
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.weblibre.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 by lazy {
requireNotNull(GlobalComponents.components) { "Components not initialized" }
}
private val webExtensionPromptFeature = ViewBoundFeatureWrapper<WebExtensionPromptFeature>()
private lateinit var recyclerView: RecyclerView
private val scope = CoroutineScope(Dispatchers.IO)
private lateinit var addons: List<Addon>
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.core.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) {
val profileContext = ProfileContext(requireContext(), components.profileApplicationContext.relativePath)
recyclerView = rootView.findViewById(R.id.add_ons_list)
recyclerView.layoutManager = LinearLayoutManager(profileContext)
scope.launch {
try {
addons = components.core.addonManager.getAddons()
scope.launch(Dispatchers.Main) {
adapter = AddonsManagerAdapter(
this@AddonsFragment,
addons,
store = components.core.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.core.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
}
@@ -0,0 +1,106 @@
/* 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.weblibre.flutter_mozilla_components.addons
import android.os.Bundle
import android.view.View
import android.widget.FrameLayout
import eu.weblibre.flutter_mozilla_components.ProfileContext
import mozilla.components.browser.state.action.WebExtensionAction
import mozilla.components.concept.engine.EngineSession
import mozilla.components.concept.engine.EngineView
import mozilla.components.lib.state.ext.consumeFrom
import mozilla.components.support.locale.ActivityContextWrapper
class FlutterAddonPopupFragment : AddonPopupBaseFragment(), EngineSession.Observer {
private var addonPopupEngineView: EngineView? = null
private var sessionConsumed
get() = arguments?.getBoolean("isSessionConsumed", false) ?: false
set(value) {
arguments?.putBoolean("isSessionConsumed", value)
}
override fun onCreateView(
inflater: android.view.LayoutInflater,
container: android.view.ViewGroup?,
savedInstanceState: Bundle?,
): View {
val extensionId = requireNotNull(arguments?.getString(ARG_EXTENSION_ID))
components.core.store.state.extensions[extensionId]?.popupSession?.let {
initializeSession(it)
}
val profileContext = ProfileContext(
requireContext(),
components.profileApplicationContext.relativePath,
)
val engineView = components.core.engine.createView(profileContext, null)
addonPopupEngineView = engineView
val originalContext =
ActivityContextWrapper.getOriginalContext(requireActivity()) ?: requireActivity()
engineView.setActivityContext(originalContext)
val root = FrameLayout(profileContext)
val nativeView = engineView.asView()
nativeView.layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
)
root.addView(nativeView)
return root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val extensionId = requireNotNull(arguments?.getString(ARG_EXTENSION_ID))
val currentSession = engineSession
if (currentSession != null) {
addonPopupEngineView?.render(currentSession)
consumePopupSession(extensionId)
} else {
consumeFrom(components.core.store) { state ->
state.extensions[extensionId]?.let { extState ->
val popupSession = extState.popupSession
if (popupSession != null) {
initializeSession(popupSession)
addonPopupEngineView?.render(popupSession)
popupSession.register(this)
consumePopupSession(extensionId)
engineSession = popupSession
} else if (sessionConsumed) {
activity?.onBackPressedDispatcher?.onBackPressed()
}
}
}
}
}
override fun onDestroyView() {
addonPopupEngineView?.setActivityContext(null)
addonPopupEngineView = null
super.onDestroyView()
}
private fun consumePopupSession(extensionId: String) {
components.core.store.dispatch(
WebExtensionAction.UpdatePopupSessionAction(extensionId, popupSession = null),
)
sessionConsumed = true
}
companion object {
private const val ARG_EXTENSION_ID = "extension_id"
fun create(extensionId: String) = FlutterAddonPopupFragment().apply {
arguments = Bundle().apply {
putString(ARG_EXTENSION_ID, extensionId)
}
}
}
}
@@ -0,0 +1,75 @@
/* 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.weblibre.flutter_mozilla_components.addons
import android.os.Bundle
import android.view.View
import android.widget.FrameLayout
import eu.weblibre.flutter_mozilla_components.ProfileContext
import mozilla.components.concept.engine.EngineView
import mozilla.components.support.locale.ActivityContextWrapper
class FlutterAddonSettingsFragment : AddonPopupBaseFragment() {
private var addonSettingsEngineView: EngineView? = null
override fun onCreateView(
inflater: android.view.LayoutInflater,
container: android.view.ViewGroup?,
savedInstanceState: Bundle?,
): View {
initializeSession()
val profileContext = ProfileContext(
requireContext(),
components.profileApplicationContext.relativePath,
)
val engineView = components.core.engine.createView(profileContext, null)
addonSettingsEngineView = engineView
val originalContext =
ActivityContextWrapper.getOriginalContext(requireActivity()) ?: requireActivity()
engineView.setActivityContext(originalContext)
val root = FrameLayout(profileContext)
val nativeView = engineView.asView()
nativeView.layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
)
root.addView(nativeView)
return root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val optionsPageUrl = arguments?.getString(ARG_OPTIONS_PAGE_URL)
if (optionsPageUrl != null) {
engineSession?.let { session ->
addonSettingsEngineView?.render(session)
session.loadUrl(optionsPageUrl)
}
} else {
activity?.onBackPressedDispatcher?.onBackPressed()
}
}
override fun onDestroyView() {
addonSettingsEngineView?.setActivityContext(null)
addonSettingsEngineView = null
super.onDestroyView()
}
companion object {
private const val ARG_OPTIONS_PAGE_URL = "options_page_url"
fun create(optionsPageUrl: String) = FlutterAddonSettingsFragment().apply {
arguments = Bundle().apply {
putString(ARG_OPTIONS_PAGE_URL, optionsPageUrl)
}
}
}
}
@@ -1,221 +0,0 @@
/* 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.weblibre.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.weblibre.flutter_mozilla_components.Components
import eu.weblibre.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.weblibre.flutter_mozilla_components.R
/**
* An activity to show the details of a installed add-on.
*/
class InstalledAddonDetailsActivity : AppCompatActivity() {
private val components by lazy {
requireNotNull(GlobalComponents.components) { "Components not initialized" }
}
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)
}
override fun onSupportNavigateUp(): Boolean {
onBackPressedDispatcher.onBackPressed()
return true
}
private fun bindAddon(addon: Addon) {
scope.launch {
try {
val addons = components.core.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,
R.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<SwitchCompat>(R.id.enable_switch)
switch.setState(addon.isEnabled())
switch.setOnCheckedChangeListener { _, isChecked ->
if (isChecked) {
components.core.addonManager.enableAddon(
addon,
onSuccess = {
switch.setState(true)
Toast.makeText(
this,
getString(R.string.mozac_feature_addons_successfully_enabled, addon.translateName(this)),
Toast.LENGTH_SHORT,
).show()
},
onError = {
Toast.makeText(
this,
getString(R.string.mozac_feature_addons_failed_to_enable, addon.translateName(this)),
Toast.LENGTH_SHORT,
).show()
},
)
} else {
components.core.addonManager.disableAddon(
addon,
onSuccess = {
switch.setState(false)
Toast.makeText(
this,
getString(R.string.mozac_feature_addons_successfully_disabled, addon.translateName(this)),
Toast.LENGTH_SHORT,
).show()
},
onError = {
Toast.makeText(
this,
getString(R.string.mozac_feature_addons_failed_to_disable, addon.translateName(this)),
Toast.LENGTH_SHORT,
).show()
},
)
}
}
}
private fun bindSettings(addon: Addon) {
val view = findViewById<View>(R.id.settings)
view.isVisible = shouldSettingsBeVisible(addon)
view.isEnabled = shouldSettingsBeVisible(addon)
view.setOnClickListener {
val optionsPageUrl = addon.installedState?.optionsPageUrl ?: return@setOnClickListener
if (addon.installedState?.openOptionsPageInTab == true) {
// Open settings in a browser tab, reusing an existing tab if already open.
components.useCases.tabsUseCases.selectOrAddTab(
url = optionsPageUrl,
ignoreFragment = true,
)
val intent = packageManager.getLaunchIntentForPackage(packageName)
intent?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
startActivity(intent)
} else {
// Open settings in an internal view with proper extension API support.
val intent = Intent(this, AddonInternalSettingsActivity::class.java)
intent.putExtra("add_on", addon)
startActivity(intent)
}
}
}
private fun bindDetails(addon: Addon) {
findViewById<View>(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<View>(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<SwitchCompat>(R.id.allow_in_private_browsing_switch)
switch.isChecked = addon.isAllowedInPrivateBrowsing()
switch.setOnCheckedChangeListener { _, isChecked ->
components.core.addonManager.setAddonAllowedInPrivateBrowsing(
addon,
isChecked,
onSuccess = {
switch.isChecked = isChecked
},
)
}
}
private fun bindRemoveButton(addon: Addon) {
findViewById<View>(R.id.remove_add_on).setOnClickListener {
components.core.addonManager.uninstallAddon(
addon,
onSuccess = {
Toast.makeText(
this,
getString(R.string.mozac_feature_addons_successfully_uninstalled, addon.translateName(this)),
Toast.LENGTH_SHORT,
).show()
finish()
},
onError = { _, _ ->
Toast.makeText(
this,
getString(R.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()
}
@@ -1,106 +0,0 @@
/* 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.weblibre.flutter_mozilla_components.addons
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.activity.SystemBarStyle
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.net.toUri
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.ktx.android.view.setupPersistentInsets
import mozilla.components.support.utils.ext.getParcelableExtraCompat
import eu.weblibre.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?) {
enableEdgeToEdge(SystemBarStyle.dark(Color.TRANSPARENT))
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_add_on_permissions)
window.setupPersistentInsets()
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<RecyclerView>(R.id.add_ons_permissions)
recyclerView.layoutManager = LinearLayoutManager(this)
val sortedPermissions = addon.translatePermissions(this).sorted()
recyclerView.adapter = PermissionsAdapter(sortedPermissions)
}
private fun bindLearnMore() {
findViewById<View>(R.id.learn_more_label).setOnClickListener(this)
}
/**
* An adapter for displaying the permissions of an add-on.
*/
class PermissionsAdapter(
private val permissions: List<String>,
) : RecyclerView.Adapter<PermissionViewHolder>() {
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<TextView>(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(LEARN_MORE_URL.toUri())
startActivity(intent)
}
}
@@ -7,102 +7,767 @@
package eu.weblibre.flutter_mozilla_components.api
import android.content.Context
import android.content.Intent
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.addons.AddonInternalSettingsActivity
import eu.weblibre.flutter_mozilla_components.addons.AddonsActivity
import eu.weblibre.flutter_mozilla_components.addons.AddonPrefs
import eu.weblibre.flutter_mozilla_components.ext.toWebPBytes
import eu.weblibre.flutter_mozilla_components.pigeons.AddonDisabledReason
import eu.weblibre.flutter_mozilla_components.pigeons.AddonIncognito
import eu.weblibre.flutter_mozilla_components.pigeons.AddonInfo
import eu.weblibre.flutter_mozilla_components.pigeons.AddonStoreInfo
import eu.weblibre.flutter_mozilla_components.pigeons.AddonUpdateAttemptInfo
import eu.weblibre.flutter_mozilla_components.pigeons.AddonUpdateStatus
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonsApi
import eu.weblibre.flutter_mozilla_components.pigeons.WebExtensionActionType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mozilla.components.concept.fetch.MutableHeaders
import mozilla.components.concept.fetch.Request
import mozilla.components.concept.engine.webextension.InstallationMethod
import mozilla.components.concept.engine.webextension.WebExtensionInstallException
import mozilla.components.feature.addons.Addon
import mozilla.components.feature.addons.update.AddonUpdater
import mozilla.components.feature.addons.update.DefaultAddonUpdater
import mozilla.components.feature.addons.ui.displayName
import mozilla.components.feature.addons.ui.summary
import mozilla.components.feature.addons.ui.translateDescription
import org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_POSTPONED
import org.json.JSONObject
class GeckoAddonsApiImpl(private val context: Context) : GeckoAddonsApi {
private val components by lazy {
requireNotNull(GlobalComponents.components) { "Components not initialized" }
}
private val scope = CoroutineScope(Dispatchers.IO)
override fun startAddonManagerActivity() {
val intent = Intent(context, AddonsActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(intent)
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val updateAttemptStorage by lazy {
DefaultAddonUpdater.UpdateAttemptStorage(context.applicationContext)
}
private val prefs by lazy {
AddonPrefs.get(context.applicationContext)
}
override fun startAddonSettingsActivity(extensionId: String) {
companion object {
private const val LOCAL_ADDON_UPDATE_SOURCE_MISSING_MESSAGE =
"No remote update source is available for this locally installed extension."
private const val LOCAL_ADDON_UPDATE_POSTPONED_MESSAGE =
"Update downloaded and will be applied after restarting the app."
private const val DEFAULT_AMO_SERVER_URL = "https://addons.mozilla.org"
private const val PERIODIC_UPDATE_RESTORE_DELAY_MS = 10_000L
}
override fun getAddons(allowCache: Boolean, callback: (Result<List<AddonInfo>>) -> Unit) {
scope.launch {
val addon = runCatching {
components.core.addonManager.getAddons()
.find { it.id == extensionId }
}.getOrNull()
if (addon == null) {
withContext(Dispatchers.Main) {
startAddonManagerActivity()
}
return@launch
}
val optionsPageUrl = addon.installedState?.optionsPageUrl
if (optionsPageUrl.isNullOrEmpty()) {
withContext(Dispatchers.Main) {
startAddonManagerActivity()
}
return@launch
}
withContext(Dispatchers.Main) {
if (addon.installedState?.openOptionsPageInTab == true) {
components.useCases.tabsUseCases.selectOrAddTab(
url = optionsPageUrl,
ignoreFragment = true,
)
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
launchIntent?.addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_SINGLE_TOP,
)
if (launchIntent != null) {
context.startActivity(launchIntent)
} else {
startAddonManagerActivity()
runCatching {
components.core.addonManager.getAddons(allowCache = allowCache)
.map { addon ->
addon.toPigeon(
context = context,
isAutoUpdateEnabled = isAutoUpdateEffectivelyEnabledForAddon(addon.id),
isLocalFileInstalled = isLocalFileInstalledAddon(addon.id),
)
}
} else {
val intent = Intent(context, AddonInternalSettingsActivity::class.java)
intent.putExtra("add_on", addon)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(intent)
}
}
}.fold(
onSuccess = { callback(Result.success(it)) },
onFailure = { callback(Result.failure(it)) },
)
}
}
override fun getAddonById(
addonId: String,
allowCache: Boolean,
callback: (Result<AddonInfo?>) -> Unit,
) {
scope.launch {
runCatching {
val installedAddon = components.core.addonManager.getAddonByID(addonId)
(installedAddon ?: components.core.addonManager.getAddons(allowCache = allowCache)
.find { it.id == addonId })?.toPigeon(
context = context,
isAutoUpdateEnabled = isAutoUpdateEffectivelyEnabledForAddon(addonId),
isLocalFileInstalled = isLocalFileInstalledAddon(addonId),
)
}.fold(
onSuccess = { callback(Result.success(it)) },
onFailure = { callback(Result.failure(it)) },
)
}
}
override fun getAddonStoreInfo(addonId: String, callback: (Result<AddonStoreInfo?>) -> Unit) {
scope.launch {
runCatching {
fetchAddonStoreInfo(addonId)
}.fold(
onSuccess = { callback(Result.success(it)) },
onFailure = { callback(Result.failure(it)) },
)
}
}
override fun invokeAddonAction(extensionId: String, actionType: WebExtensionActionType) {
when(actionType) {
WebExtensionActionType.BROWSER -> components.features.webExtensionToolbarFeature.invokeAddonBrowserAction(extensionId)
WebExtensionActionType.PAGE -> components.features.webExtensionToolbarFeature.invokeAddonPageAction(extensionId)
scope.launch {
withContext(Dispatchers.Main.immediate) {
when(actionType) {
WebExtensionActionType.BROWSER -> components.features.webExtensionToolbarFeature.invokeAddonBrowserAction(extensionId)
WebExtensionActionType.PAGE -> components.features.webExtensionToolbarFeature.invokeAddonPageAction(extensionId)
}
}
}
}
override fun enableAddon(addonId: String, callback: (Result<AddonInfo>) -> Unit) {
withInstalledAddon(addonId, callback) { addon, result ->
components.core.addonManager.enableAddon(
addon,
onSuccess = { updatedAddon ->
result(
Result.success(
updatedAddon.toPigeon(
context = context,
isAutoUpdateEnabled = isAutoUpdateEffectivelyEnabledForAddon(updatedAddon.id),
isLocalFileInstalled = isLocalFileInstalledAddon(updatedAddon.id),
),
),
)
},
onError = { throwable ->
result(Result.failure(throwable))
},
)
}
}
override fun disableAddon(addonId: String, callback: (Result<AddonInfo>) -> Unit) {
withInstalledAddon(addonId, callback) { addon, result ->
components.core.addonManager.disableAddon(
addon,
onSuccess = { updatedAddon ->
result(
Result.success(
updatedAddon.toPigeon(
context = context,
isAutoUpdateEnabled = isAutoUpdateEffectivelyEnabledForAddon(updatedAddon.id),
isLocalFileInstalled = isLocalFileInstalledAddon(updatedAddon.id),
),
),
)
},
onError = { throwable ->
result(Result.failure(throwable))
},
)
}
}
override fun setAddonAllowedInPrivateBrowsing(
addonId: String,
allowed: Boolean,
callback: (Result<AddonInfo>) -> Unit,
) {
withInstalledAddon(addonId, callback) { addon, result ->
components.core.addonManager.setAddonAllowedInPrivateBrowsing(
addon,
allowed,
onSuccess = { updatedAddon ->
result(
Result.success(
updatedAddon.toPigeon(
context = context,
isAutoUpdateEnabled = isAutoUpdateEffectivelyEnabledForAddon(updatedAddon.id),
isLocalFileInstalled = isLocalFileInstalledAddon(updatedAddon.id),
),
),
)
},
onError = { throwable ->
result(Result.failure(throwable))
},
)
}
}
override fun setAddonAutoUpdateEnabledForAddon(
addonId: String,
enabled: Boolean,
callback: (Result<AddonInfo>) -> Unit,
) {
withInstalledAddon(addonId, callback) { addon, result ->
if (enabled && isLocalFileInstalledAddon(addon.id)) {
result(
Result.success(
addon.toPigeon(
context = context,
isAutoUpdateEnabled = false,
isLocalFileInstalled = true,
),
),
)
return@withInstalledAddon
}
setAddonAutoUpdateEnabledForAddonInternal(addon.id, enabled)
result(
Result.success(
addon.toPigeon(
context = context,
isAutoUpdateEnabled = isAutoUpdateEffectivelyEnabledForAddon(addon.id),
isLocalFileInstalled = isLocalFileInstalledAddon(addon.id),
),
),
)
}
}
override fun uninstallAddon(addonId: String, callback: (Result<Unit>) -> Unit) {
withInstalledAddon(addonId, callback) { addon, result ->
components.core.addonManager.uninstallAddon(
addon,
onSuccess = {
clearAddonAutoUpdatePreference(addon.id)
clearLocalFileInstalledAddon(addon.id)
clearManualUpdateAttempt(addon.id)
result(Result.success(Unit))
},
onError = { _, throwable ->
result(Result.failure(throwable))
},
)
}
}
override fun triggerAddonUpdate(
addonId: String,
callback: (Result<AddonUpdateAttemptInfo?>) -> Unit,
) {
if (isLocalFileInstalledAddon(addonId)) {
runLocalFileAddonUpdate(addonId, callback)
return
}
scope.launch {
try {
withContext(Dispatchers.Main.immediate) {
runManagedAddonUpdate(addonId) { attempt ->
callback(Result.success(attempt))
}
}
} catch (throwable: Throwable) {
callback(Result.failure(throwable))
}
}
}
override fun triggerAllAddonUpdates(callback: (Result<Unit>) -> Unit) {
scope.launch {
try {
val addons = components.core.addonManager.getAddons()
.filter { it.isInstalled() && it.isSupported() }
.filter { addon -> isAddonAutoUpdateEnabledForAddon(addon.id) }
.filterNot { addon -> isLocalFileInstalledAddon(addon.id) }
withContext(Dispatchers.Main.immediate) {
addons.forEach { addon ->
scheduleManagedAddonUpdate(addon.id)
}
}
callback(Result.success(Unit))
} catch (throwable: Throwable) {
callback(Result.failure(throwable))
}
}
}
override fun getLastAddonUpdateAttempt(
addonId: String,
callback: (Result<AddonUpdateAttemptInfo?>) -> Unit,
) {
scope.launch {
runCatching {
val updaterAttempt = updateAttemptStorage.findUpdateAttemptBy(addonId)?.toPigeon()
val manualAttempt = getManualUpdateAttempt(addonId)
listOfNotNull(updaterAttempt, manualAttempt)
.maxByOrNull { it.dateMillisecondsSinceEpoch }
}.fold(
onSuccess = { callback(Result.success(it)) },
onFailure = { callback(Result.failure(it)) },
)
}
}
override fun isAddonAutoUpdateEnabled(callback: (Result<Boolean>) -> Unit) {
callback(Result.success(prefs.getBoolean(AddonPrefs.PREF_AUTO_UPDATE_ENABLED, true)))
}
override fun setAddonAutoUpdateEnabled(enabled: Boolean, callback: (Result<Unit>) -> Unit) {
scope.launch {
try {
prefs.edit().putBoolean(AddonPrefs.PREF_AUTO_UPDATE_ENABLED, enabled).apply()
val addons = components.core.addonManager.getAddons()
.filter { it.isInstalled() && it.isSupported() }
withContext(Dispatchers.Main.immediate) {
if (enabled) {
addons.forEach { addon ->
updateAddonAutoUpdateRegistration(addon.id)
}
} else {
addons.forEach { addon ->
components.core.addonUpdater.unregisterForFutureUpdates(addon.id)
}
}
}
callback(Result.success(Unit))
} catch (throwable: Throwable) {
callback(Result.failure(throwable))
}
}
}
override fun installAddon(url: String, callback: (Result<Unit>) -> Unit) {
val installMethod = if (url.startsWith("file://")) {
val isLocalFileInstall = url.startsWith("file://")
val installMethod = if (isLocalFileInstall) {
InstallationMethod.FROM_FILE
} else {
null
}
components.core.addonManager.installAddon(
url = url,
installationMethod = installMethod,
onSuccess = { _ ->
callback(Result.success(Unit))
},
onError = { e ->
callback(Result.failure(e))
scope.launch {
try {
withContext(Dispatchers.Main.immediate) {
performAddonInstall(url, isLocalFileInstall, callback)
}
} catch (throwable: Throwable) {
callback(Result.failure(throwable))
}
}
}
private fun <T> withInstalledAddon(
addonId: String,
callback: (Result<T>) -> Unit,
block: suspend (Addon, (Result<T>) -> Unit) -> Unit,
) {
scope.launch {
val addon = runCatching {
components.core.addonManager.getAddonByID(addonId)
}.getOrNull()
if (addon == null) {
callback(Result.failure(IllegalStateException("Addon $addonId not found")))
return@launch
}
withContext(Dispatchers.Main.immediate) {
block(addon, callback)
}
}
}
private fun isAutoUpdateEnabled(): Boolean {
return prefs.getBoolean(AddonPrefs.PREF_AUTO_UPDATE_ENABLED, true)
}
private fun isAddonAutoUpdateEnabledForAddon(addonId: String): Boolean {
return !getAddonAutoUpdateDisabledIds().contains(addonId)
}
private fun isAutoUpdateEffectivelyEnabledForAddon(addonId: String): Boolean {
return isAddonAutoUpdateEnabledForAddon(addonId) && !isLocalFileInstalledAddon(addonId)
}
private fun isLocalFileInstalledAddon(addonId: String): Boolean {
return getLocalFileAddonIds().contains(addonId)
}
private fun getAddonAutoUpdateDisabledIds(): Set<String> {
return prefs.getStringSet(AddonPrefs.PREF_AUTO_UPDATE_DISABLED_IDS, emptySet()) ?: emptySet()
}
private fun getLocalFileAddonIds(): Set<String> {
return prefs.getStringSet(AddonPrefs.PREF_LOCAL_FILE_ADDON_IDS, emptySet()) ?: emptySet()
}
private suspend fun fetchAddonStoreInfo(addonId: String): AddonStoreInfo? {
val response = components.core.client.fetch(
Request(
url = addonStoreInfoUrl(addonId),
method = Request.Method.GET,
headers = MutableHeaders("Accept" to "application/json"),
),
)
if (response.status !in 200..299) {
return null
}
val responseBody = response.body.useStream { stream ->
String(stream.readAllBytes(), Charsets.UTF_8)
}
val json = JSONObject(responseBody)
val currentVersion = json.optJSONObject("current_version") ?: return null
val latestVersion = currentVersion.optString("version")
val latestXpiUrl = currentVersion.optJSONObject("file")?.optString("url").orEmpty()
if (latestVersion.isBlank() || latestXpiUrl.isBlank()) {
return null
}
return AddonStoreInfo(
latestVersion = latestVersion,
latestXpiUrl = latestXpiUrl,
)
}
private fun addonStoreInfoUrl(addonId: String): String {
val baseUrl = components.addonCollection?.serverURL?.trimEnd('/') ?: DEFAULT_AMO_SERVER_URL
return "$baseUrl/api/v5/addons/addon/$addonId/"
}
private fun saveManualUpdateAttempt(
addonId: String,
status: AddonUpdateStatus,
message: String? = null,
) {
prefs.edit()
.putLong(manualAttemptTimestampKey(addonId), System.currentTimeMillis())
.putString(manualAttemptStatusKey(addonId), status.name)
.putString(manualAttemptMessageKey(addonId), message)
.apply()
}
private fun getManualUpdateAttempt(addonId: String): AddonUpdateAttemptInfo? {
val timestamp = prefs.getLong(manualAttemptTimestampKey(addonId), -1L)
if (timestamp < 0) {
return null
}
val status = prefs.getString(manualAttemptStatusKey(addonId), null)
?.let { runCatching { AddonUpdateStatus.valueOf(it) }.getOrNull() }
return AddonUpdateAttemptInfo(
addonId = addonId,
dateMillisecondsSinceEpoch = timestamp,
status = status,
message = prefs.getString(manualAttemptMessageKey(addonId), null),
)
}
private fun clearManualUpdateAttempt(addonId: String) {
prefs.edit()
.remove(manualAttemptTimestampKey(addonId))
.remove(manualAttemptStatusKey(addonId))
.remove(manualAttemptMessageKey(addonId))
.apply()
}
private fun manualAttemptTimestampKey(addonId: String): String {
return "$AddonPrefs.PREF_MANUAL_UPDATE_ATTEMPT_PREFIX$addonId.timestamp"
}
private fun manualAttemptStatusKey(addonId: String): String {
return "$AddonPrefs.PREF_MANUAL_UPDATE_ATTEMPT_PREFIX$addonId.status"
}
private fun manualAttemptMessageKey(addonId: String): String {
return "$AddonPrefs.PREF_MANUAL_UPDATE_ATTEMPT_PREFIX$addonId.message"
}
private fun performAddonInstall(
url: String,
isLocalFileInstall: Boolean,
callback: (Result<Unit>) -> Unit,
) {
val installationMethod = if (isLocalFileInstall) InstallationMethod.FROM_FILE else null
components.core.addonManager.installAddon(
url = url,
installationMethod = installationMethod,
onSuccess = { installedAddon ->
applyInstallUpdatePolicy(installedAddon.id, isLocalFileInstall)
callback(Result.success(Unit))
},
onError = { error ->
callback(Result.failure(error))
},
)
}
private fun applyInstallUpdatePolicy(addonId: String, isLocalFileInstall: Boolean) {
if (isLocalFileInstall) {
markAddonAsLocalFileInstalled(addonId)
setAddonAutoUpdateEnabledForAddonInternal(addonId, false)
} else {
clearLocalFileInstalledAddon(addonId)
updateAddonAutoUpdateRegistration(addonId)
}
}
private fun runLocalFileAddonUpdate(
addonId: String,
callback: (Result<AddonUpdateAttemptInfo?>) -> Unit,
) {
withInstalledAddon(addonId, callback) { addon, result ->
val storeInfo = fetchAddonStoreInfo(addonId)
if (storeInfo == null) {
saveManualUpdateAttempt(
addonId = addonId,
status = AddonUpdateStatus.ERROR,
message = LOCAL_ADDON_UPDATE_SOURCE_MISSING_MESSAGE,
)
result(Result.failure(IllegalStateException(LOCAL_ADDON_UPDATE_SOURCE_MISSING_MESSAGE)))
return@withInstalledAddon
}
if (addon.installedState?.version == storeInfo.latestVersion) {
saveManualUpdateAttempt(
addonId = addonId,
status = AddonUpdateStatus.NO_UPDATE_AVAILABLE,
)
result(Result.success(getManualUpdateAttempt(addonId)))
return@withInstalledAddon
}
components.core.addonManager.uninstallAddon(
addon,
onSuccess = {
performAddonInstall(
url = storeInfo.latestXpiUrl,
isLocalFileInstall = false,
callback = { installResult ->
installResult.fold(
onSuccess = {
saveManualUpdateAttempt(
addonId = addonId,
status = AddonUpdateStatus.SUCCESSFULLY_UPDATED,
)
result(Result.success(getManualUpdateAttempt(addonId)))
},
onFailure = { throwable ->
if (isPostponedInstallException(throwable)) {
clearLocalFileInstalledAddon(addonId)
updateAddonAutoUpdateRegistration(addonId)
saveManualUpdateAttempt(
addonId = addonId,
status = AddonUpdateStatus.SUCCESSFULLY_UPDATED,
message = LOCAL_ADDON_UPDATE_POSTPONED_MESSAGE,
)
result(Result.success(getManualUpdateAttempt(addonId)))
return@fold
}
saveManualUpdateAttempt(
addonId = addonId,
status = AddonUpdateStatus.ERROR,
message = throwable.message,
)
result(Result.failure(throwable))
},
)
},
)
},
onError = { _, throwable ->
saveManualUpdateAttempt(
addonId = addonId,
status = AddonUpdateStatus.ERROR,
message = throwable.message,
)
result(Result.failure(throwable))
},
)
}
}
private fun setAddonAutoUpdateEnabledForAddonInternal(addonId: String, enabled: Boolean) {
val disabledIds = getAddonAutoUpdateDisabledIds().toMutableSet()
val changed = if (enabled) {
disabledIds.remove(addonId)
} else {
disabledIds.add(addonId)
}
if (changed) {
prefs.edit().putStringSet(AddonPrefs.PREF_AUTO_UPDATE_DISABLED_IDS, disabledIds).apply()
}
updateAddonAutoUpdateRegistration(addonId)
}
private fun clearAddonAutoUpdatePreference(addonId: String) {
val disabledIds = getAddonAutoUpdateDisabledIds().toMutableSet()
if (disabledIds.remove(addonId)) {
prefs.edit().putStringSet(AddonPrefs.PREF_AUTO_UPDATE_DISABLED_IDS, disabledIds).apply()
}
}
private fun markAddonAsLocalFileInstalled(addonId: String) {
val localFileAddonIds = getLocalFileAddonIds().toMutableSet()
if (localFileAddonIds.add(addonId)) {
prefs.edit().putStringSet(AddonPrefs.PREF_LOCAL_FILE_ADDON_IDS, localFileAddonIds).apply()
}
}
private fun clearLocalFileInstalledAddon(addonId: String) {
val localFileAddonIds = getLocalFileAddonIds().toMutableSet()
if (localFileAddonIds.remove(addonId)) {
prefs.edit().putStringSet(AddonPrefs.PREF_LOCAL_FILE_ADDON_IDS, localFileAddonIds).apply()
}
}
private fun isPostponedInstallException(throwable: Throwable): Boolean {
val installThrowable = when (throwable) {
is WebExtensionInstallException -> throwable.cause
else -> throwable.cause
}
return installThrowable is org.mozilla.geckoview.WebExtension.InstallException &&
installThrowable.code == ERROR_POSTPONED
}
private fun updateAddonAutoUpdateRegistration(addonId: String) {
if (
isAutoUpdateEnabled() &&
isAddonAutoUpdateEnabledForAddon(addonId) &&
!isLocalFileInstalledAddon(addonId)
) {
components.core.addonUpdater.registerForFutureUpdates(addonId)
} else {
components.core.addonUpdater.unregisterForFutureUpdates(addonId)
}
}
private fun scheduleManagedAddonUpdate(addonId: String) {
val shouldRestorePeriodicRegistration = isAutoUpdateEnabled() &&
isAddonAutoUpdateEnabledForAddon(addonId)
if (shouldRestorePeriodicRegistration) {
components.core.addonUpdater.unregisterForFutureUpdates(addonId)
}
components.core.addonUpdater.update(addonId)
if (shouldRestorePeriodicRegistration) {
scope.launch {
delay(PERIODIC_UPDATE_RESTORE_DELAY_MS)
withContext(Dispatchers.Main.immediate) {
updateAddonAutoUpdateRegistration(addonId)
}
}
}
}
private fun runManagedAddonUpdate(
addonId: String,
onComplete: (AddonUpdateAttemptInfo?) -> Unit,
) {
val shouldRestorePeriodicRegistration = isAutoUpdateEnabled() &&
isAddonAutoUpdateEnabledForAddon(addonId)
if (shouldRestorePeriodicRegistration) {
components.core.addonUpdater.unregisterForFutureUpdates(addonId)
}
components.core.addonManager.updateAddon(addonId) { status ->
val pigeonStatus = status.toPigeon()
val message = (status as? AddonUpdater.Status.Error)?.message
saveManualUpdateAttempt(addonId, pigeonStatus, message)
onComplete(getManualUpdateAttempt(addonId))
if (shouldRestorePeriodicRegistration) {
scope.launch {
delay(PERIODIC_UPDATE_RESTORE_DELAY_MS)
withContext(Dispatchers.Main.immediate) {
updateAddonAutoUpdateRegistration(addonId)
}
}
}
}
}
}
private fun Addon.toPigeon(
context: Context,
isAutoUpdateEnabled: Boolean,
isLocalFileInstalled: Boolean,
): AddonInfo {
val installedState = installedState
val localizedName = displayName(context)
val localizedSummary = summary(context)
val localizedDescription = if (translatableDescription.isNotEmpty()) {
translateDescription(context)
} else {
""
}
return AddonInfo(
id = id,
displayName = localizedName,
summary = localizedSummary,
description = localizedDescription,
downloadUrl = downloadUrl,
version = version,
installedVersion = installedState?.version,
translatedPermissions = translatePermissions(context),
translatedRequiredDataCollectionPermissions =
translateRequiredDataCollectionPermissions(context),
authorName = author?.name,
authorUrl = author?.url,
homepageUrl = homepageUrl,
detailUrl = detailUrl,
ratingUrl = ratingUrl,
ratingAverage = rating?.average?.toDouble(),
ratingReviews = rating?.reviews?.toLong(),
createdAt = createdAt,
updatedAt = updatedAt,
icon = provideIcon()?.toWebPBytes(),
isInstalled = isInstalled(),
isEnabled = isEnabled(),
isSupported = isSupported(),
isAllowedInPrivateBrowsing = isAllowedInPrivateBrowsing(),
isAutoUpdateEnabled = isAutoUpdateEnabled,
isLocalFileInstalled = isLocalFileInstalled,
optionsPageUrl = installedState?.optionsPageUrl,
openOptionsPageInTab = installedState?.openOptionsPageInTab ?: false,
disabledReason = installedState?.disabledReason?.toPigeon(),
incognito = incognito.toPigeon(),
)
}
private fun Addon.DisabledReason.toPigeon(): AddonDisabledReason {
return when (this) {
Addon.DisabledReason.UNSUPPORTED -> AddonDisabledReason.UNSUPPORTED
Addon.DisabledReason.BLOCKLISTED -> AddonDisabledReason.BLOCKLISTED
Addon.DisabledReason.USER_REQUESTED -> AddonDisabledReason.USER_REQUESTED
Addon.DisabledReason.NOT_CORRECTLY_SIGNED -> AddonDisabledReason.NOT_CORRECTLY_SIGNED
Addon.DisabledReason.INCOMPATIBLE -> AddonDisabledReason.INCOMPATIBLE
Addon.DisabledReason.SOFT_BLOCKED -> AddonDisabledReason.SOFT_BLOCKED
}
}
private fun Addon.Incognito.toPigeon(): AddonIncognito {
return when (this) {
Addon.Incognito.SPANNING -> AddonIncognito.SPANNING
Addon.Incognito.SPLIT -> AddonIncognito.SPLIT
Addon.Incognito.NOT_ALLOWED -> AddonIncognito.NOT_ALLOWED
}
}
private fun AddonUpdater.UpdateAttempt.toPigeon(): AddonUpdateAttemptInfo {
return AddonUpdateAttemptInfo(
addonId = addonId,
dateMillisecondsSinceEpoch = date.time,
status = status?.toPigeon(),
message = (status as? AddonUpdater.Status.Error)?.message,
)
}
private fun AddonUpdater.Status.toPigeon(): AddonUpdateStatus {
return when (this) {
AddonUpdater.Status.NotInstalled -> AddonUpdateStatus.NOT_INSTALLED
AddonUpdater.Status.SuccessfullyUpdated -> AddonUpdateStatus.SUCCESSFULLY_UPDATED
AddonUpdater.Status.NoUpdateAvailable -> AddonUpdateStatus.NO_UPDATE_AVAILABLE
is AddonUpdater.Status.Error -> AddonUpdateStatus.ERROR
}
}
@@ -10,6 +10,8 @@ import android.app.Activity
import android.content.Intent
import android.view.View
import androidx.fragment.app.FragmentActivity
import eu.weblibre.flutter_mozilla_components.AddonPopupViewFactory
import eu.weblibre.flutter_mozilla_components.AddonSettingsViewFactory
import eu.weblibre.flutter_mozilla_components.BrowserFragment
import eu.weblibre.flutter_mozilla_components.GeckoViewFactory
import eu.weblibre.flutter_mozilla_components.EngineProvider
@@ -145,6 +147,14 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
_flutterEvents
)
)
_flutterPluginBinding.platformViewRegistry.registerViewFactory(
"eu.weblibre/addon_settings",
AddonSettingsViewFactory(activityProvider = { this.activity }),
)
_flutterPluginBinding.platformViewRegistry.registerViewFactory(
"eu.weblibre/addon_popup",
AddonPopupViewFactory(activityProvider = { this.activity }),
)
isPlatformViewRegistered = true
isGeckoInitialized = false
@@ -1,157 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:fitsSystemWindows="true"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:layout_marginBottom="6dp">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd">
<TextView
android:id="@+id/details"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="20dp"
tools:text="@tools:sample/lorem/random" />
<TextView
android:id="@+id/author_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/details"
android:text="@string/mozac_feature_addons_author" />
<TextView
android:id="@+id/author_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/details"
android:layout_alignParentEnd="true"
tools:text="@tools:sample/full_names" />
<View
android:id="@+id/author_divider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_below="@+id/author_label"
android:layout_marginTop="10dp"
android:layout_marginBottom="10dp"
android:background="@color/photonGrey40"
android:importantForAccessibility="no" />
<TextView
android:id="@+id/version_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/author_divider"
android:text="@string/mozac_feature_addons_version" />
<TextView
android:id="@+id/version_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/author_divider"
android:layout_alignParentEnd="true"
tools:text="1.2.3" />
<View
android:id="@+id/version_divider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_below="@+id/version_label"
android:layout_marginTop="10dp"
android:layout_marginBottom="10dp"
android:background="@color/photonGrey40"
android:importantForAccessibility="no" />
<TextView
android:id="@+id/last_updated_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/version_divider"
android:text="@string/mozac_feature_addons_last_updated" />
<TextView
android:id="@+id/last_updated_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/version_divider"
android:layout_alignParentEnd="true"
tools:text="Oct 16, 2019" />
<View
android:id="@+id/last_updated_divider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_below="@+id/last_updated_label"
android:layout_marginTop="10dp"
android:layout_marginBottom="10dp"
android:background="@color/photonGrey40"
android:importantForAccessibility="no" />
<TextView
android:id="@+id/home_page_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/last_updated_divider"
android:text="@string/mozac_feature_addons_home_page" />
<ImageView
android:id="@+id/home_page_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/last_updated_divider"
android:layout_alignParentEnd="true"
android:contentDescription="@string/mozac_feature_addons_home_page"
android:src="@drawable/mozac_ic_link_24"
app:tint="@color/icons" />
<View
android:id="@+id/home_page_divider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_below="@+id/home_page_label"
android:layout_marginTop="10dp"
android:layout_marginBottom="10dp"
android:background="@color/photonGrey40"
android:importantForAccessibility="no" />
<TextView
android:id="@+id/rating_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/home_page_divider"
android:text="@string/mozac_feature_addons_rating" />
<RatingBar
android:id="@+id/rating_view"
style="@style/Widget.AppCompat.RatingBar.Small"
android:layout_width="wrap_content"
android:layout_height="20dp"
android:layout_below="@+id/home_page_divider"
android:layout_toStartOf="@+id/users_count"
android:isIndicator="true"
android:numStars="5" />
<TextView
android:id="@+id/users_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/home_page_divider"
android:layout_alignParentEnd="true"
android:layout_marginStart="6dp"
tools:text="591,642" />
</RelativeLayout>
</ScrollView>
@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!-- 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/. -->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true" >
<androidx.fragment.app.FragmentContainerView
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -1,32 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:fitsSystemWindows="true"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:context=".addons.PermissionsDetailsActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/add_ons_permissions"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/learn_more_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/add_ons_permissions"
app:drawableEndCompat="@drawable/mozac_ic_link_24"
android:padding="16dp"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:background="?attr/selectableItemBackground"
android:text="@string/mozac_feature_addons_learn_more"
app:drawableTint="@color/icons" />
</RelativeLayout>
@@ -1,94 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:fitsSystemWindows="true"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginBottom="6dp">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd">
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/enable_switch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical|end"
android:background="?android:attr/selectableItemBackground"
android:checked="true"
android:clickable="true"
android:focusable="true"
android:text="@string/mozac_feature_addons_enabled"
android:padding="16dp"
android:textSize="18sp"/>
<TextView
android:id="@+id/settings"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/enable_switch"
android:background="?android:attr/selectableItemBackground"
android:drawablePadding="10dp"
android:padding="16dp"
android:text="@string/mozac_feature_addons_settings"
android:textSize="18sp"
app:drawableStartCompat="@drawable/mozac_ic_preferences"
app:drawableTint="@color/icons" />
<TextView
android:id="@+id/details"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/settings"
android:background="?android:attr/selectableItemBackground"
android:drawablePadding="6dp"
android:padding="16dp"
android:text="@string/mozac_feature_addons_details"
android:textSize="18sp"
app:drawableStartCompat="@drawable/mozac_ic_information_24"
app:drawableTint="@color/icons" />
<TextView
android:id="@+id/permissions"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/details"
android:background="?android:attr/selectableItemBackground"
android:drawablePadding="6dp"
android:padding="16dp"
android:text="@string/mozac_feature_addons_permissions"
android:textSize="18sp"
app:drawableStartCompat="@drawable/mozac_ic_permissions" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/allow_in_private_browsing_switch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical|end"
android:layout_below="@+id/permissions"
android:background="?android:attr/selectableItemBackground"
android:checked="false"
android:clickable="true"
android:focusable="true"
android:text="@string/mozac_feature_addons_settings_allow_in_private_browsing_2"
android:padding="16dp"
android:textSize="18sp"/>
<Button
android:id="@+id/remove_add_on"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/allow_in_private_browsing_switch"
android:layout_marginTop="16dp"
android:textColor="@color/photonRed50"
android:text="@string/mozac_feature_addons_remove" />
</RelativeLayout>
</ScrollView>
@@ -1,30 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/add_on_item"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp">
<TextView
android:id="@+id/permission"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="16dp"
android:paddingBottom="16dp"
tools:text="Access your data for all websites" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/photonGrey40"
android:importantForAccessibility="no" />
</LinearLayout>
@@ -1,27 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/add_ons_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".BrowserActivity"/>
<include
android:id="@+id/addonProgressOverlay"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:visibility="gone"
layout="@layout/overlay_add_on_progress" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -66,37 +66,6 @@
</intent-filter>
</activity>
<activity
android:theme="@style/Theme.AppCompat.Light"
android:name="eu.weblibre.flutter_mozilla_components.addons.AddonsActivity"
android:label="@string/mozac_feature_addons_addons"
android:exported="false"
android:parentActivityName=".MainActivity" />
<activity
android:theme="@style/Theme.AppCompat.Light"
android:name="eu.weblibre.flutter_mozilla_components.addons.AddonDetailsActivity"
android:exported="false"
android:label="@string/mozac_feature_addons_addons" />
<activity android:name="eu.weblibre.flutter_mozilla_components.addons.InstalledAddonDetailsActivity"
android:label="@string/mozac_feature_addons_addons"
android:parentActivityName="eu.weblibre.flutter_mozilla_components.addons.AddonsActivity"
android:exported="false"
android:theme="@style/Theme.AppCompat.Light" />
<activity
android:name="eu.weblibre.flutter_mozilla_components.addons.PermissionsDetailsActivity"
android:label="@string/mozac_feature_addons_addons"
android:exported="false"
android:theme="@style/Theme.AppCompat.Light" />
<activity
android:name="eu.weblibre.flutter_mozilla_components.addons.AddonInternalSettingsActivity"
android:label="@string/mozac_feature_addons_addons"
android:exported="false"
android:theme="@style/Theme.AppCompat.Light" />
<activity
android:name="eu.weblibre.flutter_mozilla_components.addons.WebExtensionActionPopupActivity"
android:label="@string/mozac_feature_addons_addons"
@@ -38,6 +38,12 @@ export 'src/pigeons/gecko.g.dart'
show
AddTabParams,
AddonCollection,
AddonDisabledReason,
AddonIncognito,
AddonInfo,
AddonStoreInfo,
AddonUpdateAttemptInfo,
AddonUpdateStatus,
AppLinksMode,
AudioHitResult,
AutoplayStatus,
@@ -13,6 +13,7 @@ import 'package:rxdart/rxdart.dart';
typedef ExtensionDataEvent = ({String extensionId, WebExtensionData? data});
typedef ExtensionIconEvent = ({String extensionId, Uint8List bytes});
typedef ExtensionPopupEvent = ({String extensionId, String extensionName});
final _apiInstance = GeckoAddonsApi();
@@ -24,6 +25,7 @@ class GeckoAddonService extends GeckoAddonEvents {
final _browserIconSubject = ReplaySubject<ExtensionIconEvent>();
final _pageIconSubject = ReplaySubject<ExtensionIconEvent>();
final _popupSubject = PublishSubject<ExtensionPopupEvent>();
Stream<ExtensionDataEvent> get browserExtensionStream =>
_browserExtensionSubject.stream;
@@ -33,13 +35,18 @@ class GeckoAddonService extends GeckoAddonEvents {
Stream<ExtensionIconEvent> get browserIconStream =>
_browserIconSubject.stream;
Stream<ExtensionIconEvent> get pageIconStream => _pageIconSubject.stream;
Stream<ExtensionPopupEvent> get popupStream => _popupSubject.stream;
Future<void> startAddonManagerActivity() {
return _api.startAddonManagerActivity();
Future<List<AddonInfo>> getAddons({bool allowCache = true}) {
return _api.getAddons(allowCache);
}
Future<void> startAddonSettingsActivity(String extensionId) {
return _api.startAddonSettingsActivity(extensionId);
Future<AddonInfo?> getAddonById(String addonId, {bool allowCache = true}) {
return _api.getAddonById(addonId, allowCache);
}
Future<AddonStoreInfo?> getAddonStoreInfo(String addonId) {
return _api.getAddonStoreInfo(addonId);
}
Future<void> invokeAddonAction(
@@ -53,6 +60,52 @@ class GeckoAddonService extends GeckoAddonEvents {
return _api.installAddon(url.toString());
}
Future<AddonInfo> enableAddon(String addonId) {
return _api.enableAddon(addonId);
}
Future<AddonInfo> disableAddon(String addonId) {
return _api.disableAddon(addonId);
}
Future<AddonInfo> setAddonAllowedInPrivateBrowsing(
String addonId,
bool allowed,
) {
return _api.setAddonAllowedInPrivateBrowsing(addonId, allowed);
}
Future<AddonInfo> setAddonAutoUpdateEnabledForAddon(
String addonId,
bool enabled,
) {
return _api.setAddonAutoUpdateEnabledForAddon(addonId, enabled);
}
Future<void> uninstallAddon(String addonId) {
return _api.uninstallAddon(addonId);
}
Future<AddonUpdateAttemptInfo?> triggerAddonUpdate(String addonId) {
return _api.triggerAddonUpdate(addonId);
}
Future<void> triggerAllAddonUpdates() {
return _api.triggerAllAddonUpdates();
}
Future<AddonUpdateAttemptInfo?> getLastAddonUpdateAttempt(String addonId) {
return _api.getLastAddonUpdateAttempt(addonId);
}
Future<bool> isAddonAutoUpdateEnabled() {
return _api.isAddonAutoUpdateEnabled();
}
Future<void> setAddonAutoUpdateEnabled({required bool enabled}) {
return _api.setAddonAutoUpdateEnabled(enabled);
}
@override
void onRemoveWebExtensionAction(
int sequence,
@@ -115,6 +168,11 @@ class GeckoAddonService extends GeckoAddonEvents {
}
}
@override
void onWebExtensionPopupRequested(String extensionId, String extensionName) {
_popupSubject.add((extensionId: extensionId, extensionName: extensionName));
}
GeckoAddonService.setUp({
BinaryMessenger? binaryMessenger,
GeckoAddonsApi? api,
@@ -132,5 +190,6 @@ class GeckoAddonService extends GeckoAddonEvents {
await _pageExtensionSubject.close();
await _browserIconSubject.close();
await _pageIconSubject.close();
await _popupSubject.close();
}
}
File diff suppressed because it is too large Load Diff
@@ -667,6 +667,112 @@ class WebExtensionData {
);
}
enum AddonDisabledReason {
unsupported,
blocklisted,
userRequested,
notCorrectlySigned,
incompatible,
softBlocked,
}
enum AddonIncognito { spanning, split, notAllowed }
enum AddonUpdateStatus {
notInstalled,
successfullyUpdated,
noUpdateAvailable,
error,
}
class AddonInfo {
final String id;
final String displayName;
final String? summary;
final String description;
final String downloadUrl;
final String version;
final String? installedVersion;
final List<String> translatedPermissions;
final List<String> translatedRequiredDataCollectionPermissions;
final String? authorName;
final String? authorUrl;
final String homepageUrl;
final String detailUrl;
final String ratingUrl;
final double? ratingAverage;
final int? ratingReviews;
final String createdAt;
final String updatedAt;
final Uint8List? icon;
final bool isInstalled;
final bool isEnabled;
final bool isSupported;
final bool isAllowedInPrivateBrowsing;
final bool isAutoUpdateEnabled;
final bool isLocalFileInstalled;
final String? optionsPageUrl;
final bool openOptionsPageInTab;
final AddonDisabledReason? disabledReason;
final AddonIncognito incognito;
const AddonInfo({
required this.id,
required this.displayName,
this.summary,
required this.description,
required this.downloadUrl,
required this.version,
this.installedVersion,
required this.translatedPermissions,
required this.translatedRequiredDataCollectionPermissions,
this.authorName,
this.authorUrl,
required this.homepageUrl,
required this.detailUrl,
required this.ratingUrl,
this.ratingAverage,
this.ratingReviews,
required this.createdAt,
required this.updatedAt,
this.icon,
required this.isInstalled,
required this.isEnabled,
required this.isSupported,
required this.isAllowedInPrivateBrowsing,
required this.isAutoUpdateEnabled,
required this.isLocalFileInstalled,
this.optionsPageUrl,
required this.openOptionsPageInTab,
this.disabledReason,
this.incognito = AddonIncognito.spanning,
});
}
class AddonStoreInfo {
final String latestVersion;
final String latestXpiUrl;
const AddonStoreInfo({
required this.latestVersion,
required this.latestXpiUrl,
});
}
class AddonUpdateAttemptInfo {
final String addonId;
final int dateMillisecondsSinceEpoch;
final AddonUpdateStatus? status;
final String? message;
const AddonUpdateAttemptInfo({
required this.addonId,
required this.dateMillisecondsSinceEpoch,
this.status,
this.message,
});
}
enum GeckoSuggestionType { session, clipboard, history }
class GeckoSuggestion {
@@ -1738,14 +1844,49 @@ abstract class GeckoSelectionActionEvents {
@HostApi()
abstract class GeckoAddonsApi {
void startAddonManagerActivity();
@async
List<AddonInfo> getAddons(bool allowCache);
void startAddonSettingsActivity(String extensionId);
@async
AddonInfo? getAddonById(String addonId, bool allowCache);
@async
AddonStoreInfo? getAddonStoreInfo(String addonId);
void invokeAddonAction(String extensionId, WebExtensionActionType actionType);
@async
AddonInfo enableAddon(String addonId);
@async
AddonInfo disableAddon(String addonId);
@async
AddonInfo setAddonAllowedInPrivateBrowsing(String addonId, bool allowed);
@async
AddonInfo setAddonAutoUpdateEnabledForAddon(String addonId, bool enabled);
@async
void uninstallAddon(String addonId);
@async
AddonUpdateAttemptInfo? triggerAddonUpdate(String addonId);
@async
void triggerAllAddonUpdates();
@async
AddonUpdateAttemptInfo? getLastAddonUpdateAttempt(String addonId);
@async
void installAddon(String url);
@async
bool isAddonAutoUpdateEnabled();
@async
void setAddonAutoUpdateEnabled(bool enabled);
}
@FlutterApi()
@@ -1769,6 +1910,8 @@ abstract class GeckoAddonEvents {
WebExtensionActionType actionType,
Uint8List icon,
);
void onWebExtensionPopupRequested(String extensionId, String extensionName);
}
@HostApi()