fix Iceraven Paged components

pull/700/head iceraven-2.13.0
akliuxingyuan 6 months ago
parent 68d135c36d
commit aa94a4e418

@ -9,6 +9,11 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.AtomicFile
import androidx.annotation.VisibleForTesting
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import mozilla.components.concept.fetch.Client
import mozilla.components.concept.fetch.Request
import mozilla.components.concept.fetch.isSuccess
@ -20,7 +25,6 @@ import mozilla.components.support.ktx.kotlin.sanitizeURL
import mozilla.components.support.ktx.util.readAndDeserialize
import mozilla.components.support.ktx.util.writeString
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import org.mozilla.fenix.Config
import org.mozilla.fenix.ext.settings
@ -52,21 +56,23 @@ internal const val DEFAULT_READ_TIMEOUT_IN_SECONDS = 20L
* is being used by default
*/
@Suppress("LongParameterList")
class PagedAMOAddonProvider(
class PagedAMOAddonsProvider(
private val context: Context,
private val client: Client,
private val serverURL: String = DEFAULT_SERVER_URL,
private val maxCacheAgeInMinutes: Long = -1,
) : AddonsProvider {
// This map acts as an in-memory cache for the installed add-ons.
@VisibleForTesting
internal val installedAddons = ConcurrentHashMap<String, Addon>()
private val logger = Logger("PagedAddonCollectionProvider")
private val diskCacheLock = Any()
private val scope = CoroutineScope(Dispatchers.IO)
// Acts as an in-memory cache for the fetched addon's icons.
@VisibleForTesting
internal val iconsCache = ConcurrentHashMap<String, Bitmap>()
/**
* Get the account we should be fetching addons from.
*/
@ -118,6 +124,7 @@ class PagedAMOAddonProvider(
* a connectivity problem or a timeout.
*/
@Throws(IOException::class)
@Suppress("NestedBlockDepth")
override suspend fun getFeaturedAddons(
allowCache: Boolean,
readTimeoutInSeconds: Long?,
@ -127,7 +134,7 @@ class PagedAMOAddonProvider(
// that we are trying to fetch the latest localized add-ons when the user changes
// language from the previous one.
val cachedFeaturedAddons = if (allowCache && !cacheExpired(context, language, useFallbackFile = false)) {
readFromDiskCache(language, useFallbackFile = false)
readFromDiskCache(language, useFallbackFile = false)?.loadIcons()
} else {
null
}
@ -138,104 +145,32 @@ class PagedAMOAddonProvider(
if (cachedFeaturedAddons != null) {
logger.info("Providing cached list of addons for $collectionAccount collection $collectionName")
return cachedFeaturedAddons
} else {
logger.info("Fetching fresh list of addons for $collectionAccount collection $collectionName")
val langParam = if (!language.isNullOrEmpty()) {
"?lang=$language"
} else {
""
}
return getAllPages(
listOf(
serverURL,
API_VERSION,
"accounts/account",
collectionAccount,
"collections",
collectionName,
"addons",
langParam
).joinToString("/"),
readTimeoutInSeconds ?: DEFAULT_READ_TIMEOUT_IN_SECONDS,
).also {
// Cache the JSON object before we parse out the addons
if (maxCacheAgeInMinutes > 0) {
writeToDiskCache(it.toString(), language)
}
deleteUnusedCacheFiles(language)
}.getAddonsFromCollection(language)
}
}
/**
* Interacts with the search endpoint to provide a list of add-ons for a given list of GUIDs.
*
* See: https://addons-server.readthedocs.io/en/latest/topics/api/addons.html#search
*
* @param guids list of add-on GUIDs to retrieve.
* @param allowCache whether or not the result may be provided from a previously cached response,
* defaults to true.
* @param readTimeoutInSeconds optional timeout in seconds to use when fetching available
* add-ons from a remote endpoint. If not specified [DEFAULT_READ_TIMEOUT_IN_SECONDS] will
* be used.
* @param language indicates in which language the translatable fields should be in, if no
* matching language is found then a fallback translation is returned using the default
* language. When it is null all translations available will be returned.
* @throws IOException if the request failed, or could not be executed due to cancellation,
* a connectivity problem or a timeout.
*/
@Throws(IOException::class)
@Suppress("NestedBlockDepth")
override suspend fun getAddonsByGUIDs(
guids: List<String>,
allowCache: Boolean,
readTimeoutInSeconds: Long?,
language: String?,
): List<Addon> {
if (guids.isEmpty()) {
logger.warn("Attempted to retrieve add-ons with an empty list of GUIDs")
return emptyList()
}
if (allowCache && installedAddons.isNotEmpty()) {
val cachedAddons = installedAddons.findAddonsBy(guids, language ?: Locale.getDefault().language)
// We should only return the cached add-ons when all the requested
// GUIDs have been found in the cache.
if (cachedAddons.size == guids.size) {
return cachedAddons
}
}
logger.info("Fetching fresh list of addons for $collectionAccount collection $collectionName")
val langParam = if (!language.isNullOrEmpty()) {
"&lang=$language"
"?lang=$language"
} else {
""
}
client.fetch(
Request(
url = "$serverURL/$API_VERSION/addons/search/?guid=${guids.joinToString(",")}" + langParam,
readTimeout = Pair(readTimeoutInSeconds ?: DEFAULT_READ_TIMEOUT_IN_SECONDS, TimeUnit.SECONDS),
),
)
.use { response ->
if (response.isSuccess) {
val responseBody = response.body.string(Charsets.UTF_8)
return try {
val addons = JSONObject(responseBody).getAddonsFromSearchResults(language)
addons.forEach {
installedAddons[it.id] = it
}
addons
} catch (e: JSONException) {
throw IOException(e)
}
} else {
val errorMessage = "Failed to get add-ons by GUIDs. Status code: ${response.status}"
logger.error(errorMessage)
throw IOException(errorMessage)
}
return getAllPages(
listOf(
serverURL,
API_VERSION,
"accounts/account",
collectionAccount,
"collections",
collectionName,
"addons",
langParam,
).joinToString("/"),
readTimeoutInSeconds ?: DEFAULT_READ_TIMEOUT_IN_SECONDS,
).also {
// Cache the JSON object before we parse out the addons
if (maxCacheAgeInMinutes > 0) {
writeToDiskCache(it.toString(), language)
}
deleteUnusedCacheFiles(language)
}.getAddonsFromCollection(language)
}
/**
@ -251,7 +186,7 @@ class PagedAMOAddonProvider(
@Throws(IOException::class)
fun getAllPages(url: String, readTimeoutInSeconds: Long): JSONObject {
// Fetch and compile all the pages into one object we can return
var compiledResponse: JSONObject? = null
var compiledResponse = JSONObject()
// Each page tells us where to get the next page, if there is one
var nextURL: String? = url
logger.debug("Fetching URI: $nextURL")
@ -263,60 +198,81 @@ class PagedAMOAddonProvider(
),
)
.use { response ->
if (!response.isSuccess) {
val errorMessage =
"Failed to fetch addon collection. Status code: ${response.status}"
if (response.isSuccess) {
val currentResponse = JSONObject(response.body.string(Charsets.UTF_8))
if (compiledResponse.length() == 0) {
compiledResponse = currentResponse
} else {
// Write the addons into the first response
compiledResponse.getJSONArray("results")
.concat(currentResponse.getJSONArray("results"))
}
nextURL = if (currentResponse.isNull("next")) null else currentResponse.getString("next")
} else {
val errorMessage = "Failed to fetch featured add-ons from collection. " + "Status code: ${response.status}"
logger.error(errorMessage)
throw IOException(errorMessage)
}
val currentResponse = JSONObject(response.body.string(Charsets.UTF_8))
if (compiledResponse == null) {
compiledResponse = currentResponse
} else {
// Write the addons into the first response
compiledResponse!!.getJSONArray("results")
.concat(currentResponse.getJSONArray("results"))
}
nextURL =
if (currentResponse.isNull("next")) null else currentResponse.getString("next")
}
}
return compiledResponse!!
return compiledResponse
}
/**
* Fetches given Addon icon from the url and returns a decoded Bitmap
* @throws IOException if the request could not be executed due to cancellation,
* a connectivity problem or a timeout.
* Asynchronously loads add-on icon for the given [iconUrl] and stores in the cache.
*/
@Throws(IOException::class)
override suspend fun getAddonIconBitmap(addon: Addon): Bitmap? {
var bitmap: Bitmap? = null
if (addon.iconUrl != "") {
client.fetch(
Request(url = addon.iconUrl.sanitizeURL()),
).use { response ->
if (response.isSuccess) {
response.body.useStream {
bitmap = BitmapFactory.decodeStream(it)
@VisibleForTesting
internal fun loadIconAsync(addonId: String, iconUrl: String): Deferred<Bitmap?> = scope.async {
val cachedIcon = iconsCache[addonId]
if (cachedIcon != null) {
logger.info("Icon for $addonId was found in the cache")
cachedIcon
} else if (iconUrl.isBlank()) {
logger.info("Unable to find the icon for $addonId blank iconUrl")
null
} else {
try {
logger.info("Trying to fetch the icon for $addonId from the network")
client.fetch(Request(url = iconUrl.sanitizeURL(), useCaches = true))
.use { response ->
if (response.isSuccess) {
response.body.useStream {
val icon = BitmapFactory.decodeStream(it)
logger.info("Icon for $addonId fetched from the network")
iconsCache[addonId] = icon
icon
}
} else {
// There was an network error and we couldn't fetch the icon.
logger.info("Unable to fetch the icon for $addonId HTTP code ${response.status}")
null
}
}
}
} catch (e: IOException) {
logger.error("Attempt to fetch the $addonId icon failed", e)
null
}
}
}
return bitmap
@VisibleForTesting
internal suspend fun List<Addon>.loadIcons(): List<Addon> {
this.map {
// Instead of loading icons one by one, let's load them async
// so we can do multiple request at the time.
loadIconAsync(it.id, it.iconUrl)
}.awaitAll() // wait until all parallel icon requests finish.
return this.map { addon ->
addon.copy(icon = iconsCache[addon.id])
}
}
@VisibleForTesting
internal fun writeToDiskCache(collectionResponse: String, language: String?) {
logger.info("Storing cache file")
synchronized(diskCacheLock) {
getCacheFile(
context,
language,
useFallbackFile = false
).writeString { collectionResponse }
getCacheFile(context, language, useFallbackFile = false, ).writeString { collectionResponse }
}
}
@ -348,11 +304,7 @@ class PagedAMOAddonProvider(
}
@VisibleForTesting
internal fun cacheExpired(
context: Context,
language: String?,
useFallbackFile: Boolean
): Boolean {
internal fun cacheExpired(context: Context, language: String?, useFallbackFile: Boolean): Boolean {
return getCacheLastUpdated(
context,
language,
@ -361,29 +313,17 @@ class PagedAMOAddonProvider(
}
@VisibleForTesting
internal fun getCacheLastUpdated(
context: Context,
language: String?,
useFallbackFile: Boolean
): Long {
internal fun getCacheLastUpdated(context: Context, language: String?, useFallbackFile: Boolean): Long {
val file = getBaseCacheFile(context, language, useFallbackFile)
return if (file.exists()) file.lastModified() else -1
}
private fun getCacheFile(
context: Context,
language: String?,
useFallbackFile: Boolean
): AtomicFile {
private fun getCacheFile(context: Context, language: String?, useFallbackFile: Boolean): AtomicFile {
return AtomicFile(getBaseCacheFile(context, language, useFallbackFile))
}
@VisibleForTesting
internal fun getBaseCacheFile(
context: Context,
language: String?,
useFallbackFile: Boolean
): File {
internal fun getBaseCacheFile(context: Context, language: String?, useFallbackFile: Boolean): File {
val collectionAccount = getCollectionAccount()
val collectionName = getCollectionName()
var file = File(context.filesDir, getCacheFileName(language))
@ -429,24 +369,17 @@ class PagedAMOAddonProvider(
}
}
internal fun Map<String, Addon>.findAddonsBy(
guids: List<String>,
language: String,
): List<Addon> {
return if (isNotEmpty()) {
filter {
guids.contains(it.key) && it.value.translatableName.containsKey(language)
}.map { it.value }
} else {
emptyList()
}
}
internal fun JSONObject.getAddonsFromSearchResults(language: String? = null): List<Addon> {
val addonsJson = getJSONArray("results")
return (0 until addonsJson.length()).map { index ->
addonsJson.getJSONObject(index).toAddon(language)
}
/**
* Represents possible sort options for the recommended add-ons from
* the configured add-on collection.
*/
enum class SortOption(val value: String) {
POPULARITY("popularity"),
POPULARITY_DESC("-popularity"),
NAME("name"),
NAME_DESC("-name"),
DATE_ADDED("added"),
DATE_ADDED_DESC("-added"),
}
internal fun JSONObject.getAddonsFromCollection(language: String? = null): List<Addon> {
@ -459,33 +392,35 @@ internal fun JSONObject.getAddonsFromCollection(language: String? = null): List<
internal fun JSONObject.toAddon(language: String? = null): Addon {
return with(this) {
val download = getDownload()
val safeLanguage = language?.lowercase(Locale.getDefault())
val summary = getSafeTranslations("summary", safeLanguage)
val isLanguageInTranslations = summary.containsKey(safeLanguage)
Addon(
id = getSafeString("guid"),
authors = getAuthors(),
categories = getCategories(),
author = getAuthor(),
createdAt = getSafeString("created"),
updatedAt = getSafeString("last_updated"),
downloadId = download?.getDownloadId() ?: "",
downloadUrl = download?.getDownloadUrl() ?: "",
updatedAt = getCurrentVersionCreated(),
downloadUrl = getDownloadUrl(),
version = getCurrentVersion(),
permissions = getPermissions(),
translatableName = getSafeTranslations("name", safeLanguage),
translatableDescription = getSafeTranslations("description", safeLanguage),
translatableSummary = summary,
iconUrl = getSafeString("icon_url"),
siteUrl = getSafeString("url"),
// This isn't the add-on homepage but the URL to the AMO detail page. On AMO, the homepage is
// a translatable field but https://github.com/mozilla/addons-server/issues/21310 prevents us
// from retrieving the homepage URL of any add-on reliably.
homepageUrl = getSafeString("url"),
rating = getRating(),
ratingUrl = getSafeString("ratings_url"),
detailUrl = getSafeString("url"),
defaultLocale = (
if (!safeLanguage.isNullOrEmpty() && isLanguageInTranslations) {
safeLanguage
} else {
getSafeString("default_locale").ifEmpty { Addon.DEFAULT_LOCALE }
}
).lowercase(Locale.ROOT),
if (!safeLanguage.isNullOrEmpty() && isLanguageInTranslations) {
safeLanguage
} else {
getSafeString("default_locale").ifEmpty { Addon.DEFAULT_LOCALE }
}
).lowercase(Locale.ROOT),
)
}
}
@ -502,60 +437,44 @@ internal fun JSONObject.getRating(): Addon.Rating? {
}
}
internal fun JSONObject.getCategories(): List<String> {
val jsonCategories = optJSONObject("categories")
return if (jsonCategories == null) {
emptyList()
} else {
val jsonAndroidCategories = jsonCategories.getSafeJSONArray("android")
(0 until jsonAndroidCategories.length()).map { index ->
jsonAndroidCategories.getString(index)
}
}
}
internal fun JSONObject.getPermissions(): List<String> {
val fileJson = getJSONObject("current_version")
.getSafeJSONArray("files")
.getJSONObject(0)
val permissionsJson = fileJson.getSafeJSONArray("permissions")
val permissionsJson = getFile()?.getSafeJSONArray("permissions") ?: JSONArray()
return (0 until permissionsJson.length()).map { index ->
permissionsJson.getString(index)
}
}
internal fun JSONObject.getCurrentVersion(): String {
return optJSONObject("current_version")?.getSafeString("version") ?: ""
return getJSONObject("current_version").getSafeString("version")
}
internal fun JSONObject.getDownload(): JSONObject? {
return (
getJSONObject("current_version")
.optJSONArray("files")
?.getJSONObject(0)
)
internal fun JSONObject.getFile(): JSONObject? {
return getJSONObject("current_version")
.getSafeJSONArray("files")
.optJSONObject(0)
}
internal fun JSONObject.getDownloadId(): String {
return getSafeString("id")
internal fun JSONObject.getCurrentVersionCreated(): String {
// We want to return: `current_version.files[0].created`.
return getFile()?.getSafeString("created").orEmpty()
}
internal fun JSONObject.getDownloadUrl(): String {
return getSafeString("url")
return getFile()?.getSafeString("url").orEmpty()
}
internal fun JSONObject.getAuthors(): List<Addon.Author> {
internal fun JSONObject.getAuthor(): Addon.Author? {
val authorsJson = getSafeJSONArray("authors")
return (0 until authorsJson.length()).map { index ->
val authorJson = authorsJson.getJSONObject(index)
// We only consider the first author in the AMO API response, mainly because Gecko does the same.
val authorJson = authorsJson.optJSONObject(0)
return if (authorJson != null) {
Addon.Author(
id = authorJson.getSafeString("id"),
name = authorJson.getSafeString("name"),
username = authorJson.getSafeString("username"),
url = authorJson.getSafeString("url"),
)
} else {
null
}
}

@ -1,324 +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 io.github.forkmaintainers.iceraven.components
import android.annotation.SuppressLint
import android.app.Dialog
import android.content.DialogInterface
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.GradientDrawable
import android.os.Bundle
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.Window
import android.widget.Button
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.annotation.ColorRes
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AppCompatDialogFragment
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.AppCompatCheckBox
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import mozilla.components.feature.addons.Addon
import mozilla.components.feature.addons.R
import mozilla.components.feature.addons.databinding.MozacFeatureAddonsFragmentDialogAddonInstalledBinding
import mozilla.components.feature.addons.ui.translateName
import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.ktx.android.content.appName
import mozilla.components.support.ktx.android.content.res.resolveAttribute
import mozilla.components.support.utils.ext.getParcelableCompat
import java.io.IOException
import mozilla.components.ui.icons.R as iconsR
@VisibleForTesting internal const val KEY_INSTALLED_ADDON = "KEY_ADDON"
private const val KEY_DIALOG_GRAVITY = "KEY_DIALOG_GRAVITY"
private const val KEY_DIALOG_WIDTH_MATCH_PARENT = "KEY_DIALOG_WIDTH_MATCH_PARENT"
private const val KEY_CONFIRM_BUTTON_BACKGROUND_COLOR = "KEY_CONFIRM_BUTTON_BACKGROUND_COLOR"
private const val KEY_CONFIRM_BUTTON_TEXT_COLOR = "KEY_CONFIRM_BUTTON_TEXT_COLOR"
private const val KEY_CONFIRM_BUTTON_RADIUS = "KEY_CONFIRM_BUTTON_RADIUS"
@VisibleForTesting internal const val KEY_ICON = "KEY_ICON"
private const val DEFAULT_VALUE = Int.MAX_VALUE
internal const val KEY_ADDON = "KEY_ADDON"
/**
* A dialog that shows [Addon] installation confirmation.
*/
class PagedAddonInstallationDialogFragment : AppCompatDialogFragment() {
private val scope = CoroutineScope(Dispatchers.IO)
@VisibleForTesting internal var iconJob: Job? = null
private val logger = Logger("PagedAddonInstallationDialogFragment")
/**
* A lambda called when the confirm button is clicked.
*/
var onConfirmButtonClicked: ((Addon, Boolean) -> Unit)? = null
/**
* A lambda called when the dialog is dismissed.
*/
var onDismissed: (() -> Unit)? = null
/**
* Reference to the application's [PagedAddonInstallationDialogFragment] to fetch add-on icons.
*/
var addonsProvider: PagedAMOAddonProvider? = null
private val safeArguments get() = requireNotNull(arguments)
internal val addon: Addon
get() {
return requireNotNull(safeArguments.getParcelableCompat(KEY_ADDON, Addon::class.java))
}
private var allowPrivateBrowsing: Boolean = false
internal val confirmButtonRadius
get() =
safeArguments.getFloat(KEY_CONFIRM_BUTTON_RADIUS, DEFAULT_VALUE.toFloat())
internal val dialogGravity: Int
get() =
safeArguments.getInt(
KEY_DIALOG_GRAVITY,
DEFAULT_VALUE,
)
internal val dialogShouldWidthMatchParent: Boolean
get() =
safeArguments.getBoolean(KEY_DIALOG_WIDTH_MATCH_PARENT)
internal val confirmButtonBackgroundColor
get() =
safeArguments.getInt(
KEY_CONFIRM_BUTTON_BACKGROUND_COLOR,
DEFAULT_VALUE,
)
internal val confirmButtonTextColor
get() =
safeArguments.getInt(
KEY_CONFIRM_BUTTON_TEXT_COLOR,
DEFAULT_VALUE,
)
override fun onStop() {
super.onStop()
iconJob?.cancel()
}
override fun onCancel(dialog: DialogInterface) {
super.onCancel(dialog)
onDismissed?.invoke()
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val sheetDialog = Dialog(requireContext())
sheetDialog.requestWindowFeature(Window.FEATURE_NO_TITLE)
sheetDialog.setCanceledOnTouchOutside(true)
val rootView = createContainer()
sheetDialog.setContainerView(rootView)
sheetDialog.window?.apply {
if (dialogGravity != DEFAULT_VALUE) {
setGravity(dialogGravity)
}
if (dialogShouldWidthMatchParent) {
setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
// This must be called after addContentView, or it won't fully fill to the edge.
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
}
}
return sheetDialog
}
private fun Dialog.setContainerView(rootView: View) {
if (dialogShouldWidthMatchParent) {
setContentView(rootView)
} else {
addContentView(
rootView,
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.MATCH_PARENT,
),
)
}
}
@SuppressLint("InflateParams")
private fun createContainer(): View {
val rootView = LayoutInflater.from(requireContext()).inflate(
R.layout.mozac_feature_addons_fragment_dialog_addon_installed,
null,
false,
)
val binding = MozacFeatureAddonsFragmentDialogAddonInstalledBinding.bind(rootView)
rootView.findViewById<TextView>(R.id.title).text =
requireContext().getString(
R.string.mozac_feature_addons_installed_dialog_title,
addon.translateName(requireContext()),
requireContext().appName,
)
val icon = safeArguments.getParcelableCompat(KEY_ICON, Bitmap::class.java)
if (icon != null) {
binding.icon.setImageDrawable(BitmapDrawable(resources, icon))
} else {
iconJob = fetchIcon(addon, binding.icon)
}
val allowedInPrivateBrowsing = rootView.findViewById<AppCompatCheckBox>(R.id.allow_in_private_browsing)
allowedInPrivateBrowsing.setOnCheckedChangeListener { _, isChecked ->
allowPrivateBrowsing = isChecked
}
val confirmButton = rootView.findViewById<Button>(R.id.confirm_button)
confirmButton.setOnClickListener {
onConfirmButtonClicked?.invoke(addon, allowPrivateBrowsing)
dismiss()
}
if (confirmButtonBackgroundColor != DEFAULT_VALUE) {
val backgroundTintList =
AppCompatResources.getColorStateList(requireContext(), confirmButtonBackgroundColor)
confirmButton.backgroundTintList = backgroundTintList
}
if (confirmButtonTextColor != DEFAULT_VALUE) {
val color = ContextCompat.getColor(requireContext(), confirmButtonTextColor)
confirmButton.setTextColor(color)
}
if (confirmButtonRadius != DEFAULT_VALUE.toFloat()) {
val shape = GradientDrawable()
shape.shape = GradientDrawable.RECTANGLE
shape.setColor(
ContextCompat.getColor(
requireContext(),
confirmButtonBackgroundColor,
),
)
shape.cornerRadius = confirmButtonRadius
confirmButton.background = shape
}
return rootView
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun fetchIcon(addon: Addon, iconView: ImageView, scope: CoroutineScope = this.scope): Job {
return scope.launch {
try {
val iconBitmap = addonsProvider?.getAddonIconBitmap(addon)
iconBitmap?.let {
scope.launch(Dispatchers.Main) {
safeArguments.putParcelable(KEY_ICON, it)
iconView.setImageDrawable(BitmapDrawable(iconView.resources, it))
}
}
} catch (e: IOException) {
scope.launch(Dispatchers.Main) {
val context = iconView.context
val att = context.theme.resolveAttribute(android.R.attr.textColorPrimary)
iconView.setColorFilter(ContextCompat.getColor(context, att))
iconView.setImageDrawable(
AppCompatResources.getDrawable(context, iconsR.drawable.mozac_ic_extension_24),
)
}
logger.error("Attempt to fetch the ${addon.id} icon failed", e)
}
}
}
override fun show(manager: FragmentManager, tag: String?) {
// This dialog is shown as a result of an async operation (installing
// an add-on). Once installation succeeds, the activity may already be
// in the process of being destroyed. Since the dialog doesn't have any
// state we need to keep, and since it's also fine to not display the
// dialog at all in case the user navigates away, we can simply use
// commitAllowingStateLoss here to prevent crashing on commit:
// https://github.com/mozilla-mobile/android-components/issues/7782
val ft = manager.beginTransaction()
ft.add(this, tag)
ft.commitAllowingStateLoss()
}
@Suppress("LongParameterList")
companion object {
/**
* Returns a new instance of [AddonInstallationDialogFragment].
* @param addon The addon to show in the dialog.
* @param promptsStyling Styling properties for the dialog.
* @param onConfirmButtonClicked A lambda called when the confirm button is clicked.
*/
fun newInstance(
addon: Addon,
addonsProvider: PagedAMOAddonProvider,
promptsStyling: PromptsStyling? = PromptsStyling(
gravity = Gravity.BOTTOM,
shouldWidthMatchParent = true,
),
onDismissed: (() -> Unit)? = null,
onConfirmButtonClicked: ((Addon, Boolean) -> Unit)? = null,
): PagedAddonInstallationDialogFragment {
val fragment = PagedAddonInstallationDialogFragment()
val arguments = fragment.arguments ?: Bundle()
arguments.apply {
putParcelable(KEY_INSTALLED_ADDON, addon)
promptsStyling?.gravity?.apply {
putInt(KEY_DIALOG_GRAVITY, this)
}
promptsStyling?.shouldWidthMatchParent?.apply {
putBoolean(KEY_DIALOG_WIDTH_MATCH_PARENT, this)
}
promptsStyling?.confirmButtonBackgroundColor?.apply {
putInt(KEY_CONFIRM_BUTTON_BACKGROUND_COLOR, this)
}
promptsStyling?.confirmButtonTextColor?.apply {
putInt(KEY_CONFIRM_BUTTON_TEXT_COLOR, this)
}
}
fragment.onConfirmButtonClicked = onConfirmButtonClicked
fragment.onDismissed = onDismissed
fragment.arguments = arguments
fragment.addonsProvider = addonsProvider
return fragment
}
}
/**
* Styling for the addon installation dialog.
*/
data class PromptsStyling(
val gravity: Int,
val shouldWidthMatchParent: Boolean = false,
@ColorRes
val confirmButtonBackgroundColor: Int? = null,
@ColorRes
val confirmButtonTextColor: Int? = null,
val confirmButtonRadius: Float? = null,
)
}

@ -1,494 +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 io.github.forkmaintainers.iceraven.components
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.graphics.Typeface
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.TransitionDrawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.RatingBar
import android.widget.TextView
import androidx.annotation.ColorRes
import androidx.annotation.DimenRes
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.annotation.VisibleForTesting
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import mozilla.components.feature.addons.Addon
import mozilla.components.feature.addons.R
import mozilla.components.feature.addons.ui.AddonsManagerAdapterDelegate
import mozilla.components.feature.addons.ui.CustomViewHolder
import mozilla.components.feature.addons.ui.CustomViewHolder.AddonViewHolder
import mozilla.components.feature.addons.ui.CustomViewHolder.FooterViewHolder
import mozilla.components.feature.addons.ui.CustomViewHolder.SectionViewHolder
import mozilla.components.feature.addons.ui.CustomViewHolder.UnsupportedSectionViewHolder
import mozilla.components.feature.addons.ui.translateName
import mozilla.components.feature.addons.ui.translateSummary
import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.ktx.android.content.res.resolveAttribute
import java.io.IOException
import java.text.NumberFormat
import java.util.Locale
import mozilla.components.ui.icons.R as iconsR
private const val VIEW_HOLDER_TYPE_SECTION = 0
private const val VIEW_HOLDER_TYPE_NOT_YET_SUPPORTED_SECTION = 1
private const val VIEW_HOLDER_TYPE_ADDON = 2
private const val VIEW_HOLDER_TYPE_FOOTER = 3
/**
* An adapter for displaying add-on items. This will display information related to the state of
* an add-on such as recommended, unsupported or installed. In addition, it will perform actions
* such as installing an add-on.
*
* @property addonsProvider An add-ons provider.
* @property addonsManagerDelegate Delegate that will provides method for handling the add-on items.
* @param addons The list of add-ons to display.
* @property style Indicates how items should look like.
*/
@Suppress("LargeClass")
class PagedAddonsManagerAdapter(
private val addonsProvider: PagedAMOAddonProvider,
private val addonsManagerDelegate: AddonsManagerAdapterDelegate,
addons: List<Addon>,
private val style: Style? = null,
) : ListAdapter<Any, CustomViewHolder>(DifferCallback) {
private val scope = CoroutineScope(Dispatchers.IO)
private val logger = Logger("PagedAddonsManagerAdapter")
/**
* Represents all the add-ons that will be distributed in multiple headers like
* enabled, recommended and unsupported, this help have the data source of the items,
* displayed in the UI.
*/
@VisibleForTesting
internal var addonsMap: MutableMap<String, Addon> = addons.associateBy({ it.id }, { it }).toMutableMap()
init {
submitList(createListWithSections(addons))
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CustomViewHolder {
return when (viewType) {
VIEW_HOLDER_TYPE_ADDON -> createAddonViewHolder(parent)
VIEW_HOLDER_TYPE_SECTION -> createSectionViewHolder(parent)
VIEW_HOLDER_TYPE_NOT_YET_SUPPORTED_SECTION -> createUnsupportedSectionViewHolder(parent)
VIEW_HOLDER_TYPE_FOOTER -> createFooterSectionViewHolder(parent)
else -> throw IllegalArgumentException("Unrecognized viewType")
}
}
private fun createSectionViewHolder(parent: ViewGroup): CustomViewHolder {
val context = parent.context
val inflater = LayoutInflater.from(context)
val view = inflater.inflate(R.layout.mozac_feature_addons_section_item, parent, false)
val titleView = view.findViewById<TextView>(R.id.title)
val divider = view.findViewById<View>(R.id.divider)
return SectionViewHolder(view, titleView, divider)
}
private fun createFooterSectionViewHolder(parent: ViewGroup): CustomViewHolder {
val context = parent.context
val inflater = LayoutInflater.from(context)
val view = inflater.inflate(
R.layout.mozac_feature_addons_footer_section_item,
parent,
false,
)
return FooterViewHolder(view)
}
private fun createUnsupportedSectionViewHolder(parent: ViewGroup): CustomViewHolder {
val context = parent.context
val inflater = LayoutInflater.from(context)
val view = inflater.inflate(
R.layout.mozac_feature_addons_section_unsupported_section_item,
parent,
false,
)
val titleView = view.findViewById<TextView>(R.id.title)
val descriptionView = view.findViewById<TextView>(R.id.description)
return UnsupportedSectionViewHolder(view, titleView, descriptionView)
}
private fun createAddonViewHolder(parent: ViewGroup): AddonViewHolder {
val context = parent.context
val inflater = LayoutInflater.from(context)
val view = inflater.inflate(R.layout.mozac_feature_addons_item, parent, false)
val iconView = view.findViewById<ImageView>(R.id.add_on_icon)
val titleView = view.findViewById<TextView>(R.id.add_on_name)
val summaryView = view.findViewById<TextView>(R.id.add_on_description)
val ratingView = view.findViewById<RatingBar>(R.id.rating)
val ratingAccessibleView = view.findViewById<TextView>(R.id.rating_accessibility)
val userCountView = view.findViewById<TextView>(R.id.users_count)
val addButton = view.findViewById<ImageView>(R.id.add_button)
val allowedInPrivateBrowsingLabel = view.findViewById<ImageView>(R.id.allowed_in_private_browsing_label)
return AddonViewHolder(
view,
iconView,
titleView,
summaryView,
ratingView,
ratingAccessibleView,
userCountView,
addButton,
allowedInPrivateBrowsingLabel,
)
}
override fun getItemViewType(position: Int): Int {
return when (getItem(position)) {
is Addon -> VIEW_HOLDER_TYPE_ADDON
is Section -> VIEW_HOLDER_TYPE_SECTION
is NotYetSupportedSection -> VIEW_HOLDER_TYPE_NOT_YET_SUPPORTED_SECTION
is FooterSection -> VIEW_HOLDER_TYPE_FOOTER
else -> throw IllegalArgumentException("items[position] has unrecognized type")
}
}
override fun onBindViewHolder(holder: CustomViewHolder, position: Int) {
val item = getItem(position)
when (holder) {
is SectionViewHolder -> bindSection(holder, item as Section, position)
is AddonViewHolder -> bindAddon(holder, item as Addon)
is UnsupportedSectionViewHolder -> bindNotYetSupportedSection(
holder,
item as NotYetSupportedSection,
)
is FooterViewHolder -> bindFooterButton(holder)
}
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun bindSection(holder: SectionViewHolder, section: Section, position: Int) {
holder.titleView.setText(section.title)
style?.let {
holder.divider.isVisible = it.visibleDividers && position != 0
it.maybeSetSectionsTextColor(holder.titleView)
it.maybeSetSectionsTypeFace(holder.titleView)
it.maybeSetSectionsDividerStyle(holder.divider)
}
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun bindNotYetSupportedSection(
holder: UnsupportedSectionViewHolder,
section: NotYetSupportedSection,
) {
val unsupportedAddons = addonsMap.values.filter { it.inUnsupportedSection() }
val context = holder.itemView.context
holder.titleView.setText(section.title)
holder.descriptionView.text =
if (unsupportedAddons.size == 1) {
context.getString(R.string.mozac_feature_addons_unsupported_caption)
} else {
context.getString(
R.string.mozac_feature_addons_unsupported_caption_plural,
unsupportedAddons.size.toString(),
)
}
holder.itemView.setOnClickListener {
addonsManagerDelegate.onNotYetSupportedSectionClicked(unsupportedAddons)
}
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun bindFooterButton(
holder: FooterViewHolder,
) {
holder.itemView.setOnClickListener {
addonsManagerDelegate.onFindMoreAddonsButtonClicked()
}
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun bindAddon(holder: AddonViewHolder, addon: Addon) {
val context = holder.itemView.context
addon.rating?.let {
val userCount = context.getString(R.string.mozac_feature_addons_user_rating_count_2)
val ratingContentDescription =
String.format(
context.getString(R.string.mozac_feature_addons_rating_content_description),
it.average,
)
holder.ratingView.contentDescription = ratingContentDescription
// Android RatingBar is not very accessibility-friendly, we will use non visible TextView
// for contentDescription for the TalkBack feature
holder.ratingAccessibleView.text = ratingContentDescription
holder.ratingView.rating = it.average
holder.userCountView.text = String.format(userCount, getFormattedAmount(it.reviews))
}
holder.titleView.text =
if (addon.translatableName.isNotEmpty()) {
addon.translateName(context)
} else {
addon.id
}
if (addon.translatableSummary.isNotEmpty()) {
holder.summaryView.text = addon.translateSummary(context)
} else {
holder.summaryView.visibility = View.GONE
}
holder.itemView.tag = addon
holder.itemView.setOnClickListener {
addonsManagerDelegate.onAddonItemClicked(addon)
}
holder.addButton.isVisible = !addon.isInstalled()
holder.addButton.setOnClickListener {
if (!addon.isInstalled()) {
addonsManagerDelegate.onInstallAddonButtonClicked(addon)
}
}
holder.allowedInPrivateBrowsingLabel.isVisible = addon.isAllowedInPrivateBrowsing()
style?.maybeSetPrivateBrowsingLabelDrawale(holder.allowedInPrivateBrowsingLabel)
fetchIcon(addon, holder.iconView)
style?.maybeSetAddonNameTextColor(holder.titleView)
style?.maybeSetAddonSummaryTextColor(holder.summaryView)
}
@Suppress("MagicNumber")
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun fetchIcon(addon: Addon, iconView: ImageView, scope: CoroutineScope = this.scope): Job {
return scope.launch {
try {
// We calculate how much time takes to fetch an icon,
// if takes less than a second, we assume it comes
// from a cache and we don't show any transition animation.
val startTime = System.currentTimeMillis()
val iconBitmap = addonsProvider.getAddonIconBitmap(addon)
val timeToFetch: Double = (System.currentTimeMillis() - startTime) / 1000.0
val isFromCache = timeToFetch < 1
if (iconBitmap != null) {
scope.launch(Main) {
if (isFromCache) {
iconView.setImageDrawable(BitmapDrawable(iconView.resources, iconBitmap))
} else {
setWithCrossFadeAnimation(iconView, iconBitmap)
}
}
} else if (addon.installedState?.icon != null) {
scope.launch(Main) {
iconView.setImageDrawable(BitmapDrawable(iconView.resources, addon.installedState!!.icon))
}
}
} catch (e: IOException) {
scope.launch(Main) {
val context = iconView.context
val att = context.theme.resolveAttribute(android.R.attr.textColorPrimary)
iconView.setColorFilter(ContextCompat.getColor(context, att))
iconView.setImageDrawable(
AppCompatResources.getDrawable(context, iconsR.drawable.mozac_ic_extension_24),
)
}
logger.error("Attempt to fetch the ${addon.id} icon failed", e)
}
}
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@Suppress("ComplexMethod")
internal fun createListWithSections(addons: List<Addon>, excludedAddonIDs: List<String> = emptyList()): List<Any> {
val itemsWithSections = ArrayList<Any>()
val installedAddons = ArrayList<Addon>()
val recommendedAddons = ArrayList<Addon>()
val disabledAddons = ArrayList<Addon>()
val unsupportedAddons = ArrayList<Addon>()
addons.forEach { addon ->
when {
addon.inUnsupportedSection() -> unsupportedAddons.add(addon)
addon.inRecommendedSection() -> recommendedAddons.add(addon)
addon.inInstalledSection() -> installedAddons.add(addon)
addon.inDisabledSection() -> disabledAddons.add(addon)
}
}
// Add installed section and addons if available
if (installedAddons.isNotEmpty()) {
itemsWithSections.add(Section(R.string.mozac_feature_addons_enabled, false))
itemsWithSections.addAll(installedAddons)
}
// Add disabled section and addons if available
if (disabledAddons.isNotEmpty()) {
itemsWithSections.add(Section(R.string.mozac_feature_addons_disabled_section, true))
itemsWithSections.addAll(disabledAddons)
}
// Add recommended section and addons if available
if (recommendedAddons.isNotEmpty()) {
itemsWithSections.add(Section(R.string.mozac_feature_addons_recommended_section, true))
val filteredRecommendedAddons = recommendedAddons.filter {
it.id !in excludedAddonIDs
}
itemsWithSections.addAll(filteredRecommendedAddons)
}
// Add unsupported section
if (unsupportedAddons.isNotEmpty()) {
itemsWithSections.add(NotYetSupportedSection(R.string.mozac_feature_addons_unavailable_section))
}
if (addonsManagerDelegate.shouldShowFindMoreAddonsButton()) {
itemsWithSections.add(FooterSection)
}
return itemsWithSections
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal data class Section(@StringRes val title: Int, val visibleDivider: Boolean = true)
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal data class NotYetSupportedSection(@StringRes val title: Int)
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal object FooterSection
/**
* Allows to customize how items should look like.
*/
data class Style(
@ColorRes
val sectionsTextColor: Int? = null,
@ColorRes
val addonNameTextColor: Int? = null,
@ColorRes
val addonSummaryTextColor: Int? = null,
val sectionsTypeFace: Typeface? = null,
@DrawableRes
val addonAllowPrivateBrowsingLabelDrawableRes: Int? = null,
val visibleDividers: Boolean = true,
@ColorRes
val dividerColor: Int? = null,
@DimenRes
val dividerHeight: Int? = null,
) {
internal fun maybeSetSectionsTextColor(textView: TextView) {
sectionsTextColor?.let {
val color = ContextCompat.getColor(textView.context, it)
textView.setTextColor(color)
}
}
internal fun maybeSetSectionsTypeFace(textView: TextView) {
sectionsTypeFace?.let {
textView.typeface = it
}
}
internal fun maybeSetAddonNameTextColor(textView: TextView) {
addonNameTextColor?.let {
val color = ContextCompat.getColor(textView.context, it)
textView.setTextColor(color)
}
}
internal fun maybeSetAddonSummaryTextColor(textView: TextView) {
addonSummaryTextColor?.let {
val color = ContextCompat.getColor(textView.context, it)
textView.setTextColor(color)
}
}
internal fun maybeSetPrivateBrowsingLabelDrawale(imageView: ImageView) {
addonAllowPrivateBrowsingLabelDrawableRes?.let {
imageView.setImageDrawable(ContextCompat.getDrawable(imageView.context, it))
}
}
internal fun maybeSetSectionsDividerStyle(divider: View) {
dividerColor?.let {
divider.setBackgroundColor(it)
}
dividerHeight?.let {
divider.layoutParams.height = divider.context.resources.getDimensionPixelOffset(it)
}
}
}
/**
* Update the portion of the list that contains the provided [addon].
* @property addon The add-on to be updated.
*/
fun updateAddon(addon: Addon) {
addonsMap[addon.id] = addon
submitList(createListWithSections(addonsMap.values.toList()))
}
/**
* Updates only the portion of the list that changes between the current list and the new provided [addons].
* Be aware that updating a subset of the visible list is not supported, [addons] will replace
* the current list, but only the add-ons that have been changed will be updated in the UI.
* If you provide a subset it will replace the current list.
* @property addons A list of add-on to replace the actual list.
*/
fun updateAddons(addons: List<Addon>) {
addonsMap = addons.associateBy({ it.id }, { it }).toMutableMap()
submitList(createListWithSections(addons))
}
internal object DifferCallback : DiffUtil.ItemCallback<Any>() {
override fun areItemsTheSame(oldItem: Any, newItem: Any): Boolean {
return when {
oldItem is Addon && newItem is Addon -> oldItem.id == newItem.id
oldItem is Section && newItem is Section -> oldItem.title == newItem.title
oldItem is NotYetSupportedSection && newItem is NotYetSupportedSection -> oldItem.title == newItem.title
else -> false
}
}
@SuppressLint("DiffUtilEquals")
override fun areContentsTheSame(oldItem: Any, newItem: Any): Boolean {
return oldItem == newItem
}
}
internal fun setWithCrossFadeAnimation(image: ImageView, bitmap: Bitmap, durationMillis: Int = 1700) {
with(image) {
val bitmapDrawable = BitmapDrawable(context.resources, bitmap)
val animation = TransitionDrawable(arrayOf(drawable, bitmapDrawable))
animation.isCrossFadeEnabled = true
setImageDrawable(animation)
animation.startTransition(durationMillis)
}
}
}
private fun Addon.inUnsupportedSection() = isInstalled() && !isSupported()
private fun Addon.inRecommendedSection() = !isInstalled()
private fun Addon.inInstalledSection() = isInstalled() && isSupported() && isEnabled()
private fun Addon.inDisabledSection() = isInstalled() && isSupported() && !isEnabled()
/**
* Get the formatted number amount for the current default locale.
*/
internal fun getFormattedAmount(amount: Int): String {
return NumberFormat.getNumberInstance(Locale.getDefault()).format(amount)
}

@ -206,10 +206,6 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity {
ExtensionsProcessDisabledController(this@HomeActivity)
}
private val extensionProcessDisabledPopupFeature by lazy {
ExtensionProcessDisabledController(this@HomeActivity, components.core.store)
}
private val serviceWorkerSupport by lazy {
ServiceWorkerSupportFeature(this)
}

@ -26,8 +26,6 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import io.github.forkmaintainers.iceraven.components.PagedAddonInstallationDialogFragment
import io.github.forkmaintainers.iceraven.components.PagedAddonsManagerAdapter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
@ -40,7 +38,6 @@ import mozilla.components.feature.addons.ui.AddonsManagerAdapter
import mozilla.components.feature.addons.ui.AddonsManagerAdapterDelegate
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.BuildConfig
import org.mozilla.fenix.Config
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.components.FenixSnackbar
@ -73,6 +70,7 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management)
super.onViewCreated(view, savedInstanceState)
binding = FragmentAddOnsManagementBinding.bind(view)
bindRecyclerView()
setupMenu()
(activity as HomeActivity).webExtensionPromptFeature.onAddonChanged = {
runIfFragmentIsAttached {
adapter?.updateAddon(it)
@ -220,7 +218,6 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management)
// If the fragment was launched to install an "external" add-on from AMO, we deactivate
// the cache to get the most up-to-date list of add-ons to match against.
val allowCache = args.installAddonId == null || installExternalAddonComplete
lifecycleScope.launch(IO) {
try {
logger.info("AddonsManagementFragment asking for addons")
@ -229,10 +226,11 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management)
lifecycleScope.launch(Dispatchers.Main) {
runIfFragmentIsAttached {
if (!shouldRefresh) {
adapter = PagedAddonsManagerAdapter(
adapter = AddonsManagerAdapter(
addonsManagerDelegate = managementView,
addons = addons,
style = createAddonStyle(requireContext()),
excludedAddonIDs = emptyList(),
store = requireComponents.core.store
)
}
@ -271,14 +269,14 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management)
}
}
private fun createAddonStyle(context: Context): PagedAddonsManagerAdapter.Style {
private fun createAddonStyle(context: Context): AddonsManagerAdapter.Style {
val sectionsTypeFace = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
Typeface.create(Typeface.DEFAULT, FONT_WEIGHT_MEDIUM, false)
} else {
Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
}
return PagedAddonsManagerAdapter.Style(
return AddonsManagerAdapter.Style(
sectionsTextColor = ThemeManager.resolveAttribute(R.attr.textPrimary, context),
addonNameTextColor = ThemeManager.resolveAttribute(R.attr.textPrimary, context),
addonSummaryTextColor = ThemeManager.resolveAttribute(R.attr.textSecondary, context),

@ -9,7 +9,7 @@ import android.app.Application
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import io.github.forkmaintainers.iceraven.components.PagedAMOAddonProvider
import io.github.forkmaintainers.iceraven.components.PagedAMOAddonsProvider
import androidx.core.app.NotificationManagerCompat
import mozilla.components.feature.addons.AddonManager
import mozilla.components.feature.addons.migration.DefaultSupportedAddonsChecker
@ -110,7 +110,7 @@ class Components(private val context: Context) {
}
val addonsProvider by lazyMonitored {
PagedAMOAddonProvider(
PagedAMOAddonsProvider(
context,
core.client,
serverURL = BuildConfig.AMO_SERVER_URL,

@ -18,9 +18,9 @@ import mozilla.components.browser.state.state.extension.WebExtensionPromptReques
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.webextension.WebExtensionInstallException
import mozilla.components.feature.addons.Addon
import io.github.forkmaintainers.iceraven.components.PagedAddonInstallationDialogFragment as AddonInstallationDialogFragment
import mozilla.components.feature.addons.AddonManager
import mozilla.components.feature.addons.ui.AddonDialogFragment
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

Loading…
Cancel
Save