diff --git a/app/src/main/java/network/novak/fenix/components/PagedAddonCollectionProvider.kt b/app/src/main/java/network/novak/fenix/components/PagedAddonCollectionProvider.kt new file mode 100644 index 000000000..7697ad9e0 --- /dev/null +++ b/app/src/main/java/network/novak/fenix/components/PagedAddonCollectionProvider.kt @@ -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 { + 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? { + 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 { + 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 { + 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 { + 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 { + 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 { + return if (isNull(valueKey)) { + emptyMap() + } else { + val map = mutableMapOf() + 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)) + } +} + diff --git a/app/src/main/java/network/novak/fenix/components/PagedAddonInstallationDialogFragment.kt b/app/src/main/java/network/novak/fenix/components/PagedAddonInstallationDialogFragment.kt new file mode 100644 index 000000000..63316ce68 --- /dev/null +++ b/app/src/main/java/network/novak/fenix/components/PagedAddonInstallationDialogFragment.kt @@ -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(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(R.id.title).text = + requireContext().getString( + R.string.mozac_feature_addons_installed_dialog_title, + addon.translatedName, + requireContext().appName + ) + + val icon = safeArguments.getParcelable(KEY_ICON) + if (icon != null) { + rootView.icon.setImageDrawable(BitmapDrawable(resources, icon)) + } else { + iconJob = fetchIcon(addon, rootView.icon) + } + + val allowedInPrivateBrowsing = rootView.findViewById(R.id.allow_in_private_browsing) + allowedInPrivateBrowsing.setOnCheckedChangeListener { _, isChecked -> + allowPrivateBrowsing = isChecked + } + + val confirmButton = rootView.findViewById