diff --git a/app/build.gradle b/app/build.gradle index 2b6bbc7bd7..75b93ce9ae 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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 diff --git a/app/nimbus.fml.yaml b/app/nimbus.fml.yaml index dd3df22176..e5b2201204 100644 --- a/app/nimbus.fml.yaml +++ b/app/nimbus.fml.yaml @@ -9,6 +9,7 @@ channels: - beta - nightly - developer + - forkDebug - forkRelease includes: - messaging.fml.yaml diff --git a/app/src/forkDebug/AndroidManifest.xml b/app/src/forkDebug/AndroidManifest.xml index 3d25c0db6b..1685e51397 100644 --- a/app/src/forkDebug/AndroidManifest.xml +++ b/app/src/forkDebug/AndroidManifest.xml @@ -1,5 +1,4 @@ diff --git a/app/src/forkDebug/java/org/mozilla/fenix/DebugFenixApplication.kt b/app/src/forkDebug/java/org/mozilla/fenix/DebugFenixApplication.kt index 7825870b01..c57f7db011 100644 --- a/app/src/forkDebug/java/org/mozilla/fenix/DebugFenixApplication.kt +++ b/app/src/forkDebug/java/org/mozilla/fenix/DebugFenixApplication.kt @@ -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) + } } } diff --git a/app/src/forkDebug/res/values/colors.xml b/app/src/forkDebug/res/values/colors.xml index 608bd8cedb..31d64e9680 100644 --- a/app/src/forkDebug/res/values/colors.xml +++ b/app/src/forkDebug/res/values/colors.xml @@ -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/. --> - @color/debug_launcher_background + @color/photonInk20 diff --git a/app/src/main/java/io/github/forkmaintainers/iceraven/components/PagedAddonCollectionProvider.kt b/app/src/main/java/io/github/forkmaintainers/iceraven/components/PagedAddonCollectionProvider.kt index a471d3accc..4e227151eb 100644 --- a/app/src/main/java/io/github/forkmaintainers/iceraven/components/PagedAddonCollectionProvider.kt +++ b/app/src/main/java/io/github/forkmaintainers/iceraven/components/PagedAddonCollectionProvider.kt @@ -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 { - 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? { + internal fun readFromDiskCache(language: String?, useFallbackFile: Boolean): List? { 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 { +internal fun JSONObject.getAddons(language: String? = null): List { 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 { + // 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 { return if (isNull(valueKey)) { emptyMap() @@ -387,7 +510,7 @@ internal fun JSONObject.getSafeMap(valueKey: String): Map { jsonObject.keys() .forEach { key -> - map[key] = jsonObject.getSafeString(key) + map[key.lowercase(Locale.ROOT)] = jsonObject.getSafeString(key) } map } diff --git a/app/src/main/java/io/github/forkmaintainers/iceraven/components/PagedAddonInstallationDialogFragment.kt b/app/src/main/java/io/github/forkmaintainers/iceraven/components/PagedAddonInstallationDialogFragment.kt index a802c0e8b1..27b3a61686 100644 --- a/app/src/main/java/io/github/forkmaintainers/iceraven/components/PagedAddonInstallationDialogFragment.kt +++ b/app/src/main/java/io/github/forkmaintainers/iceraven/components/PagedAddonInstallationDialogFragment.kt @@ -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" diff --git a/app/src/main/java/io/github/forkmaintainers/iceraven/components/PagedAddonsManagerAdapter.kt b/app/src/main/java/io/github/forkmaintainers/iceraven/components/PagedAddonsManagerAdapter.kt index 6a8530b917..5e5d07935c 100644 --- a/app/src/main/java/io/github/forkmaintainers/iceraven/components/PagedAddonsManagerAdapter.kt +++ b/app/src/main/java/io/github/forkmaintainers/iceraven/components/PagedAddonsManagerAdapter.kt @@ -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): List { + internal fun createListWithSections(addons: List, excludedAddonIDs: List = emptyList()): List { val itemsWithSections = ArrayList() val installedAddons = ArrayList() val recommendedAddons = ArrayList() @@ -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) + } + } } /** diff --git a/app/src/main/java/org/mozilla/fenix/addons/AddonsManagementFragment.kt b/app/src/main/java/org/mozilla/fenix/addons/AddonsManagementFragment.kt index e5a60cf934..e574311f7f 100644 --- a/app/src/main/java/org/mozilla/fenix/addons/AddonsManagementFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/addons/AddonsManagementFragment.kt @@ -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() - 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() { diff --git a/app/src/main/java/org/mozilla/fenix/components/Components.kt b/app/src/main/java/org/mozilla/fenix/components/Components.kt index ee3abfdf37..bb9c950b1e 100644 --- a/app/src/main/java/org/mozilla/fenix/components/Components.kt +++ b/app/src/main/java/org/mozilla/fenix/components/Components.kt @@ -108,7 +108,7 @@ class Components(private val context: Context) { } fun clearAddonCache() { - addonCollectionProvider.deleteCacheFile(context) + addonCollectionProvider.deleteCacheFile() } @Suppress("MagicNumber")