addon Page locale support

hide ime when submit text
fix forkDebug build
pull/636/head iceraven-2.2.1
akliuxingyuan 1 year ago
parent d41f7ef405
commit 83348b6980

@ -170,10 +170,16 @@ android {
debuggable true
def deepLinkSchemeValue = "iceraven-debug"
buildConfigField "String", "DEEP_LINK_SCHEME", "\"$deepLinkSchemeValue\""
manifestPlaceholders = [
"sharedUserId": "io.github.forkmaintainers.iceraven.sharedID",
"deepLinkScheme": deepLinkSchemeValue,
"targetActivity": targetActivity
]
// Use custom default allowed addon list
buildConfigField "String", "AMO_COLLECTION_USER", "\"16201230\""
buildConfigField "String", "AMO_COLLECTION_NAME", "\"What-I-want-on-Fenix\""
resValue "bool", "IS_DEBUG", "true"
matchingFallbacks = ['debug']
}
forkRelease releaseTemplate >> {
buildConfigField "boolean", "USE_RELEASE_VERSIONING", "true"
@ -182,7 +188,8 @@ android {
buildConfigField "String", "DEEP_LINK_SCHEME", "\"$deepLinkSchemeValue\""
manifestPlaceholders = [
"sharedUserId": "io.github.forkmaintainers.iceraven.sharedID",
"deepLinkScheme": deepLinkSchemeValue
"deepLinkScheme": deepLinkSchemeValue,
"targetActivity": targetActivity
]
// Use custom default allowed addon list
buildConfigField "String", "AMO_COLLECTION_USER", "\"16201230\""
@ -488,6 +495,7 @@ nimbus {
fenixNightly: "nightly",
fenixBeta: "beta",
fenixRelease: "release",
fenixForkDebug: "forkDebug",
fenixForkRelease: "forkRelease"
]
// This is generated by the FML and should be checked into git.
@ -625,6 +633,7 @@ dependencies {
implementation project(':lib-dataprotect')
debugImplementation FenixDependencies.leakcanary
forkDebugImplementation FenixDependencies.leakcanary
implementation FenixDependencies.androidx_annotation
implementation FenixDependencies.androidx_compose_ui

@ -9,6 +9,7 @@ channels:
- beta
- nightly
- developer
- forkDebug
- forkRelease
includes:
- messaging.fml.yaml

@ -1,5 +1,4 @@
<manifest
package="org.mozilla.fenix"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

@ -8,20 +8,31 @@ import android.os.StrictMode
import androidx.preference.PreferenceManager
import leakcanary.AppWatcher
import leakcanary.LeakCanary
import org.mozilla.fenix.ext.application
import org.mozilla.fenix.ext.getPreferenceKey
class DebugFenixApplication : FenixApplication() {
override fun setupLeakCanary() {
if (!AppWatcher.isInstalled) {
AppWatcher.manualInstall(
application = application,
watchersToInstall = AppWatcher.appDefaultWatchers(application),
)
}
val isEnabled = components.strictMode.resetAfter(StrictMode.allowThreadDiskReads()) {
PreferenceManager.getDefaultSharedPreferences(this)
.getBoolean(getPreferenceKey(R.string.pref_key_leakcanary), true)
}
updateLeakCanaryState(isEnabled)
}
override fun updateLeakCanaryState(isEnabled: Boolean) {
AppWatcher.config = AppWatcher.config.copy(enabled = isEnabled)
LeakCanary.config = LeakCanary.config.copy(dumpHeap = isEnabled)
LeakCanary.showLeakDisplayActivityLauncherIcon(isEnabled)
components.strictMode.resetAfter(StrictMode.allowThreadDiskReads()) {
LeakCanary.config = LeakCanary.config.copy(dumpHeap = isEnabled)
}
}
}

@ -3,5 +3,5 @@
- 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/. -->
<resources>
<color name="ic_launcher_background">@color/debug_launcher_background</color>
<color name="ic_launcher_background">@color/photonInk20</color>
</resources>

@ -2,21 +2,20 @@
* 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/. */
@file:Suppress("TooManyFunctions")
package io.github.forkmaintainers.iceraven.components
import android.content.Context
import android.util.AtomicFile
import androidx.annotation.VisibleForTesting
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.AtomicFile
import androidx.annotation.VisibleForTesting
import mozilla.components.concept.fetch.Client
import mozilla.components.concept.fetch.Request
import mozilla.components.concept.fetch.isSuccess
import mozilla.components.feature.addons.Addon
import mozilla.components.feature.addons.AddonsProvider
import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.ktx.kotlin.sanitizeFileName
import mozilla.components.support.ktx.kotlin.sanitizeURL
import mozilla.components.support.ktx.util.readAndDeserialize
import mozilla.components.support.ktx.util.writeString
@ -28,29 +27,35 @@ import org.mozilla.fenix.ext.settings
import java.io.File
import java.io.IOException
import java.util.Date
import java.util.Locale
import java.util.concurrent.TimeUnit
internal const val API_VERSION = "api/v4"
internal const val DEFAULT_SERVER_URL = "https://addons.mozilla.org"
internal const val COLLECTION_FILE_NAME_PREFIX = "%s_components_addon_collection"
internal const val COLLECTION_FILE_NAME = "%s_components_addon_collection_%s.json"
internal const val COLLECTION_FILE_NAME_WITH_LANGUAGE = "%s_components_addon_collection_%s_%s.json"
internal const val REGEX_FILE_NAMES = "%s_components_addon_collection(_\\w+)?_%s.json"
internal const val MINUTE_IN_MS = 60 * 1000
internal const val DEFAULT_READ_TIMEOUT_IN_SECONDS = 20L
/**
* Provide access to the collections AMO API.
* Provide access to the AMO collections API.
* https://addons-server.readthedocs.io/en/latest/topics/api/collections.html
*
* Unlike the android-components version, supports multiple-page responses and
* custom collection accounts.
*
* Needs to extend AddonCollectionProvider because AddonsManagerAdapter won't
* Needs to extend AddonsProvider because AddonsManagerAdapter won't
* take just any AddonsProvider.
*
* @property context A reference to the application context.
* @property client A [Client] for interacting with the AMO HTTP api.
* @property serverURL The url of the endpoint to interact with e.g production, staging
* or testing. Defaults to [DEFAULT_SERVER_URL].
* @property maxCacheAgeInMinutes maximum time (in minutes) the collection cache
* should remain valid. Defaults to -1, meaning no cache is being used by default.
* @property client A reference of [Client] for interacting with the AMO HTTP api.
* should remain valid before a refresh is attempted. Defaults to -1, meaning no
* cache is being used by default
*/
@Suppress("LongParameterList")
class PagedAddonCollectionProvider(
@ -69,7 +74,9 @@ class PagedAddonCollectionProvider(
*/
private fun getCollectionAccount(): String {
var result = context.settings().customAddonsAccount
if (Config.channel.isNightlyOrDebug && context.settings().amoCollectionOverrideConfigured()) {
if (Config.channel.isNightlyOrDebug && context.settings()
.amoCollectionOverrideConfigured()
) {
result = context.settings().overrideAmoUser
}
@ -82,7 +89,9 @@ class PagedAddonCollectionProvider(
*/
private fun getCollectionName(): String {
var result = context.settings().customAddonsCollection
if (Config.channel.isNightlyOrDebug && context.settings().amoCollectionOverrideConfigured()) {
if (Config.channel.isNightlyOrDebug && context.settings()
.amoCollectionOverrideConfigured()
) {
result = context.settings().overrideAmoCollection
}
@ -92,15 +101,19 @@ class PagedAddonCollectionProvider(
/**
* Interacts with the collections endpoint to provide a list of available
* add-ons. May return a cached response, if available, not expired (see
* [maxCacheAgeInMinutes]) and allowed (see [allowCache]).
* add-ons. May return a cached response, if [allowCache] is true, and the
* cache is not expired (see [maxCacheAgeInMinutes]) or fetching from
* AMO failed.
*
* @param allowCache whether or not the result may be provided
* from a previously cached response, defaults to true.
* from a previously cached response, defaults to true. Note that
* [maxCacheAgeInMinutes] must be set for the cache to be active.
* @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 optional language that will be ignored.
* @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.
*/
@ -110,20 +123,29 @@ class PagedAddonCollectionProvider(
readTimeoutInSeconds: Long?,
language: String?,
): List<Addon> {
val cachedAddons = if (allowCache && !cacheExpired(context)) {
readFromDiskCache()
} else {
null
}
// We want to make sure we always use useFallbackFile = false here, as it warranties
// that we are trying to fetch the latest localized add-ons when the user changes
// language from the previous one.
val cachedAvailableAddons =
if (allowCache && !cacheExpired(context, language, useFallbackFile = false)) {
readFromDiskCache(language, useFallbackFile = false)
} else {
null
}
val collectionAccount = getCollectionAccount()
val collectionName = getCollectionName()
if (cachedAddons != null) {
if (cachedAvailableAddons != null) {
logger.info("Providing cached list of addons for $collectionAccount collection $collectionName")
return cachedAddons
return cachedAvailableAddons
} else {
logger.info("Fetching fresh list of addons for $collectionAccount collection $collectionName")
val langParam = if (!language.isNullOrEmpty()) {
"?lang=$language"
} else {
""
}
return getAllPages(
listOf(
serverURL,
@ -133,14 +155,16 @@ class PagedAddonCollectionProvider(
"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())
writeToDiskCache(it.toString(), language)
}
}.getAddons()
deleteUnusedCacheFiles(language)
}.getAddons(language)
}
}
@ -155,11 +179,12 @@ class PagedAddonCollectionProvider(
* a connectivity problem or a timeout.
*/
@Throws(IOException::class)
suspend fun getAllPages(url: String, readTimeoutInSeconds: Long): JSONObject {
fun getAllPages(url: String, readTimeoutInSeconds: Long): JSONObject {
// Fetch and compile all the pages into one object we can return
var compiledResponse: JSONObject? = null
// Each page tells us where to get the next page, if there is one
var nextURL: String? = url
logger.debug("Fetching URI: $nextURL")
while (nextURL != null) {
client.fetch(
Request(
@ -169,7 +194,8 @@ class PagedAddonCollectionProvider(
)
.use { response ->
if (!response.isSuccess) {
val errorMessage = "Failed to fetch addon collection. Status code: ${response.status}"
val errorMessage =
"Failed to fetch addon collection. Status code: ${response.status}"
logger.error(errorMessage)
throw IOException(errorMessage)
}
@ -183,9 +209,11 @@ class PagedAddonCollectionProvider(
compiledResponse = currentResponse
} else {
// Write the addons into the first response
compiledResponse!!.getJSONArray("results").concat(currentResponse.getJSONArray("results"))
compiledResponse!!.getJSONArray("results")
.concat(currentResponse.getJSONArray("results"))
}
nextURL = if (currentResponse.isNull("next")) null else currentResponse.getString("next")
nextURL =
if (currentResponse.isNull("next")) null else currentResponse.getString("next")
}
}
return compiledResponse!!
@ -215,63 +243,139 @@ class PagedAddonCollectionProvider(
}
@VisibleForTesting
internal fun writeToDiskCache(collectionResponse: String) {
internal fun writeToDiskCache(collectionResponse: String, language: String?) {
logger.info("Storing cache file")
synchronized(diskCacheLock) {
getCacheFile(context).writeString { collectionResponse }
getCacheFile(
context,
language,
useFallbackFile = false
).writeString { collectionResponse }
}
}
@VisibleForTesting
internal fun readFromDiskCache(): List<Addon>? {
internal fun readFromDiskCache(language: String?, useFallbackFile: Boolean): List<Addon>? {
logger.info("Loading cache file")
synchronized(diskCacheLock) {
return getCacheFile(context).readAndDeserialize {
JSONObject(it).getAddons()
return getCacheFile(context, language, useFallbackFile).readAndDeserialize {
JSONObject(it).getAddons(language)
}
}
}
/**
* Deletes cache files from previous (now unused) collections.
*/
@VisibleForTesting
internal fun cacheExpired(context: Context): Boolean {
return getCacheLastUpdated(context) < Date().time - maxCacheAgeInMinutes * MINUTE_IN_MS
internal fun deleteUnusedCacheFiles(language: String?) {
val currentCacheFileName = getBaseCacheFile(context, language, useFallbackFile = true).name
context.filesDir
.listFiles { _, s ->
s.startsWith(COLLECTION_FILE_NAME_PREFIX.format(getCollectionAccount())) && s != currentCacheFileName
}
?.forEach {
logger.debug("Deleting unused collection cache: " + it.name)
it.delete()
}
}
@VisibleForTesting
internal fun getCacheLastUpdated(context: Context): Long {
val file = getBaseCacheFile(context)
internal fun cacheExpired(
context: Context,
language: String?,
useFallbackFile: Boolean
): Boolean {
return getCacheLastUpdated(
context,
language,
useFallbackFile,
) < Date().time - maxCacheAgeInMinutes * MINUTE_IN_MS
}
@VisibleForTesting
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): AtomicFile {
return AtomicFile(getBaseCacheFile(context))
private fun getCacheFile(
context: Context,
language: String?,
useFallbackFile: Boolean
): AtomicFile {
return AtomicFile(getBaseCacheFile(context, language, useFallbackFile))
}
private fun getBaseCacheFile(context: Context): File {
@VisibleForTesting
internal fun getBaseCacheFile(
context: Context,
language: String?,
useFallbackFile: Boolean
): File {
val collectionAccount = getCollectionAccount()
val collectionName = getCollectionName()
return File(context.filesDir, COLLECTION_FILE_NAME.format(collectionAccount, collectionName))
var file = File(context.filesDir, getCacheFileName(language))
if (!file.exists() && useFallbackFile) {
// In situations, where users change languages and we can't retrieve the new one,
// we always want to fallback to the previous localized file.
// Try to find first available localized file.
val regex = Regex(REGEX_FILE_NAMES.format(collectionAccount, collectionName))
val fallbackFile = context.filesDir.listFiles()?.find { it.name.matches(regex) }
if (fallbackFile?.exists() == true) {
file = fallbackFile
}
}
return file
}
fun deleteCacheFile(context: Context): Boolean {
@VisibleForTesting
internal fun getCacheFileName(language: String? = ""): String {
val collectionAccount = getCollectionAccount()
val collectionName = getCollectionName()
val fileName = if (language.isNullOrEmpty()) {
COLLECTION_FILE_NAME.format(collectionAccount, collectionName)
} else {
COLLECTION_FILE_NAME_WITH_LANGUAGE.format(collectionAccount, language, collectionName)
}
return fileName.sanitizeFileName()
}
fun deleteCacheFile() {
logger.info("Clearing cache file")
synchronized(diskCacheLock) {
val file = getBaseCacheFile(context)
return if (file.exists()) file.delete() else false
//val file = getBaseCacheFile(context, language, useFallbackFile = true)
//return if (file.exists()) file.delete() else false
context.filesDir.listFiles { _, s ->
s.contains("components_addon_collection")
}?.forEach {
logger.debug("Deleting collection files ${it.name}")
it.delete()
}
}
}
}
internal fun JSONObject.getAddons(): List<Addon> {
internal fun JSONObject.getAddons(language: String? = null): List<Addon> {
val addonsJson = getJSONArray("results")
return (0 until addonsJson.length()).map { index ->
addonsJson.getJSONObject(index).toAddons()
addonsJson.getJSONObject(index).toAddons(language)
}
}
internal fun JSONObject.toAddons(): Addon {
internal fun JSONObject.toAddons(language: String? = null): Addon {
return with(getJSONObject("addon")) {
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(),
@ -282,13 +386,19 @@ internal fun JSONObject.toAddons(): Addon {
downloadUrl = download?.getDownloadUrl() ?: "",
version = getCurrentVersion(),
permissions = getPermissions(),
translatableName = getSafeMap("name"),
translatableDescription = getSafeMap("description"),
translatableSummary = getSafeMap("summary"),
translatableName = getSafeTranslations("name", safeLanguage),
translatableDescription = getSafeTranslations("description", safeLanguage),
translatableSummary = summary,
iconUrl = getSafeString("icon_url"),
siteUrl = getSafeString("url"),
rating = getRating(),
defaultLocale = getSafeString("default_locale").ifEmpty { Addon.DEFAULT_LOCALE },
defaultLocale = (
if (!safeLanguage.isNullOrEmpty() && isLanguageInTranslations) {
safeLanguage
} else {
getSafeString("default_locale").ifEmpty { Addon.DEFAULT_LOCALE }
}
).lowercase(Locale.ROOT),
)
}
}
@ -378,6 +488,19 @@ internal fun JSONObject.getSafeJSONArray(key: String): JSONArray {
}
}
internal fun JSONObject.getSafeTranslations(valueKey: String, language: String?): Map<String, String> {
// We can have two different versions of the JSON structure for translatable fields:
// 1) A string with only one language, when we provide a language parameter.
// 2) An object containing all the languages available when a language parameter is NOT present.
// For this reason, we have to be specific about how we parse the JSON.
return if (get(valueKey) is String) {
val safeLanguage = (language ?: Addon.DEFAULT_LOCALE).lowercase(Locale.ROOT)
mapOf(safeLanguage to getSafeString(valueKey))
} else {
getSafeMap(valueKey)
}
}
internal fun JSONObject.getSafeMap(valueKey: String): Map<String, String> {
return if (isNull(valueKey)) {
emptyMap()
@ -387,7 +510,7 @@ internal fun JSONObject.getSafeMap(valueKey: String): Map<String, String> {
jsonObject.keys()
.forEach { key ->
map[key] = jsonObject.getSafeString(key)
map[key.lowercase(Locale.ROOT)] = jsonObject.getSafeString(key)
}
map
}

@ -24,6 +24,7 @@ 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
@ -33,7 +34,6 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import mozilla.components.feature.addons.Addon
import mozilla.components.feature.addons.R
import mozilla.components.ui.icons.R as iconsR
import mozilla.components.feature.addons.databinding.MozacFeatureAddonsFragmentDialogAddonInstalledBinding
import mozilla.components.feature.addons.ui.translateName
import mozilla.components.support.base.log.logger.Logger
@ -41,6 +41,7 @@ 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"
@ -52,14 +53,10 @@ 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.
*/
// We have an extra "Lint" Android Studio linter pass that Android Components
// where the original code came from doesn't. So we tell it to ignore us. Make
// sure to keep up with changes in Android Components though.
@SuppressLint("all")
class PagedAddonInstallationDialogFragment : AppCompatDialogFragment() {
private val scope = CoroutineScope(Dispatchers.IO)
@ -72,13 +69,16 @@ class PagedAddonInstallationDialogFragment : AppCompatDialogFragment() {
var onConfirmButtonClicked: ((Addon, Boolean) -> Unit)? = null
/**
* Reference to the application's [PagedAddonCollectionProvider] to fetch add-on icons.
* Reference to the application's [PagedAddonInstallationDialogFragment] to fetch add-on icons.
*/
var addonCollectionProvider: PagedAddonCollectionProvider? = null
private val safeArguments get() = requireNotNull(arguments)
internal val addon get() = requireNotNull(safeArguments.getParcelableCompat(KEY_ADDON, Addon::class.java))
internal val addon: Addon
get() {
return requireNotNull(safeArguments.getParcelableCompat(KEY_ADDON, Addon::class.java))
}
private var allowPrivateBrowsing: Boolean = false
internal val confirmButtonRadius
@ -189,7 +189,7 @@ class PagedAddonInstallationDialogFragment : AppCompatDialogFragment() {
if (confirmButtonBackgroundColor != DEFAULT_VALUE) {
val backgroundTintList =
ContextCompat.getColorStateList(requireContext(), confirmButtonBackgroundColor)
AppCompatResources.getColorStateList(requireContext(), confirmButtonBackgroundColor)
confirmButton.backgroundTintList = backgroundTintList
}
@ -231,7 +231,7 @@ class PagedAddonInstallationDialogFragment : AppCompatDialogFragment() {
val att = context.theme.resolveAttribute(android.R.attr.textColorPrimary)
iconView.setColorFilter(ContextCompat.getColor(context, att))
iconView.setImageDrawable(
ContextCompat.getDrawable(context, iconsR.drawable.mozac_ic_extensions),
AppCompatResources.getDrawable(context, iconsR.drawable.mozac_ic_extensions),
)
}
logger.error("Attempt to fetch the ${addon.id} icon failed", e)
@ -309,5 +309,3 @@ class PagedAddonInstallationDialogFragment : AppCompatDialogFragment() {
val confirmButtonRadius: Float? = null,
)
}
internal const val KEY_ADDON = "KEY_ADDON"

@ -16,9 +16,11 @@ 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
@ -30,7 +32,6 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import mozilla.components.feature.addons.Addon
import mozilla.components.feature.addons.R
import mozilla.components.ui.icons.R as iconsR
import mozilla.components.feature.addons.ui.AddonsManagerAdapterDelegate
import mozilla.components.feature.addons.ui.CustomViewHolder
import mozilla.components.feature.addons.ui.CustomViewHolder.AddonViewHolder
@ -43,6 +44,7 @@ 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
@ -57,12 +59,9 @@ private const val VIEW_HOLDER_TYPE_ADDON = 2
* @property addonsManagerDelegate Delegate that will provides method for handling the add-on items.
* @param addons The list of add-on based on the AMO store.
* @property style Indicates how items should look like.
* @property excludedAddonIDs The list of add-on IDs to be excluded from the recommended section.
*/
@Suppress("TooManyFunctions", "LargeClass")
// We have an extra "Lint" Android Studio linter pass that Android Components
// where the original code came from doesn't. So we tell it to ignore us. Make
// sure to keep up with changes in Android Components though.
@SuppressLint("all")
@Suppress("LargeClass")
class PagedAddonsManagerAdapter(
private val addonCollectionProvider: PagedAddonCollectionProvider,
private val addonsManagerDelegate: AddonsManagerAdapterDelegate,
@ -154,7 +153,7 @@ class PagedAddonsManagerAdapter(
val item = getItem(position)
when (holder) {
is SectionViewHolder -> bindSection(holder, item as Section)
is SectionViewHolder -> bindSection(holder, item as Section, position)
is AddonViewHolder -> bindAddon(holder, item as Addon)
is UnsupportedSectionViewHolder -> bindNotYetSupportedSection(
holder,
@ -164,10 +163,15 @@ class PagedAddonsManagerAdapter(
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun bindSection(holder: SectionViewHolder, section: Section) {
internal fun bindSection(holder: SectionViewHolder, section: Section, position: Int) {
holder.titleView.setText(section.title)
style?.maybeSetSectionsTextColor(holder.titleView)
style?.maybeSetSectionsTypeFace(holder.titleView)
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)
@ -256,21 +260,27 @@ class PagedAddonsManagerAdapter(
val iconBitmap = addonCollectionProvider.getAddonIconBitmap(addon)
val timeToFetch: Double = (System.currentTimeMillis() - startTime) / 1000.0
val isFromCache = timeToFetch < 1
iconBitmap?.let {
if (iconBitmap != null) {
scope.launch(Main) {
if (isFromCache) {
iconView.setImageDrawable(BitmapDrawable(iconView.resources, it))
iconView.setImageDrawable(BitmapDrawable(iconView.resources, iconBitmap))
} else {
setWithCrossFadeAnimation(iconView, it)
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(context.getDrawable(iconsR.drawable.mozac_ic_extensions))
iconView.setImageDrawable(
AppCompatResources.getDrawable(context, iconsR.drawable.mozac_ic_extensions),
)
}
logger.error("Attempt to fetch the ${addon.id} icon failed", e)
}
@ -279,7 +289,7 @@ class PagedAddonsManagerAdapter(
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@Suppress("ComplexMethod")
internal fun createListWithSections(addons: List<Addon>): List<Any> {
internal fun createListWithSections(addons: List<Addon>, excludedAddonIDs: List<String> = emptyList()): List<Any> {
val itemsWithSections = ArrayList<Any>()
val installedAddons = ArrayList<Addon>()
val recommendedAddons = ArrayList<Addon>()
@ -297,20 +307,23 @@ class PagedAddonsManagerAdapter(
// Add installed section and addons if available
if (installedAddons.isNotEmpty()) {
itemsWithSections.add(Section(R.string.mozac_feature_addons_enabled))
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))
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))
itemsWithSections.addAll(recommendedAddons)
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
@ -322,7 +335,7 @@ class PagedAddonsManagerAdapter(
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal data class Section(@StringRes val title: Int)
internal data class Section(@StringRes val title: Int, val visibleDivider: Boolean = true)
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal data class NotYetSupportedSection(@StringRes val title: Int)
@ -340,6 +353,11 @@ class PagedAddonsManagerAdapter(
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 {
@ -373,6 +391,15 @@ class PagedAddonsManagerAdapter(
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)
}
}
}
/**

@ -11,22 +11,24 @@ import android.os.Build
import android.os.Bundle
import android.view.Gravity
import android.view.Menu
import android.view.MenuItem
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.accessibility.AccessibilityEvent
import android.view.inputmethod.EditorInfo
import androidx.annotation.VisibleForTesting
import androidx.appcompat.widget.SearchView
import androidx.core.view.isVisible
import androidx.core.view.MenuHost
import androidx.core.view.MenuProvider
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
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
@ -35,6 +37,7 @@ import mozilla.components.feature.addons.AddonManagerException
import mozilla.components.feature.addons.ui.PermissionsDialogFragment
import mozilla.components.feature.addons.ui.translateName
import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.ktx.android.view.hideKeyboard
import org.mozilla.fenix.R
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.databinding.FragmentAddOnsManagementBinding
@ -45,9 +48,8 @@ import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.theme.ThemeManager
import java.lang.ref.WeakReference
import java.util.Locale
import java.util.concurrent.CancellationException
import io.github.forkmaintainers.iceraven.components.PagedAddonInstallationDialogFragment
import io.github.forkmaintainers.iceraven.components.PagedAddonsManagerAdapter
/**
* Fragment use for managing add-ons.
@ -83,14 +85,14 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
logger.info("View created for AddonsManagementFragment")
super.onViewCreated(view, savedInstanceState)
setupMenu()
binding = FragmentAddOnsManagementBinding.bind(view)
bindRecyclerView()
setupMenu()
}
private fun setupMenu() {
val menuHost: MenuHost = requireActivity()
val menuHost = requireActivity() as MenuHost
menuHost.addMenuProvider(
object : MenuProvider {
@ -104,11 +106,13 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management)
searchView.setOnQueryTextListener(
object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String): Boolean {
return searchAddons(query.trim())
searchAddons(query.trim())
return false
}
override fun onQueryTextChange(newText: String): Boolean {
return searchAddons(newText.trim())
searchAddons(newText.trim())
return false
}
},
)
@ -123,20 +127,28 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management)
)
}
private fun searchAddons(addonNameSubStr: String): Boolean {
private fun searchAddons(addonSearchText: String): Boolean {
if (adapter == null) {
return false
}
val searchedAddons = arrayListOf<Addon>()
addons?.forEach { addon ->
val names = addon.translatableName
names["en-US"]?.let { name ->
if (name.lowercase().contains(addonNameSubStr.lowercase())) {
val language = Locale.getDefault().language
names[language]?.let { name ->
if (name.lowercase().contains(addonSearchText.lowercase())) {
searchedAddons.add(addon)
}
}
val description = addon.translatableDescription
description[language]?.let { desc ->
if (desc.lowercase().contains(addonSearchText.lowercase())) {
if (!searchedAddons.contains(addon)) {
searchedAddons.add(addon)
}
}
}
}
updateUI(searchedAddons)
@ -161,6 +173,7 @@ class AddonsManagementFragment : Fragment(R.layout.fragment_add_ons_management)
super.onResume()
showToolbar(getString(R.string.preferences_addons))
view?.hideKeyboard()
}
override fun onStart() {

@ -108,7 +108,7 @@ class Components(private val context: Context) {
}
fun clearAddonCache() {
addonCollectionProvider.deleteCacheFile(context)
addonCollectionProvider.deleteCacheFile()
}
@Suppress("MagicNumber")

Loading…
Cancel
Save