Add duplicate copies of the Android Components pieces that need to change to support a nerw add-on source

pull/35/head
Adam Novak 4 years ago
parent 94ce9bb335
commit 18dc288f35

@ -0,0 +1,349 @@
/* 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/. */
@file:Suppress("TooManyFunctions")
package network.novak.fenix.components
import android.content.Context
import android.util.AtomicFile
import androidx.annotation.VisibleForTesting
import android.graphics.Bitmap
import android.graphics.BitmapFactory
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.feature.addons.amo.AddonCollectionProvider
import mozilla.components.support.base.log.logger.Logger
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 java.io.File
import java.io.IOException
import java.util.Date
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 DEFAULT_COLLECTION_ACCOUNT = "mozilla"
internal const val DEFAULT_COLLECTION_NAME = "7e8d6dc651b54ab385fb8791bf9dac"
internal const val COLLECTION_FILE_NAME = "%s_components_addon_collection_%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.
* 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
* take just any AddonsProvider.
*
* @property serverURL The url of the endpoint to interact with e.g production, staging
* or testing. Defaults to [DEFAULT_SERVER_URL].
* @property collectionAccount The account owning the collection to access, defaults
* to [DEFAULT_COLLECTION_ACCOUNT].
* @property collectionName The name of the collection to access, defaults
* to [DEFAULT_COLLECTION_NAME].
* @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.
*/
@Suppress("LongParameterList")
class PagedAddonCollectionProvider(
private val context: Context,
private val client: Client,
private val serverURL: String = DEFAULT_SERVER_URL,
private val collectionAccount: String = DEFAULT_COLLECTION_ACCOUNT,
private val collectionName: String = DEFAULT_COLLECTION_NAME,
private val maxCacheAgeInMinutes: Long = -1
) : AddonsProvider {
private val logger = Logger("PagedAddonCollectionProvider")
private val diskCacheLock = Any()
/**
* 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]).
*
* @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.
* @throws IOException if the request failed, or could not be executed due to cancellation,
* a connectivity problem or a timeout.
*/
@Throws(IOException::class)
override suspend fun getAvailableAddons(allowCache: Boolean, readTimeoutInSeconds: Long?): List<Addon> {
val cachedAddons = if (allowCache && !cacheExpired(context)) {
readFromDiskCache()
} else {
null
}
if (cachedAddons != null) {
return cachedAddons
} else {
return getAllPages(listOf(
serverURL,
API_VERSION,
"accounts/account",
collectionAccount,
"collections",
collectionName,
"addons"
).joinToString("/"), readTimeoutInSeconds ?: DEFAULT_READ_TIMEOUT_IN_SECONDS).also {
// Cache the JSON object before we parse out the addons
if (maxCacheAgeInMinutes > 0) {
writeToDiskCache(it.toString())
}
}.getAddons()
}
}
/**
* Fetches all pages of add-ons from the given URL (following the "next"
* field in the returned JSON) and combines the "results" arrays into that
* of the first page. Returns that coalesced object.
*
* @param url URL of the first page to fetch
* @param readTimeoutInSeconds timeout in seconds to use when fetching each page.
* @throws IOException if the request failed, or could not be executed due to cancellation,
* a connectivity problem or a timeout.
*/
@Throws(IOException::class)
suspend 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
while (nextURL != null) {
client.fetch(
Request(
url = nextURL,
readTimeout = Pair(readTimeoutInSeconds, TimeUnit.SECONDS)
)
)
.use { response ->
if (!response.isSuccess) {
val errorMessage = "Failed to fetch addon collection. Status code: ${response.status}"
logger.error(errorMessage)
throw IOException(errorMessage)
}
val currentResponse = try {
JSONObject(response.body.string(Charsets.UTF_8))
} catch (e: JSONException) {
throw IOException(e)
}
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!!
}
/**
* 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.
*/
@Throws(IOException::class)
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)
}
}
}
}
return bitmap
}
@VisibleForTesting
internal fun writeToDiskCache(collectionResponse: String) {
synchronized(diskCacheLock) {
getCacheFile(context).writeString { collectionResponse }
}
}
@VisibleForTesting
internal fun readFromDiskCache(): List<Addon>? {
synchronized(diskCacheLock) {
return getCacheFile(context).readAndDeserialize {
JSONObject(it).getAddons()
}
}
}
@VisibleForTesting
internal fun cacheExpired(context: Context): Boolean {
return getCacheLastUpdated(context) < Date().time - maxCacheAgeInMinutes * MINUTE_IN_MS
}
@VisibleForTesting
internal fun getCacheLastUpdated(context: Context): Long {
val file = getBaseCacheFile(context)
return if (file.exists()) file.lastModified() else -1
}
private fun getCacheFile(context: Context): AtomicFile {
return AtomicFile(getBaseCacheFile(context))
}
private fun getBaseCacheFile(context: Context): File {
return File(context.filesDir, COLLECTION_FILE_NAME.format(collectionAccount, collectionName))
}
}
internal fun JSONObject.getAddons(): List<Addon> {
val addonsJson = getJSONArray("results")
return (0 until addonsJson.length()).map { index ->
addonsJson.getJSONObject(index).toAddons()
}
}
internal fun JSONObject.toAddons(): Addon {
return with(getJSONObject("addon")) {
Addon(
id = getSafeString("guid"),
authors = getAuthors(),
categories = getCategories(),
createdAt = getSafeString("created"),
updatedAt = getSafeString("last_updated"),
downloadUrl = getDownloadUrl(),
version = getCurrentVersion(),
permissions = getPermissions(),
translatableName = getSafeMap("name"),
translatableDescription = getSafeMap("description"),
translatableSummary = getSafeMap("summary"),
iconUrl = getSafeString("icon_url"),
siteUrl = getSafeString("url"),
rating = getRating(),
defaultLocale = getSafeString("default_locale").ifEmpty { Addon.DEFAULT_LOCALE }
)
}
}
internal fun JSONObject.getRating(): Addon.Rating? {
val jsonRating = optJSONObject("ratings")
return if (jsonRating != null) {
Addon.Rating(
reviews = jsonRating.optInt("count"),
average = jsonRating.optDouble("average").toFloat()
)
} else {
null
}
}
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")
return (0 until permissionsJson.length()).map { index ->
permissionsJson.getString(index)
}
}
internal fun JSONObject.getCurrentVersion(): String {
return optJSONObject("current_version")?.getSafeString("version") ?: ""
}
internal fun JSONObject.getDownloadUrl(): String {
return (getJSONObject("current_version")
.optJSONArray("files")
?.getJSONObject(0))
?.getSafeString("url") ?: ""
}
internal fun JSONObject.getAuthors(): List<Addon.Author> {
val authorsJson = getSafeJSONArray("authors")
return (0 until authorsJson.length()).map { index ->
val authorJson = authorsJson.getJSONObject(index)
Addon.Author(
id = authorJson.getSafeString("id"),
name = authorJson.getSafeString("name"),
username = authorJson.getSafeString("username"),
url = authorJson.getSafeString("url")
)
}
}
internal fun JSONObject.getSafeString(key: String): String {
return if (isNull(key)) {
""
} else {
getString(key)
}
}
internal fun JSONObject.getSafeJSONArray(key: String): JSONArray {
return if (isNull(key)) {
JSONArray("[]")
} else {
getJSONArray(key)
}
}
internal fun JSONObject.getSafeMap(valueKey: String): Map<String, String> {
return if (isNull(valueKey)) {
emptyMap()
} else {
val map = mutableMapOf<String, String>()
val jsonObject = getJSONObject(valueKey)
jsonObject.keys()
.forEach { key ->
map[key] = jsonObject.getSafeString(key)
}
map
}
}
/**
* Concatenates the given JSONArray onto this one.
*/
internal fun JSONArray.concat(other: JSONArray) {
(0 until other.length()).map { index ->
put(length(), other.getJSONObject(index))
}
}

@ -0,0 +1,303 @@
/* 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 network.novak.fenix.components
import android.annotation.SuppressLint
import android.app.Dialog
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.widget.AppCompatCheckBox
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentManager
import kotlinx.android.synthetic.main.mozac_feature_addons_fragment_dialog_addon_installed.view.*
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.amo.AddonCollectionProvider
import mozilla.components.feature.addons.ui.translatedName
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 network.novak.fenix.components.PagedAddonCollectionProvider
import java.io.IOException
@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
/**
* 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
/**
* Reference to the application's [AddonCollectionProvider] to fetch add-on icons.
*/
var addonCollectionProvider: PagedAddonCollectionProvider? = null
private val safeArguments get() = requireNotNull(arguments)
internal val addon get() = requireNotNull(safeArguments.getParcelable<Addon>(KEY_ADDON))
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 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
)
rootView.findViewById<TextView>(R.id.title).text =
requireContext().getString(
R.string.mozac_feature_addons_installed_dialog_title,
addon.translatedName,
requireContext().appName
)
val icon = safeArguments.getParcelable<Bitmap>(KEY_ICON)
if (icon != null) {
rootView.icon.setImageDrawable(BitmapDrawable(resources, icon))
} else {
iconJob = fetchIcon(addon, rootView.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 =
ContextCompat.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 = addonCollectionProvider?.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(context.getDrawable(R.drawable.mozac_ic_extensions))
}
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,
addonCollectionProvider: PagedAddonCollectionProvider,
promptsStyling: PromptsStyling? = PromptsStyling(
gravity = Gravity.BOTTOM,
shouldWidthMatchParent = true
),
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.arguments = arguments
fragment.addonCollectionProvider = addonCollectionProvider
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
)
}
internal const val KEY_ADDON = "KEY_ADDON"

@ -0,0 +1,432 @@
/* 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 network.novak.fenix.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.DrawableRes
import androidx.annotation.StringRes
import androidx.annotation.VisibleForTesting
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.AddonsProvider
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.SectionViewHolder
import mozilla.components.feature.addons.ui.CustomViewHolder.UnsupportedSectionViewHolder
import mozilla.components.feature.addons.ui.translatedName
import mozilla.components.feature.addons.ui.translatedSummary
import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.ktx.android.content.res.resolveAttribute
import network.novak.fenix.components.PagedAddonCollectionProvider
import java.io.IOException
import java.text.NumberFormat
import java.util.Locale
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
/**
* 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 addonCollectionProvider Provider of AMO collection API.
* @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.
*/
@Suppress("TooManyFunctions", "LargeClass")
class PagedAddonsManagerAdapter(
private val addonCollectionProvider: PagedAddonCollectionProvider,
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)
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)
return SectionViewHolder(view, titleView)
}
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
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)
is AddonViewHolder -> bindAddon(holder, item as Addon)
is UnsupportedSectionViewHolder -> bindNotYetSupportedSection(
holder,
item as NotYetSupportedSection
)
}
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun bindSection(holder: SectionViewHolder, section: Section) {
holder.titleView.setText(section.title)
style?.maybeSetSectionsTextColor(holder.titleView)
style?.maybeSetSectionsTypeFace(holder.titleView)
}
@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 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.translatedName
} else {
addon.id
}
if (addon.translatableSummary.isNotEmpty()) {
holder.summaryView.text = addon.translatedSummary
} 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 = addonCollectionProvider.getAddonIconBitmap(addon)
val timeToFetch: Double = (System.currentTimeMillis() - startTime) / 1000.0
val isFromCache = timeToFetch < 1
iconBitmap?.let {
scope.launch(Main) {
if (isFromCache) {
iconView.setImageDrawable(BitmapDrawable(iconView.resources, it))
} else {
setWithCrossFadeAnimation(iconView, it)
}
}
}
} 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(R.drawable.mozac_ic_extensions))
}
logger.error("Attempt to fetch the ${addon.id} icon failed", e)
}
}
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@Suppress("ComplexMethod")
internal fun createListWithSections(addons: List<Addon>): 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))
itemsWithSections.addAll(installedAddons)
}
// Add disabled section and addons if available
if (disabledAddons.isNotEmpty()) {
itemsWithSections.add(Section(R.string.mozac_feature_addons_disabled_section))
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)
}
// Add unsupported section
if (unsupportedAddons.isNotEmpty()) {
itemsWithSections.add(NotYetSupportedSection(R.string.mozac_feature_addons_unavailable_section))
}
return itemsWithSections
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal data class Section(@StringRes val title: Int)
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal data class NotYetSupportedSection(@StringRes val title: Int)
/**
* 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
) {
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))
}
}
}
/**
* 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)
}
Loading…
Cancel
Save