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