Add duplicate copies of the Android Components pieces that need to change to support a nerw add-on source
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…
Reference in New Issue