diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-15 03:35:49 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-15 03:35:49 +0000 |
commit | d8bbc7858622b6d9c278469aab701ca0b609cddf (patch) | |
tree | eff41dc61d9f714852212739e6b3738b82a2af87 /mobile/android/android-components/components/browser/icons | |
parent | Releasing progress-linux version 125.0.3-1~progress7.99u1. (diff) | |
download | firefox-d8bbc7858622b6d9c278469aab701ca0b609cddf.tar.xz firefox-d8bbc7858622b6d9c278469aab701ca0b609cddf.zip |
Merging upstream version 126.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'mobile/android/android-components/components/browser/icons')
81 files changed, 7470 insertions, 0 deletions
diff --git a/mobile/android/android-components/components/browser/icons/.gitignore b/mobile/android/android-components/components/browser/icons/.gitignore new file mode 100644 index 0000000000..2ddf5f27b1 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/.gitignore @@ -0,0 +1 @@ +manifest.json diff --git a/mobile/android/android-components/components/browser/icons/README.md b/mobile/android/android-components/components/browser/icons/README.md new file mode 100644 index 0000000000..eeb521fc70 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/README.md @@ -0,0 +1,19 @@ +# [Android Components](../../../README.md) > Browser > Icons + +A component for loading and storing website icons (like [Favicons](https://en.wikipedia.org/wiki/Favicon)). + +## Usage + +### Setting up the dependency + +Use Gradle to download the library from [maven.mozilla.org](https://maven.mozilla.org/) ([Setup repository](../../../README.md#maven-repository)): + +```Groovy +implementation "org.mozilla.components:browser-icons:{latest-version}" +``` + +## License + + 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/ diff --git a/mobile/android/android-components/components/browser/icons/build.gradle b/mobile/android/android-components/components/browser/icons/build.gradle new file mode 100644 index 0000000000..a0117897fb --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/build.gradle @@ -0,0 +1,92 @@ +/* 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/. */ + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + defaultConfig { + minSdkVersion config.minSdkVersion + compileSdk config.compileSdkVersion + targetSdkVersion config.targetSdkVersion + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + packagingOptions { + resources { + excludes += ['META-INF/proguard/androidx-annotations.pro'] + } + } + + sourceSets { + androidTest { + // Use the same resources as the unit tests + resources.srcDirs += ['src/test/resources'] + } + } + + buildFeatures { + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion = Versions.compose_compiler + } + + namespace 'mozilla.components.browser.icons' +} + +tasks.register("updateBuiltInExtensionVersion", Copy) { task -> + updateExtensionVersion(task, 'src/main/assets/extensions/browser-icons') +} + +dependencies { + implementation project(':concept-base') + implementation project(':concept-engine') + implementation project(':concept-fetch') + implementation project(':browser-state') + implementation project(':support-images') + implementation project(':support-ktx') + + implementation ComponentsDependencies.androidx_annotation + implementation ComponentsDependencies.androidx_compose_material + implementation ComponentsDependencies.androidx_compose_ui + implementation ComponentsDependencies.androidx_core_ktx + implementation ComponentsDependencies.androidx_palette + + implementation ComponentsDependencies.kotlin_coroutines + + implementation ComponentsDependencies.thirdparty_disklrucache + + implementation ComponentsDependencies.thirdparty_androidsvg + + testImplementation project(':support-test') + testImplementation project(':lib-fetch-httpurlconnection') + testImplementation project(':lib-fetch-okhttp') + + testImplementation ComponentsDependencies.androidx_test_core + testImplementation ComponentsDependencies.androidx_test_junit + testImplementation ComponentsDependencies.kotlin_reflect + testImplementation ComponentsDependencies.testing_mockwebserver + testImplementation ComponentsDependencies.testing_robolectric + testImplementation ComponentsDependencies.testing_coroutines + + androidTestImplementation ComponentsDependencies.androidx_test_core + androidTestImplementation ComponentsDependencies.androidx_test_runner + androidTestImplementation ComponentsDependencies.androidx_test_rules + androidTestImplementation ComponentsDependencies.testing_coroutines +} + +apply from: '../../../android-lint.gradle' +apply from: '../../../publish.gradle' +ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description) + +preBuild.dependsOn updateBuiltInExtensionVersion diff --git a/mobile/android/android-components/components/browser/icons/proguard-rules.pro b/mobile/android/android-components/components/browser/icons/proguard-rules.pro new file mode 100644 index 0000000000..f1b424510d --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/mobile/android/android-components/components/browser/icons/src/androidTest/java/mozilla/components/browser/icons/OnDeviceBrowserIconsTest.kt b/mobile/android/android-components/components/browser/icons/src/androidTest/java/mozilla/components/browser/icons/OnDeviceBrowserIconsTest.kt new file mode 100644 index 0000000000..61dc01d348 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/androidTest/java/mozilla/components/browser/icons/OnDeviceBrowserIconsTest.kt @@ -0,0 +1,70 @@ +/* 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 mozilla.components.browser.icons + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import mozilla.components.browser.icons.generator.IconGenerator +import mozilla.components.concept.engine.manifest.Size +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Request +import mozilla.components.concept.fetch.Response +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +class OnDeviceBrowserIconsTest { + private val context: Context + get() = ApplicationProvider.getApplicationContext() + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun dataUriLoad() = runTest { + val request = IconRequest( + url = "https://www.mozilla.org", + size = IconRequest.Size.DEFAULT, + resources = listOf( + IconRequest.Resource( + url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAMAAABHPGVmAAAA21BMVE" + + "UAAAADAwMREREhISExMTFBQUFRUVFhYWFxcXGBgYFQUlBgYmCQkJA/Gxt8Hx9BQEFQUVBQj0JenE+A" + + "gICgoKAgISA7Li5cODhQUFBgYGBudmx9hXywsLAwMDA5OlJISWF5eWiBgX+Qj5Cgar+vo7bAwMBAQE" + + "A7PnxJTIpycm7Cwj2YmIagn6CujMG/t8PPz89wcHCAgH+Sko2foJ+vr6+/v7/f399fX19vb2+mdFmd" + + "ioCfn5+Xy8u11dXv7+9/f3+lh3anm5Wk2dnD5OT8/PyPj4/e3t/u7u7///9PuU9UAAAAq0lEQVR4Ae" + + "3YIQ7CQBCF4T4ou0uQaCxX4P5XAtVicA8zadIsTabh/9W6TzzRdDQ4bfY6DNsFsiYQEK3uFKSgo2OT" + + "JAgIiL7K5Sff88mvxibpEBCQqzt3NLkWxCZJEBAQ3Ra/2LNfdf//8SAgILpzufsjBATkGdQ6kquOTZ" + + "IgICB69NzmuNztDAEBkauLTU6uBDVXHJtkQUBAqivu7V5ObnZjUHGjY5MkCAjIBymjUnvFUjKoAAAA" + + "AElFTkSuQmCC", + sizes = listOf(Size(64, 64)), + mimeType = "image/png", + type = IconRequest.Resource.Type.FAVICON, + ), + ), + ) + + val icon = BrowserIcons( + context, + httpClient = object : Client() { + override fun fetch(request: Request): Response { + @Suppress("TooGenericExceptionThrown") + throw RuntimeException("Client execution not expected") + } + }, + generator = object : IconGenerator { + override fun generate(context: Context, request: IconRequest): Icon { + @Suppress("TooGenericExceptionThrown") + throw RuntimeException("Generator execution not expected") + } + }, + ).loadIcon(request).await() + + assertNotNull(icon) + + val bitmap = icon.bitmap + assertEquals(100, bitmap.width) + assertEquals(100, bitmap.height) + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/androidTest/java/mozilla/components/browser/icons/decoder/ICOIconDecoderTest.kt b/mobile/android/android-components/components/browser/icons/src/androidTest/java/mozilla/components/browser/icons/decoder/ICOIconDecoderTest.kt new file mode 100644 index 0000000000..7310666416 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/androidTest/java/mozilla/components/browser/icons/decoder/ICOIconDecoderTest.kt @@ -0,0 +1,129 @@ +/* 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/. */ + +import mozilla.components.browser.icons.decoder.ICOIconDecoder +import mozilla.components.browser.icons.decoder.ico.decodeDirectoryEntries +import mozilla.components.support.images.DesiredSize +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +class ICOIconDecoderTest { + @Test + fun testIconSizesOfMicrosoftFavicon() { + val icon = loadIcon("microsoft_favicon.ico") + val entries = decodeDirectoryEntries(icon, 1024) + + assertEquals(6, entries.size) + + val bitmaps = entries + .mapNotNull { entry -> entry.toBitmap(icon) } + .sortedBy { bitmap -> bitmap.width } + + assertEquals(6, bitmaps.size) + + assertEquals(16, bitmaps[0].width) + assertEquals(16, bitmaps[0].height) + + assertEquals(24, bitmaps[1].width) + assertEquals(24, bitmaps[1].height) + + assertEquals(32, bitmaps[2].width) + assertEquals(32, bitmaps[2].height) + + assertEquals(48, bitmaps[3].width) + assertEquals(48, bitmaps[3].height) + + assertEquals(72, bitmaps[4].width) + assertEquals(72, bitmaps[4].height) + + assertEquals(128, bitmaps[5].width) + assertEquals(128, bitmaps[5].height) + } + + @Test + fun testBestMicrosoftIconTarget192Max256() { + val icon = loadIcon("microsoft_favicon.ico") + + val decoder = ICOIconDecoder() + val bitmap = decoder.decode(icon, DesiredSize(192, 192, 256, 2.0f)) + + assertNotNull(bitmap) + + assertEquals(128, bitmap!!.width) + assertEquals(128, bitmap.height) + } + + @Test + fun testBestMicrosoftIconTarget64Max120() { + val icon = loadIcon("microsoft_favicon.ico") + + val decoder = ICOIconDecoder() + val bitmap = decoder.decode(icon, DesiredSize(64, 64, 120, 2.0f)) + + assertNotNull(bitmap) + + assertEquals(72, bitmap!!.width) + assertEquals(72, bitmap.height) + } + + @Test + fun testIconSizesOfGolemFavicon() { + val icon = loadIcon("golem_favicon.ico") + + val entries = decodeDirectoryEntries(icon, 1024) + + assertEquals(5, entries.size) + + val bitmaps = entries + .mapNotNull { entry -> entry.toBitmap(icon) } + .sortedBy { bitmap -> bitmap.width } + + assertEquals(5, bitmaps.size) + + assertEquals(16, bitmaps[0].width) + assertEquals(16, bitmaps[0].height) + + assertEquals(24, bitmaps[1].width) + assertEquals(24, bitmaps[1].height) + + assertEquals(32, bitmaps[2].width) + assertEquals(32, bitmaps[2].height) + + assertEquals(48, bitmaps[3].width) + assertEquals(48, bitmaps[3].height) + + assertEquals(256, bitmaps[4].width) + assertEquals(256, bitmaps[4].height) + } + + @Test + fun testIconSizesOfNvidiaFavicon() { + val icon = loadIcon("nvidia_favicon.ico") + + val entries = decodeDirectoryEntries(icon, 1024) + + assertEquals(3, entries.size) + + val bitmaps = entries + .mapNotNull { entry -> entry.toBitmap(icon) } + .sortedBy { bitmap -> bitmap.width } + + assertEquals(3, bitmaps.size) + + assertEquals(16, bitmaps[0].width) + assertEquals(16, bitmaps[0].height) + + assertEquals(32, bitmaps[1].width) + assertEquals(32, bitmaps[1].height) + + assertEquals(48, bitmaps[2].width) + assertEquals(48, bitmaps[2].height) + } + + private fun loadIcon(fileName: String): ByteArray = + javaClass.getResourceAsStream("/ico/$fileName")!! + .buffered() + .readBytes() +} diff --git a/mobile/android/android-components/components/browser/icons/src/main/AndroidManifest.xml b/mobile/android/android-components/components/browser/icons/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..e16cda1d34 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ +<!-- 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/. --> +<manifest /> diff --git a/mobile/android/android-components/components/browser/icons/src/main/assets/extensions/browser-icons/icons.js b/mobile/android/android-components/components/browser/icons/src/main/assets/extensions/browser-icons/icons.js new file mode 100644 index 0000000000..20eada9a19 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/assets/extensions/browser-icons/icons.js @@ -0,0 +1,81 @@ +/* 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/. */ + + /* + * This web extension looks for known icon tags, collects URLs and available + * meta data (e.g. sizes) and passes that to the app code. + */ + +/** + * Takes a DOMTokenList and returns a String array. + */ +function sizesToList(sizes) { + if (sizes == null) { + return [] + } + + if (!(sizes instanceof DOMTokenList)) { + return [] + } + + return Array.from(sizes) +} + +function collect_link_icons(icons, rel) { + document.querySelectorAll('link[rel="' + rel + '"]').forEach( + function(currentValue, currentIndex, listObj) { + icons.push({ + 'type': rel, + 'href': currentValue.href, + 'sizes': sizesToList(currentValue.sizes), + 'mimeType': currentValue.type + }); + }) +} + +function collect_meta_property_icons(icons, property) { + document.querySelectorAll('meta[property="' + property + '"]').forEach( + function(currentValue, currentIndex, listObj) { + icons.push({ + 'type': property, + 'href': currentValue.content + }) + } + ) +} + +function collect_meta_name_icons(icons, name) { + document.querySelectorAll('meta[name="' + name + '"]').forEach( + function(currentValue, currentIndex, listObj) { + icons.push({ + 'type': name, + 'href': currentValue.content + }) + } + ) +} + +let icons = []; + +collect_link_icons(icons, 'icon'); +collect_link_icons(icons, 'shortcut icon'); +collect_link_icons(icons, 'fluid-icon') +collect_link_icons(icons, 'apple-touch-icon') +collect_link_icons(icons, 'image_src') +collect_link_icons(icons, 'apple-touch-icon image_src') +collect_link_icons(icons, 'apple-touch-icon-precomposed') + +collect_meta_property_icons(icons, 'og:image') +collect_meta_property_icons(icons, 'og:image:url') +collect_meta_property_icons(icons, 'og:image:secure_url') + +collect_meta_name_icons(icons, 'twitter:image') +collect_meta_name_icons(icons, 'msapplication-TileImage') + +let message = { + 'url': document.location.href, + 'icons': icons +} + +browser.runtime.sendNativeMessage("MozacBrowserIcons", message); diff --git a/mobile/android/android-components/components/browser/icons/src/main/assets/extensions/browser-icons/manifest.template.json b/mobile/android/android-components/components/browser/icons/src/main/assets/extensions/browser-icons/manifest.template.json new file mode 100644 index 0000000000..846fc92101 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/assets/extensions/browser-icons/manifest.template.json @@ -0,0 +1,22 @@ +{ + "manifest_version": 2, + "browser_specific_settings": { + "gecko": { + "id": "icons@mozac.org" + } + }, + "name": "Mozilla Android Components - Browser Icons", + "version": "${version}", + "content_scripts": [ + { + "matches": ["*://*/*"], + "js": ["icons.js"], + "run_at": "document_end" + } + ], + "permissions": [ + "geckoViewAddons", + "nativeMessaging", + "nativeMessagingFromContent" + ] +} diff --git a/mobile/android/android-components/components/browser/icons/src/main/assets/mozac.browser.icons/icons-top200.json b/mobile/android/android-components/components/browser/icons/src/main/assets/mozac.browser.icons/icons-top200.json new file mode 100644 index 0000000000..270a3570f9 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/assets/mozac.browser.icons/icons-top200.json @@ -0,0 +1,1056 @@ +[ + { + "image_url": "https://youradchoices.com/./DAA_style/YAC/icon.png", + "domains": [ + "aboutads.info" + ] + }, + { + "image_url": "https://accounts.google.com/favicon.ico", + "domains": [ + "accounts.google.com" + ] + }, + { + "image_url": "https://static.addtoany.com/images/icon-180.png", + "domains": [ + "addtoany.com" + ] + }, + { + "image_url": "https://amazon.ca/favicon.ico", + "domains": [ + "amazon.ca" + ] + }, + { + "image_url": "https://amazon.cn/favicon.ico", + "domains": [ + "amazon.cn" + ] + }, + { + "image_url": "https://amazon.co.jp/favicon.ico", + "domains": [ + "amazon.co.jp" + ] + }, + { + "image_url": "https://amazon.co.uk/favicon.ico", + "domains": [ + "amazon.co.uk", + "amazon.co.uk" + ] + }, + { + "image_url": "https://amazon.com/favicon.ico", + "domains": [ + "amazon.com", + "amazon.com" + ] + }, + { + "image_url": "https://amazon.com.au/favicon.ico", + "domains": [ + "amazon.com.au" + ] + }, + { + "image_url": "https://amazon.com.br/favicon.ico", + "domains": [ + "amazon.com.br" + ] + }, + { + "image_url": "https://amazon.com.mx/favicon.ico", + "domains": [ + "amazon.com.mx" + ] + }, + { + "image_url": "https://amazon.de/favicon.ico", + "domains": [ + "amazon.de" + ] + }, + { + "image_url": "https://amazon.es/favicon.ico", + "domains": [ + "amazon.es" + ] + }, + { + "image_url": "https://amazon.fr/favicon.ico", + "domains": [ + "amazon.fr" + ] + }, + { + "image_url": "https://amazon.in/favicon.ico", + "domains": [ + "amazon.in" + ] + }, + { + "image_url": "https://amazon.it/favicon.ico", + "domains": [ + "amazon.it" + ] + }, + { + "image_url": "https://amzn.to/favicon.ico", + "domains": [ + "amzn.to" + ] + }, + { + "image_url": "https://apache.org/favicons/favicon-194x194.png", + "domains": [ + "apache.org" + ] + }, + { + "image_url": "https://apple.com/favicon.ico", + "domains": [ + "apple.com" + ] + }, + { + "image_url": "https://apps.apple.com/favicon.ico", + "domains": [ + "apps.apple.com" + ] + }, + { + "image_url": "https://archive.org/offshoot_assets/favicon.ico", + "domains": [ + "archive.org" + ] + }, + { + "image_url": "https://psstatic.cdn.bcebos.com/video/wiseindex/aa6eef91f8b5b1a33b454c401_1660835115000.png", + "domains": [ + "baidu.com" + ] + }, + { + "image_url": "https://bbc.co.uk/favicon.ico", + "domains": [ + "bbc.co.uk" + ] + }, + { + "image_url": "https://gn-web-assets.api.bbc.com/wwhp/20230821-1053-ff8cbd1fdf502854de8eb5a063ed1023f172e519/responsive/img/apple-touch/apple-touch-180.jpg", + "domains": [ + "bbc.com" + ] + }, + { + "image_url": "https://behance.net/favicon.ico", + "domains": [ + "behance.net" + ] + }, + { + "image_url": "https://www.bing.com:443/sa/simg/favicon-trans-bg-blue-mg-png.png", + "domains": [ + "bing.com" + ] + }, + { + "image_url": "https://docrdsfx76ssb.cloudfront.net/static/1695195096/pages/wp-content/uploads/2019/02/favicon.ico", + "domains": [ + "bit.ly" + ] + }, + { + "image_url": "https://blogger.com/favicon.ico", + "domains": [ + "blogger.com" + ] + }, + { + "image_url": "https://blogspot.com/favicon.ico", + "domains": [ + "blogspot.com" + ] + }, + { + "image_url": "https://www.bloomberg.com/favicon-black.png", + "domains": [ + "bloomberg.com" + ] + }, + { + "image_url": "https://www.businessinsider.com/public/assets/BI/US/favicons/apple-touch-icon-192x192.png?v=2023-06", + "domains": [ + "businessinsider.com" + ] + }, + { + "image_url": "https://www.ca.gov/images/apple-touch-icon-192x192.png", + "domains": [ + "ca.gov" + ] + }, + { + "image_url": "https://www.cbsnews.com/fly/bundles/cbsnewscore/icons/icon-192x192.png?v=cc1d20369924eaddf626a3a17b75fcb0", + "domains": [ + "cbsnews.com" + ] + }, + { + "image_url": "https://www.cdc.gov/TemplatePackage/4.0/assets/imgs/apple-touch-icon-180x180.png", + "domains": [ + "cdc.gov" + ] + }, + { + "image_url": "https://www.cloudflare.com/favicon.ico", + "domains": [ + "cloudflare.com" + ] + }, + { + "image_url": "https://www.cnbc.com/favicon.ico", + "domains": [ + "cnbc.com" + ] + }, + { + "image_url": "https://www.cnet.com/favicon-256-v3.png", + "domains": [ + "cnet.com" + ] + }, + { + "image_url": "https://www.cnn.com/media/sites/cnn/apple-touch-icon.png", + "domains": [ + "cnn.com" + ] + }, + { + "image_url": "https://cpanel.net/wp-content/themes/cPbase/assets/img/apple-touch-icon.png", + "domains": [ + "cpanel.net" + ] + }, + { + "image_url": "https://creativecommons.org/wp-content/uploads/2016/05/cc-site-icon-300x300.png", + "domains": [ + "creativecommons.org" + ] + }, + { + "image_url": "https://dailymail.co.uk/favicon.ico", + "domains": [ + "dailymail.co.uk" + ] + }, + { + "image_url": "https://www.debian.org/favicon.ico", + "domains": [ + "debian.org" + ] + }, + { + "image_url": "https://www.gstatic.com/devrel-devsite/prod/v47c000584df8fd5ed12554bcabcc16cd4fd28aee940bdc8ae9e35cab77cbb7da/developers/images/touchicon-180-new.png", + "domains": [ + "developers.google.com" + ] + }, + { + "image_url": "https://docs.google.com/favicon.ico", + "domains": [ + "docs.google.com" + ] + }, + { + "image_url": "https://www.doi.org/images/favicons/android-chrome-512x512.png", + "domains": [ + "doi.org" + ] + }, + { + "image_url": "https://ssl.gstatic.com/images/branding/product/2x/hh_drive_36dp.png", + "domains": [ + "drive.google.com" + ] + }, + { + "image_url": "https://cfl.dropboxstatic.com/static/images/favicon.ico", + "domains": [ + "dropbox.com" + ] + }, + { + "image_url": "https://pages.ebay.com/favicon.ico", + "domains": [ + "ebay.com" + ] + }, + { + "image_url": "https://commission.europa.eu/profiles/contrib/ewcms/themes/ewcms_theme/favicon.ico", + "domains": [ + "ec.europa.eu" + ] + }, + { + "image_url": "https://en.m.wikipedia.org/static/apple-touch/wikipedia.png", + "domains": [ + "en.wikipedia.org" + ] + }, + { + "image_url": "https://www.etsy.com/images/apple-touch-icon.png", + "domains": [ + "etsy.com" + ] + }, + { + "image_url": "https://european-union.europa.eu/profiles/contrib/ewcms/themes/ewcms_theme/favicon.ico", + "domains": [ + "europa.eu" + ] + }, + { + "image_url": "https://cdn.evbstatic.com/s3-build/prod/1383882-rc2023-09-26_16.04-94fcd55/django/images/favicons/favicon-194x194.png", + "domains": [ + "eventbrite.com" + ] + }, + { + "image_url": "https://static.xx.fbcdn.net/rsrc.php/v3/yN/r/EWLVhDVJTum.png", + "domains": [ + "facebook.com" + ] + }, + { + "image_url": "https://combo.staticflickr.com/pw/images/favicons/favicon-228.png", + "domains": [ + "flickr.com" + ] + }, + { + "image_url": "https://i.forbesimg.com/media/assets/appicons/forbes-app-icon_144x144.png", + "domains": [ + "forbes.com" + ] + }, + { + "image_url": "https://www.free.fr/assets/img/shared/fav/favicon-196x196.png", + "domains": [ + "free.fr" + ] + }, + { + "image_url": "https://www.ft.com/__origami/service/image/v2/images/raw/ftlogo-v1%3Abrand-ft-logo-square-coloured?source=update-logos&format=svg", + "domains": [ + "ft.com" + ] + }, + { + "image_url": "https://www.google.com/business/static/icons/favicon.ico?cache=2cf40ae", + "domains": [ + "g.page" + ] + }, + { + "image_url": "https://www.canada.ca/etc/designs/canada/wet-boew/assets/favicon.ico", + "domains": [ + "gc.ca" + ] + }, + { + "image_url": "https://github.githubassets.com/favicons/favicon.svg", + "domains": [ + "github.com", + "github.com" + ] + }, + { + "image_url": "https://pages.github.com/favicon.ico", + "domains": [ + "github.io" + ] + }, + { + "image_url": "https://www.gnu.org/graphics/gnu-head-mini.png", + "domains": [ + "gnu.org" + ] + }, + { + "image_url": "https://lumiere-a.akamaihd.net/v1/images/favicon-94e3862e7fb9_2bdfd7d9.png?region=0%2C0%2C64%2C64", + "domains": [ + "go.com" + ] + }, + { + "image_url": "https://google.com/favicon.ico", + "domains": [ + "google.com" + ] + }, + { + "image_url": "https://blog.google/static/blogv2/images/apple-touch-icon.png", + "domains": [ + "googleblog.com" + ] + }, + { + "image_url": "https://gravatar.com/favicon.ico", + "domains": [ + "gravatar.com" + ] + }, + { + "image_url": "https://www.harvard.edu/wp-content/uploads/2020/10/cropped-logo-branding-compressed-300x300.png", + "domains": [ + "harvard.edu" + ] + }, + { + "image_url": "https://www.hp.com/content/dam/sites/worldwide/dems/favicons/hp-blue-favicon.png", + "domains": [ + "hp.com" + ] + }, + { + "image_url": "https://www.huffpost.com/favicon.ico", + "domains": [ + "huffingtonpost.com" + ] + }, + { + "image_url": "https://www.ibm.com/content/dam/adobe-cms/default-images/favicon.svg", + "domains": [ + "ibm.com" + ] + }, + { + "image_url": "https://ietf.org/favicon.ico", + "domains": [ + "ietf.org" + ] + }, + { + "image_url": "https://m.media-amazon.com/images/G/01/imdb/images-ANDW73HA/android-mobile-196x196._CB479962153_.png", + "domains": [ + "imdb.com" + ] + }, + { + "image_url": "https://s.imgur.com/images/icons/icon-152.png", + "domains": [ + "imgur.com" + ] + }, + { + "image_url": "https://www.independent.co.uk/img/shortcut-icons/icon-512x512.png", + "domains": [ + "independent.co.uk" + ] + }, + { + "image_url": "https://static.cdninstagram.com/rsrc.php/v3/yI/r/VsNE-OHk_8a.png", + "domains": [ + "instagram.com" + ] + }, + { + "image_url": "https://issuu.com/icon.svg", + "domains": [ + "issuu.com" + ] + }, + { + "image_url": "https://itunes.apple.com/favicon.ico", + "domains": [ + "itunes.apple.com" + ] + }, + { + "image_url": "https://www.latimes.com:443/apple-touch-icon.png", + "domains": [ + "latimes.com" + ] + }, + { + "image_url": "https://line.me/favicon.ico", + "domains": [ + "line.me" + ] + }, + { + "image_url": "https://static.licdn.com/aero-v1/sc/h/al2o9zrvru7aqj8e1x2rzsrca", + "domains": [ + "linkedin.com" + ] + }, + { + "image_url": "https://website.linktr.ee/icons/icon-512x512.png", + "domains": [ + "linktr.ee" + ] + }, + { + "image_url": "https://loc.gov/favicon.ico", + "domains": [ + "loc.gov" + ] + }, + { + "image_url": "https://mail.google.com/favicon.ico", + "domains": [ + "mail.google.com", + "mail.google.com" + ] + }, + { + "image_url": "https://maps.gstatic.com/mapfiles/maps_lite/pwa/icons/maps15_bnuw3a_ios_192x192.png", + "domains": [ + "maps.google.com" + ] + }, + { + "image_url": "https://miro.medium.com/v2/resize:fill:152:152/1*sHhtYhaCe2Uc3IU0IgKwIQ.png", + "domains": [ + "medium.com" + ] + }, + { + "image_url": "https://www.microsoft.com/favicon.ico?v2", + "domains": [ + "microsoft.com", + "live.com", + "outlook.com" + ] + }, + { + "image_url": "https://www.miit.gov.cn/favicon.ico", + "domains": [ + "miit.gov.cn" + ] + }, + { + "image_url": "https://web.mit.edu/themes/mit/assets/favicon/favicon.svg", + "domains": [ + "mit.edu" + ] + }, + { + "image_url": "https://www.mozilla.org/media/img/favicons/mozilla/favicon-196x196.2af054fea211.png", + "domains": [ + "mozilla.org" + ] + }, + { + "image_url": "https://res.wx.qq.com/a/wx_fed/assets/res/OTE0YTAw.png", + "domains": [ + "mp.weixin.qq.com" + ] + }, + { + "image_url": "https://msn.com/favicon.ico", + "domains": [ + "msn.com" + ] + }, + { + "image_url": "https://cdn.shopify.com/shopifycloud/shopify/assets/favicon-bdd4952d510d9607e893c45e36bba6b0a8c9c59cb8344e7a75ebe7215112b7f5.png", + "domains": [ + "myshopify.com" + ] + }, + { + "image_url": "https://x.myspacecdn.com/new/common/images/favicons/144-Retina-iPad.png", + "domains": [ + "myspace.com" + ] + }, + { + "image_url": "https://www.nasa.gov/sites/all/themes/custom/nasatwo/images/apple-touch-icon-152x152.png", + "domains": [ + "nasa.gov" + ] + }, + { + "image_url": "https://www.nature.com/static/images/favicons/nature/apple-touch-icon-f39cb19454.png", + "domains": [ + "nature.com" + ] + }, + { + "image_url": "https://www.nginx.com/wp-content/uploads/2019/10/favicon-64x46.ico", + "domains": [ + "nginx.com" + ] + }, + { + "image_url": "https://nginx.org/favicon.ico", + "domains": [ + "nginx.org" + ] + }, + { + "image_url": "https://www.nih.gov/sites/all/themes/nih/apple-touch-icon.png", + "domains": [ + "nih.gov" + ] + }, + { + "image_url": "https://static-assets.npr.org/static/images/favicon/favicon-180x180.png", + "domains": [ + "npr.org" + ] + }, + { + "image_url": "https://www.nytimes.com/vi-assets/static-assets/apple-touch-icon-28865b72953380a40aa43318108876cb.png", + "domains": [ + "nytimes.com" + ] + }, + { + "image_url": "https://res.cdn.office.net/officehub/images/content/images/favicon_m365-67350a08e8.ico", + "domains": [ + "office.com" + ] + }, + { + "image_url": "https://cdn-production-opera-website.operacdn.com/staticfiles/assets/images/favicon/apple-touch-icon.555ee4c450b1.png", + "domains": [ + "opera.com" + ] + }, + { + "image_url": "https://www.oracle.com/favicon.ico", + "domains": [ + "oracle.com" + ] + }, + { + "image_url": "https://global.oup.com/system/images/favicon-180.png", + "domains": [ + "oup.com" + ] + }, + { + "image_url": "https://www.paypalobjects.com/webstatic/icon/pp258.png", + "domains": [ + "paypal.com" + ] + }, + { + "image_url": "https://www.php.net/favicon.svg?v=2", + "domains": [ + "php.net" + ] + }, + { + "image_url": "https://s.pinimg.com/webapp/logo_trans_144x144-5e37c0c6.png", + "domains": [ + "pinterest.com" + ] + }, + { + "image_url": "https://www.gstatic.com/android/market_images/web/favicon_v3.ico", + "domains": [ + "play.google.com" + ] + }, + { + "image_url": "https://workspaceupdates.googleblog.com/favicon.ico", + "domains": [ + "plus.google.com", + "workspaceupdates.googleblog.com" + ] + }, + { + "image_url": "https://podcasts.apple.com/favicon.ico", + "domains": [ + "podcasts.apple.com" + ] + }, + { + "image_url": "https://ssl.gstatic.com/policies/favicon.ico", + "domains": [ + "policies.google.com" + ] + }, + { + "image_url": "https://www.prnewswire.com/content/dam/prnewswire/icons/2019-Q4-PRN-Icon-32-32.png", + "domains": [ + "prnewswire.com" + ] + }, + { + "image_url": "https://cdn.ncbi.nlm.nih.gov/coreutils/nwds/img/favicons/favicon-192.png", + "domains": [ + "pubmed.ncbi.nlm.nih.gov" + ] + }, + { + "image_url": "https://mat1.gtimg.com/www/icon/favicon2.ico", + "domains": [ + "qq.com" + ] + }, + { + "image_url": "https://www.redditstatic.com/shreddit/assets/favicon/192x192.png", + "domains": [ + "reddit.com" + ] + }, + { + "image_url": "https://www.researchgate.net/favicon-96x96.png", + "domains": [ + "researchgate.net" + ] + }, + { + "image_url": "https://www.reuters.com/pf/resources/images/reuters/favicon/tr_kinesis.svg?d=157", + "domains": [ + "reuters.com" + ] + }, + { + "image_url": "https://cdn.shopify.com/static/shopify-favicon.png", + "domains": [ + "shopify.com" + ] + }, + { + "image_url": "https://mjs.sinaimg.cn/wap/online/public/images/addToHome/sina_114x114_v1.png", + "domains": [ + "sina.com.cn" + ] + }, + { + "image_url": "https://public.slidesharecdn.com/_next/static/media/favicon.7bc3d920.ico", + "domains": [ + "slideshare.net" + ] + }, + { + "image_url": "https://m.sndcdn.com/_next/static/images/apple-touch-icon-180-893d0d532e8fbba714cceb8d9eae9567.png", + "domains": [ + "soundcloud.com" + ] + }, + { + "image_url": "https://a.fsdn.com/con/img/sandiego/svg/originals/sf-icon-orange-no_sf.svg", + "domains": [ + "sourceforge.net" + ] + }, + { + "image_url": "https://open.spotifycdn.com/cdn/images/favicon.0f31d2ea.ico", + "domains": [ + "spotify.com", + "open.spotify.com" + ] + }, + { + "image_url": "https://www.springer.com/public/images/springer-icon.svg", + "domains": [ + "springer.com" + ] + }, + { + "image_url": "https://media-www.sqspcdn.com/logos/apple-touch-icon-1024.png", + "domains": [ + "squarespace.com" + ] + }, + { + "image_url": "https://cdn.sstatic.net/Sites/stackoverflow/Img/apple-touch-icon.png?v=c78bd457575a", + "domains": [ + "stackoverflow.com" + ] + }, + { + "image_url": "https://www-media.stanford.edu/assets/favicon/favicon-196x196.png", + "domains": [ + "stanford.edu" + ] + }, + { + "image_url": "https://cdn.statcdn.com/static/favicon.svg", + "domains": [ + "statista.com" + ] + }, + { + "image_url": "https://support.google.com/favicon.ico", + "domains": [ + "support.google.com" + ] + }, + { + "image_url": "https://prod.smassets.net/assets/static/images/surveymonkey/favicon.svg", + "domains": [ + "surveymonkey.com" + ] + }, + { + "image_url": "https://abs.twimg.com/favicons/favicon.ico", + "domains": [ + "t.co" + ] + }, + { + "image_url": "https://telegram.org/img/website_icon.svg?4", + "domains": [ + "t.me", + "telegram.me" + ] + }, + { + "image_url": "https://techcrunch.com/wp-content/uploads/2015/02/cropped-cropped-favicon-gradient.png?w=192", + "domains": [ + "techcrunch.com" + ] + }, + { + "image_url": "https://pa.tedcdn.com/apple-touch-icon.png", + "domains": [ + "ted.com" + ] + }, + { + "image_url": "https://www.telegraph.co.uk/etc.clientlibs/settings/wcm/designs/telegraph/core/clientlibs/core/resources/icons/favicon.svg", + "domains": [ + "telegraph.co.uk" + ] + }, + { + "image_url": "https://assets.guim.co.uk/static/frontend/icons/homescreen/apple-touch-icon.svg", + "domains": [ + "theguardian.com" + ] + }, + { + "image_url": "https://assets.market-storefront.envato-static.com/storefront/assets/favicons/themeforest/apple-touch-icon-144x144-precomposed-e158f10207a0bcb58e6e9ae62482d9cb5de11794f5ecb56a25e0501dce624bd2.png", + "domains": [ + "themeforest.net" + ] + }, + { + "image_url": "https://www.theverge.com/icons/android_chrome_512x512.png", + "domains": [ + "theverge.com" + ] + }, + { + "image_url": "https://tiktok.com/favicon.ico", + "domains": [ + "tiktok.com" + ] + }, + { + "image_url": "https://time.com/img/favicons/favicon-192.png", + "domains": [ + "time.com" + ] + }, + { + "image_url": "https://tinyurl.com/images/icons/favicon-192.png", + "domains": [ + "tinyurl.com" + ] + }, + { + "image_url": "https://assets.tumblr.com/pop/manifest/favicon-cfddd25f.svg", + "domains": [ + "tumblr.com" + ] + }, + { + "image_url": "https://m.twitch.tv/static/images/pwa/icons/pwaicon-180.png", + "domains": [ + "twitch.tv", + "go.twitch.tv" + ] + }, + { + "image_url": "https://abs.twimg.com/responsive-web/client-web-legacy/icon-ios.77d25eba.png", + "domains": [ + "twitter.com" + ] + }, + { + "image_url": "https://unsplash.com/apple-touch-icon.png", + "domains": [ + "unsplash.com" + ] + }, + { + "image_url": "https://usatoday.com/favicon.ico", + "domains": [ + "usatoday.com" + ] + }, + { + "image_url": "https://validator.w3.org/images/favicon.ico", + "domains": [ + "validator.w3.org" + ] + }, + { + "image_url": "https://i.vimeocdn.com/favicon/main-touch_180", + "domains": [ + "vimeo.com", + "player.vimeo.com" + ] + }, + { + "image_url": "https://m.vk.com/images/icons/pwa/apple/default.png?15", + "domains": [ + "vk.com" + ] + }, + { + "image_url": "https://w3.org/favicon.ico", + "domains": [ + "w3.org" + ] + }, + { + "image_url": "https://www.washingtonpost.com/favicon.svg", + "domains": [ + "washingtonpost.com" + ] + }, + { + "image_url": "https://web.archive.org/_static/images/archive.ico", + "domains": [ + "web.archive.org" + ] + }, + { + "image_url": "https://www.webmd.com/favico/apple-touch-icon-114x114-precomposed.png", + "domains": [ + "webmd.com" + ] + }, + { + "image_url": "https://weebly.com/favicon.ico", + "domains": [ + "weebly.com" + ] + }, + { + "image_url": "https://h5.sinaimg.cn/m/weibo-lite/appicon.png", + "domains": [ + "weibo.com" + ] + }, + { + "image_url": "https://static.whatsapp.net/rsrc.php/v3/yz/r/ujTY9i_Jhs1.png", + "domains": [ + "whatsapp.com", + "api.whatsapp.com", + "wa.me" + ] + }, + { + "image_url": "https://www.who.int/apple-touch-icon-precomposed.png", + "domains": [ + "who.int" + ] + }, + { + "image_url": "https://foundation.wikimedia.org/favicon.ico", + "domains": [ + "wikimedia.org" + ] + }, + { + "image_url": "https://www.wikipedia.org/static/apple-touch/wikipedia.png", + "domains": [ + "wikipedia.org" + ] + }, + { + "image_url": "https://www.wiley.com/etc.clientlibs/wiley/clientlibs/clientlib-consumer/resources/images/icons/favicon.svg", + "domains": [ + "wiley.com" + ] + }, + { + "image_url": "https://c.s-microsoft.com/favicon.ico", + "domains": [ + "windows.microsoft.com" + ] + }, + { + "image_url": "https://www.wix.com/favicon.ico", + "domains": [ + "wixsite.com", + "wix.com" + ] + }, + { + "image_url": "https://0.gravatar.com/blavatar/653166773dc88127bd3afe0b6dfe5ea7?s=114&d=https%3A%2F%2Fs1.wp.com%2Fi%2Fwebclip.png", + "domains": [ + "wordpress.com", + "wp.com" + ] + }, + { + "image_url": "https://s.w.org/images/wmark.png", + "domains": [ + "wordpress.org" + ] + }, + { + "image_url": "https://s.wsj.net/img/meta/wsj_favicon.svg", + "domains": [ + "wsj.com" + ] + }, + { + "image_url": "https://www.gov.uk/assets/static/govuk-apple-touch-icon-180x180-026deaa34fa328ae5f1f519a37dbd15e6555c5086e1ba83986cd0827a7209902.png", + "domains": [ + "www.gov.uk" + ] + }, + { + "image_url": "https://www.ncbi.nlm.nih.gov/favicon.ico", + "domains": [ + "www.ncbi.nlm.nih.gov" + ] + }, + { + "image_url": "https://s.yimg.com/cv/apiv2/social/images/yahoo_default_logo.png", + "domains": [ + "yahoo.com" + ] + }, + { + "image_url": "https://s3-media0.fl.yelpcdn.com/assets/srv0/yelp_large_assets/dcfe403147fc/assets/img/logos/favicon.ico", + "domains": [ + "yelp.com" + ] + }, + { + "image_url": "https://www.youtube.com/img/favicon_144.png", + "domains": [ + "youtube-nocookie.com" + ] + }, + { + "image_url": "https://m.youtube.com/static/apple-touch-icon-180x180-precomposed.png", + "domains": [ + "youtube.com", + "youtu.be" + ] + }, + { + "image_url": "https://st1.zoom.us/zoom.ico", + "domains": [ + "zoom.us" + ] + } +] diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/BrowserIcons.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/BrowserIcons.kt new file mode 100644 index 0000000000..3bf5c2b2ff --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/BrowserIcons.kt @@ -0,0 +1,476 @@ +/* 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 mozilla.components.browser.icons + +import android.annotation.SuppressLint +import android.content.ComponentCallbacks2 +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.widget.ImageView +import androidx.annotation.MainThread +import androidx.annotation.VisibleForTesting +import androidx.annotation.WorkerThread +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import mozilla.components.browser.icons.compose.IconLoaderScope +import mozilla.components.browser.icons.compose.IconLoaderState +import mozilla.components.browser.icons.compose.InternalIconLoaderScope +import mozilla.components.browser.icons.decoder.ICOIconDecoder +import mozilla.components.browser.icons.decoder.SvgIconDecoder +import mozilla.components.browser.icons.extension.IconMessageHandler +import mozilla.components.browser.icons.generator.DefaultIconGenerator +import mozilla.components.browser.icons.generator.IconGenerator +import mozilla.components.browser.icons.loader.DataUriIconLoader +import mozilla.components.browser.icons.loader.DiskIconLoader +import mozilla.components.browser.icons.loader.HttpIconLoader +import mozilla.components.browser.icons.loader.IconLoader +import mozilla.components.browser.icons.loader.MemoryIconLoader +import mozilla.components.browser.icons.loader.NonBlockingHttpIconLoader +import mozilla.components.browser.icons.pipeline.IconResourceComparator +import mozilla.components.browser.icons.preparer.DiskIconPreparer +import mozilla.components.browser.icons.preparer.IconPreprarer +import mozilla.components.browser.icons.preparer.MemoryIconPreparer +import mozilla.components.browser.icons.preparer.TippyTopIconPreparer +import mozilla.components.browser.icons.processor.DiskIconProcessor +import mozilla.components.browser.icons.processor.IconProcessor +import mozilla.components.browser.icons.processor.MemoryIconProcessor +import mozilla.components.browser.icons.utils.IconDiskCache +import mozilla.components.browser.icons.utils.IconMemoryCache +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.base.memory.MemoryConsumer +import mozilla.components.concept.engine.Engine +import mozilla.components.concept.engine.webextension.WebExtension +import mozilla.components.concept.fetch.Client +import mozilla.components.lib.state.ext.flowScoped +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.base.utils.NamedThreadFactory +import mozilla.components.support.images.CancelOnDetach +import mozilla.components.support.images.DesiredSize +import mozilla.components.support.images.decoder.AndroidImageDecoder +import mozilla.components.support.images.decoder.ImageDecoder +import mozilla.components.support.ktx.kotlinx.coroutines.flow.filterChanged +import java.lang.ref.WeakReference +import java.util.concurrent.Executors + +@VisibleForTesting +internal const val MAXIMUM_SCALE_FACTOR = 2.0f + +private const val EXTENSION_MESSAGING_NAME = "MozacBrowserIcons" + +// Number of worker threads we are using internally. +private const val THREADS = 3 + +internal val sharedMemoryCache = IconMemoryCache() +internal val sharedDiskCache = IconDiskCache() + +/** + * Entry point for loading icons for websites. + * + * @param generator The [IconGenerator] to generate an icon if no icon could be loaded. + * @param decoders List of [ImageDecoder] instances to use when decoding a loaded icon into a [android.graphics.Bitmap]. + */ +class BrowserIcons constructor( + private val context: Context, + httpClient: Client, + private val generator: IconGenerator = DefaultIconGenerator(), + private val preparers: List<IconPreprarer> = listOf( + TippyTopIconPreparer(context.assets), + MemoryIconPreparer(sharedMemoryCache), + DiskIconPreparer(sharedDiskCache), + ), + internal var loaders: List<IconLoader> = listOf( + MemoryIconLoader(sharedMemoryCache), + DiskIconLoader(sharedDiskCache), + HttpIconLoader(httpClient), + DataUriIconLoader(), + ), + private val decoders: List<ImageDecoder> = listOf( + AndroidImageDecoder(), + ICOIconDecoder(), + SvgIconDecoder(), + ), + private val processors: List<IconProcessor> = listOf( + MemoryIconProcessor(sharedMemoryCache), + DiskIconProcessor(sharedDiskCache), + ), + jobDispatcher: CoroutineDispatcher = Executors.newFixedThreadPool( + THREADS, + NamedThreadFactory("BrowserIcons"), + ).asCoroutineDispatcher(), +) : MemoryConsumer { + private val logger = Logger("BrowserIcons") + private val maximumSize = context.resources.getDimensionPixelSize(R.dimen.mozac_browser_icons_maximum_size) + private val minimumSize = context.resources.getDimensionPixelSize(R.dimen.mozac_browser_icons_minimum_size) + private val scope = CoroutineScope(jobDispatcher) + private val backgroundHttpIconLoader = NonBlockingHttpIconLoader(httpClient) { request, resource, result -> + val desiredSize = request.getDesiredSize(context, minimumSize, maximumSize) + + val icon = decodeIconLoaderResult(result, decoders, desiredSize) + ?: generator.generate(context, request) + + process(context, processors, request, resource, icon, desiredSize) + } + + /** + * Asynchronously loads an [Icon] for the given [IconRequest]. + */ + fun loadIcon(request: IconRequest): Deferred<Icon> = scope.async { + loadIconInternalAsync(request).await().also { loadedIcon -> + logger.debug("Loaded icon (source = ${loadedIcon.source}): ${request.url}") + } + } + + /** + * Synchronously loads an [Icon] for the given [IconRequest] using an in-memory loader. + */ + private fun loadIconMemoryOnly(initialRequest: IconRequest, desiredSize: DesiredSize): Icon? { + val preparers = listOf(MemoryIconPreparer(sharedMemoryCache)) + val loaders = listOf(MemoryIconLoader(sharedMemoryCache)) + val request = prepare(context, preparers, initialRequest) + + load(context, request, loaders, decoders, desiredSize)?.let { + return it.first + } + + return null + } + + @WorkerThread + @VisibleForTesting + internal fun loadIconInternalAsync( + initialRequest: IconRequest, + size: DesiredSize? = null, + ): Deferred<Icon> = scope.async { + val desiredSize = size ?: desiredSizeForRequest(initialRequest) + + // (1) First prepare the request. + val request = prepare(context, preparers, initialRequest) + + // (2) Check whether icons should be downloaded in background. + val updatedLoaders = loaders.map { + if (it is HttpIconLoader && !initialRequest.waitOnNetworkLoad) { + backgroundHttpIconLoader + } else { + it + } + } + + // (3) Then try to load an icon. + val (icon, resource) = load(context, request, updatedLoaders, decoders, desiredSize) + ?: (generator.generate(context, request) to null) + + // (4) Finally process the icon. + process(context, processors, request, resource, icon, desiredSize) + ?: generator.generate(context, request) + } + + /** + * Installs the "icons" extension in the engine in order to dynamically load icons for loaded websites. + */ + fun install(engine: Engine, store: BrowserStore) { + engine.installBuiltInWebExtension( + id = "icons@mozac.org", + url = "resource://android/assets/extensions/browser-icons/", + onSuccess = { extension -> + Logger.debug("Installed browser-icons extension") + + store.flowScoped { flow -> subscribeToUpdates(store, flow, extension) } + }, + onError = { throwable -> + Logger.error("Could not install browser-icons extension", throwable) + }, + ) + } + + /** + * Loads an icon using [BrowserIcons] and then displays it in the [ImageView]. Synchronous loading + * via an in-memory cache is attempted first, followed by an asynchronous load as a fallback. + * If the view is detached from the window before loading is completed, then loading is cancelled. + * + * @param view [ImageView] to load icon into. + * @param request Load icon for this given [IconRequest]. + * @param placeholder [Drawable] to display while icon is loading. + * @param error [Drawable] to display if loading fails. + */ + fun loadIntoView( + view: ImageView, + request: IconRequest, + placeholder: Drawable? = null, + error: Drawable? = null, + ): Job { + return loadIntoViewInternal(WeakReference(view), request, placeholder, error) + } + + @MainThread + @VisibleForTesting + @Suppress("UndocumentedPublicFunction") // this is visible only for tests + fun loadIntoViewInternal( + view: WeakReference<ImageView>, + request: IconRequest, + placeholder: Drawable?, + error: Drawable?, + ): Job { + // If we previously started loading into the view, cancel the job. + val existingJob = view.get()?.getTag(R.id.mozac_browser_icons_tag_job) as? Job + existingJob?.cancel() + + view.get()?.setImageDrawable(placeholder) + + // Happy path: try to load icon synchronously from an in-memory cache. + val desiredSize = desiredSizeForRequest(request) + val inMemoryIcon = loadIconMemoryOnly(request, desiredSize) + if (inMemoryIcon != null) { + view.get()?.setImageBitmap(inMemoryIcon.bitmap) + return Job().also { it.complete() } + } + + // Unhappy path: if the in-memory load didn't succeed, try the expensive IO loaders. + @SuppressLint("WrongThread") + val deferredIcon = loadIconInternalAsync(request, desiredSize) + view.get()?.setTag(R.id.mozac_browser_icons_tag_job, deferredIcon) + val onAttachStateChangeListener = CancelOnDetach(deferredIcon).also { + view.get()?.addOnAttachStateChangeListener(it) + } + + return scope.launch(Dispatchers.Main) { + try { + val icon = deferredIcon.await() + view.get()?.setImageBitmap(icon.bitmap) + } catch (e: CancellationException) { + view.get()?.setImageDrawable(error) + } finally { + view.get()?.removeOnAttachStateChangeListener(onAttachStateChangeListener) + view.get()?.setTag(R.id.mozac_browser_icons_tag_job, null) + } + } + } + + /** + * Loads an icon using [BrowserIcons] into the given Composable [content]. Synchronous loading + * via an in-memory cache is attempted first, followed by an asynchronous load as a fallback. + * + * @param url The URL of the website an icon should be loaded for. + * @param iconResource Optional [IconRequest.Resource] to load the icon from. + * @param iconSize The preferred size of the icon that should be loaded. + * @param isPrivate Whether this request for this icon came from a private session. + * @param content The Composable content block to render the icon. + */ + @Composable + fun LoadableImage( + url: String, + iconResource: IconRequest.Resource? = null, + iconSize: IconRequest.Size = IconRequest.Size.DEFAULT, + isPrivate: Boolean = false, + content: @Composable IconLoaderScope.() -> Unit, + ) { + val iconResources = iconResource?.let { listOf(it) } ?: emptyList() + val request = IconRequest(url, iconSize, iconResources, null, isPrivate) + val iconLoaderScope = remember(request) { InternalIconLoaderScope() } + + // Happy path: try to load icon synchronously from an in-memory cache. + val desiredSize = desiredSizeForRequest(request) + val inMemoryIcon = loadIconMemoryOnly(request, desiredSize) + if (inMemoryIcon != null) { + iconLoaderScope.state.value = IconLoaderState.Icon( + BitmapPainter(inMemoryIcon.bitmap.asImageBitmap()), + inMemoryIcon.color, + inMemoryIcon.source, + inMemoryIcon.maskable, + ) + } else { + // Unhappy path: if the in-memory load didn't succeed, try the expensive IO loaders. + val deferredIcon = loadIconInternalAsync(request, desiredSize) + + LaunchedEffect(request) { + try { + val icon = deferredIcon.await() + iconLoaderScope.state.value = IconLoaderState.Icon( + BitmapPainter(icon.bitmap.asImageBitmap()), + icon.color, + icon.source, + icon.maskable, + ) + } catch (e: CancellationException) { + Logger.debug("Could not retrieve icon for $url", e) + } + } + } + + iconLoaderScope.content() + } + + private fun desiredSizeForRequest(request: IconRequest) = DesiredSize( + targetSize = context.resources.getDimensionPixelSize(request.size.dimen), + minSize = minimumSize, + maxSize = maximumSize, + maxScaleFactor = MAXIMUM_SCALE_FACTOR, + ) + + /** + * The device is running low on memory. This component should trim its memory usage. + */ + @Deprecated("Use onTrimMemory instead.", replaceWith = ReplaceWith("onTrimMemory")) + fun onLowMemory() { + sharedMemoryCache.clear() + } + + override fun onTrimMemory(level: Int) { + val shouldClearMemoryCache = when (level) { + // Foreground: The device is running much lower on memory. The app is running and not killable, but the + // system wants us to release unused resources to improve system performance. + ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW, + // Foreground: The device is running extremely low on memory. The app is not yet considered a killable + // process, but the system will begin killing background processes if apps do not release resources. + ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL, + -> true + + // Background: The system is running low on memory and our process is near the middle of the LRU list. + // If the system becomes further constrained for memory, there's a chance our process will be killed. + ComponentCallbacks2.TRIM_MEMORY_MODERATE, + // Background: The system is running low on memory and our process is one of the first to be killed + // if the system does not recover memory now. + ComponentCallbacks2.TRIM_MEMORY_COMPLETE, + -> true + + else -> false + } + + if (shouldClearMemoryCache) { + sharedMemoryCache.clear() + } + } + + /** + * Clears all icons and metadata from disk and memory. + * + * This will clear the default disk and memory cache that is used by the default configuration. + * If custom [IconLoader] and [IconProcessor] instances with a custom storage are provided to + * [BrowserIcons] then the calling app is responsible for clearing that data. + */ + fun clear() { + sharedDiskCache.clear(context) + sharedMemoryCache.clear() + } + + private suspend fun subscribeToUpdates( + store: BrowserStore, + flow: Flow<BrowserState>, + extension: WebExtension, + ) { + // Whenever we see a new EngineSession in the store then we register our content message + // handler if it has not been added yet. + + flow.map { it.tabs } + .filterChanged { it.engineState.engineSession } + .collect { state -> + val engineSession = state.engineState.engineSession ?: return@collect + + if (extension.hasContentMessageHandler(engineSession, EXTENSION_MESSAGING_NAME)) { + return@collect + } + + val handler = IconMessageHandler(store, state.id, state.content.private, this) + extension.registerContentMessageHandler(engineSession, EXTENSION_MESSAGING_NAME, handler) + } + } +} + +private fun prepare(context: Context, preparers: List<IconPreprarer>, request: IconRequest): IconRequest = + preparers.fold(request) { preparedRequest, preparer -> + preparer.prepare(context, preparedRequest) + } + +private fun load( + context: Context, + request: IconRequest, + loaders: List<IconLoader>, + decoders: List<ImageDecoder>, + desiredSize: DesiredSize, +): Pair<Icon, IconRequest.Resource>? { + request.resources + .asSequence() + .distinct() + .sortedWith(IconResourceComparator) + .forEach { resource -> + loaders.forEach { loader -> + val result = loader.load(context, request, resource) + + val icon = decodeIconLoaderResult(result, decoders, desiredSize) + + if (icon != null) { + return Pair(icon, resource) + } + } + } + + return null +} + +private fun decodeIconLoaderResult( + result: IconLoader.Result, + decoders: List<ImageDecoder>, + desiredSize: DesiredSize, +): Icon? = when (result) { + IconLoader.Result.NoResult -> null + + is IconLoader.Result.BitmapResult -> Icon(result.bitmap, source = result.source) + + is IconLoader.Result.BytesResult -> + decodeBytes(result.bytes, decoders, desiredSize)?.let { Icon(it, source = result.source) } +} + +@VisibleForTesting +internal fun IconRequest.getDesiredSize(context: Context, minimumSize: Int, maximumSize: Int) = + DesiredSize( + targetSize = context.resources.getDimensionPixelSize(size.dimen), + minSize = minimumSize, + maxSize = maximumSize, + maxScaleFactor = MAXIMUM_SCALE_FACTOR, + ) + +private fun decodeBytes( + data: ByteArray, + decoders: List<ImageDecoder>, + desiredSize: DesiredSize, +): Bitmap? { + decoders.forEach { decoder -> + val bitmap = decoder.decode(data, desiredSize) + + if (bitmap != null) { + return bitmap + } + } + + return null +} + +private fun process( + context: Context, + processors: List<IconProcessor>, + request: IconRequest, + resource: IconRequest.Resource?, + icon: Icon?, + desiredSize: DesiredSize, +): Icon? = + processors.fold(icon) { processedIcon, processor -> + if (processedIcon == null) return null + processor.process(context, request, resource, processedIcon, desiredSize) + } diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/Icon.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/Icon.kt new file mode 100644 index 0000000000..6974ee0420 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/Icon.kt @@ -0,0 +1,52 @@ +/* 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 mozilla.components.browser.icons + +import android.graphics.Bitmap + +/** + * An [Icon] returned by [BrowserIcons] after processing an [IconRequest] + * + * @property bitmap The loaded icon as a [Bitmap]. + * @property color The dominant color of the icon. Will be null if no color could be extracted. + * @property source The source of the icon. + * @property maskable True if the icon represents as full-bleed icon that can be cropped to other shapes. + */ +data class Icon( + val bitmap: Bitmap, + val color: Int? = null, + val source: Source, + val maskable: Boolean = false, +) { + /** + * The source of an [Icon]. + */ + enum class Source { + /** + * This icon was generated. + */ + GENERATOR, + + /** + * This icon was downloaded. + */ + DOWNLOAD, + + /** + * This icon was inlined in the document. + */ + INLINE, + + /** + * This icon was loaded from an in-memory cache. + */ + MEMORY, + + /** + * This icon was loaded from a disk cache. + */ + DISK, + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconRequest.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconRequest.kt new file mode 100644 index 0000000000..9df9e3404e --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconRequest.kt @@ -0,0 +1,140 @@ +/* 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 mozilla.components.browser.icons + +import androidx.annotation.ColorInt +import androidx.annotation.DimenRes +import mozilla.components.concept.engine.manifest.Size as HtmlSize + +/** + * A request to load an [Icon]. + * + * @property url The URL of the website an icon should be loaded for. + * @property size The preferred size of the icon that should be loaded. + * @property resources An optional list of icon resources to load the icon from. + * @property color The suggested dominant color of the icon. + * @property isPrivate Whether this request for this icon came from a private session. + * @property waitOnNetworkLoad Whether client code should wait on the resource being loaded or + * loading can continue in background. + */ +data class IconRequest( + val url: String, + val size: Size = Size.DEFAULT, + val resources: List<Resource> = emptyList(), + @ColorInt val color: Int? = null, + val isPrivate: Boolean = false, + val waitOnNetworkLoad: Boolean = true, +) { + + /** + * Supported sizes. + * + * We are trying to limit the supported sizes in order to optimize our caching strategy. + */ + enum class Size(@DimenRes val dimen: Int) { + DEFAULT(R.dimen.mozac_browser_icons_size_default), + LAUNCHER(R.dimen.mozac_browser_icons_size_launcher), + LAUNCHER_ADAPTIVE(R.dimen.mozac_browser_icons_size_launcher_adaptive), + } + + /** + * An icon resource that can be loaded. + * + * @param url URL the icon resource can be fetched from. + * @param type The type of the icon. + * @param sizes Optional list of icon sizes provided by this resource (if known). + * @param mimeType Optional MIME type of this icon resource (if known). + * @param maskable True if the icon represents as full-bleed icon that can be cropped to other shapes. + */ + data class Resource( + val url: String, + val type: Type, + val sizes: List<HtmlSize> = emptyList(), + val mimeType: String? = null, + val maskable: Boolean = false, + ) { + /** + * An icon resource type. + */ + enum class Type { + /** + * A favicon ("icon" or "shortcut icon"). + * + * https://en.wikipedia.org/wiki/Favicon + */ + FAVICON, + + /** + * An Apple touch icon. + * + * Originally used for adding an icon to the home screen of an iOS device. + * + * https://realfavicongenerator.net/blog/apple-touch-icon-the-good-the-bad-the-ugly/ + */ + APPLE_TOUCH_ICON, + + /** + * A "fluid" icon. + * + * Fluid is a macOS application that wraps website to look and behave like native desktop + * applications. + * + * https://fluidapp.com/ + */ + FLUID_ICON, + + /** + * An "image_src" icon. + * + * Yahoo and Facebook used this icon for previewing web content. Since then Facebook seems to use + * OpenGraph instead. However website still define "image_src" icons. + * + * https://www.niallkennedy.com/blog/2009/03/enhanced-social-share.html + */ + IMAGE_SRC, + + /** + * An "Open Graph" image. + * + * "An image URL which should represent your object within the graph." + * + * http://ogp.me/ + */ + OPENGRAPH, + + /** + * A "Twitter Card" image. + * + * "URL of image to use in the card." + * + * https://developer.twitter.com/en/docs/tweets/optimize-with-cards/overview/markup.html + */ + TWITTER, + + /** + * A "Microsoft tile" image. + * + * When pinning sites on Windows this image is used. + * + * "Specifies the background image for live tile." + * + * https://technet.microsoft.com/en-us/windows/dn255024(v=vs.60) + */ + MICROSOFT_TILE, + + /** + * An icon found in Mozilla's "tippy top" list. + */ + TIPPY_TOP, + + /** + * A Web App Manifest image. + * + * https://developer.mozilla.org/en-US/docs/Web/Manifest/icons + */ + MANIFEST_ICON, + } + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/compose/IconLoaderScope.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/compose/IconLoaderScope.kt new file mode 100644 index 0000000000..f9d283471f --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/compose/IconLoaderScope.kt @@ -0,0 +1,59 @@ +/* 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 mozilla.components.browser.icons.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import mozilla.components.browser.icons.BrowserIcons + +/** + * The scope of a [BrowserIcons.Loader] block. + */ +interface IconLoaderScope { + val state: MutableState<IconLoaderState> +} + +/** + * Renders the inner [content] block once an icon was loaded. + */ +@Composable +fun IconLoaderScope.WithIcon( + content: @Composable (icon: IconLoaderState.Icon) -> Unit, +) { + WithInternalState { + val state = state.value + if (state is IconLoaderState.Icon) { + content(state) + } + } +} + +/** + * Renders the inner [content] block until an icon was loaded. + */ +@Composable +fun IconLoaderScope.Placeholder( + content: @Composable () -> Unit, +) { + WithInternalState { + val state = state.value + if (state is IconLoaderState.Loading) { + content() + } + } +} + +@Composable +private fun IconLoaderScope.WithInternalState( + content: @Composable InternalIconLoaderScope.() -> Unit, +) { + val internalScope = this as InternalIconLoaderScope + internalScope.content() +} + +internal class InternalIconLoaderScope( + override val state: MutableState<IconLoaderState> = mutableStateOf(IconLoaderState.Loading), +) : IconLoaderScope diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/compose/IconLoaderState.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/compose/IconLoaderState.kt new file mode 100644 index 0000000000..c04f658fa4 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/compose/IconLoaderState.kt @@ -0,0 +1,35 @@ +/* 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 mozilla.components.browser.icons.compose + +import androidx.compose.ui.graphics.painter.Painter +import mozilla.components.browser.icons.BrowserIcons +import mozilla.components.browser.icons.Icon.Source + +/** + * The state an [IconLoaderScope] is in. + */ +sealed class IconLoaderState { + /** + * The [BrowserIcons.Loader] is currently loading the icon. + */ + object Loading : IconLoaderState() + + /** + * The [BrowserIcons.Loader] has completed loading the icon and it is available through the + * attached [painter]. + * + * @property painter The loaded or generated icon as a [Painter]. + * @property color The dominant color of the icon. Will be null if no color could be extracted. + * @property source The source of the icon. + * @property maskable True if the icon represents as full-bleed icon that can be cropped to other shapes. + */ + data class Icon( + val painter: Painter, + val color: Int?, + val source: Source, + val maskable: Boolean, + ) : IconLoaderState() +} diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/compose/Loader.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/compose/Loader.kt new file mode 100644 index 0000000000..7bc98a9dd1 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/compose/Loader.kt @@ -0,0 +1,50 @@ +/* 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 mozilla.components.browser.icons.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import mozilla.components.browser.icons.BrowserIcons +import mozilla.components.browser.icons.IconRequest + +/** + * Loads an icon for the given [url] (or generates one) and makes it available to the inner + * [IconLoaderScope]. + * + * The loaded image will be available through the [WithIcon] composable. While the icon is still + * loading [Placeholder] will get rendered. + * + * @param url The URL of the website an icon should be loaded for. Note that this is the URL of the + * website the icon is *for* (e.g. https://github.com) and not the URL of the icon itself (e.g. + * https://github.com/favicon.ico) + * @param size The preferred size of the icon that should be loaded. + * @param isPrivate Whether or not a private request (like in private browsing) should be used to + * download the icon (if needed). + */ +@Composable +fun BrowserIcons.Loader( + url: String, + size: IconRequest.Size = IconRequest.Size.DEFAULT, + isPrivate: Boolean = false, + content: @Composable IconLoaderScope.() -> Unit, +) { + val request = IconRequest(url, size, emptyList(), null, isPrivate) + val scope = remember(request) { InternalIconLoaderScope() } + + LaunchedEffect(request) { + val icon = loadIcon(request).await() + scope.state.value = IconLoaderState.Icon( + BitmapPainter(icon.bitmap.asImageBitmap()), + icon.color, + icon.source, + icon.maskable, + ) + } + + scope.content() +} diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/decoder/ICOIconDecoder.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/decoder/ICOIconDecoder.kt new file mode 100644 index 0000000000..35ac3f6006 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/decoder/ICOIconDecoder.kt @@ -0,0 +1,42 @@ +/* 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 mozilla.components.browser.icons.decoder + +import android.graphics.Bitmap +import mozilla.components.browser.icons.decoder.ico.decodeDirectoryEntries +import mozilla.components.browser.icons.utils.findBestSize +import mozilla.components.support.images.DesiredSize +import mozilla.components.support.images.decoder.ImageDecoder + +// Some geometry of an ICO file. +internal const val HEADER_LENGTH_BYTES = 6 +internal const val ICON_DIRECTORY_ENTRY_LENGTH_BYTES = 16 + +internal const val ZERO_BYTE = 0.toByte() + +/** + * [ImageDecoder] implementation for decoding ICO files. + * + * An ICO file is a container format that may hold up to 255 images in either BMP or PNG format. + * A mixture of image types may not exist. + */ +class ICOIconDecoder : ImageDecoder { + override fun decode(data: ByteArray, desiredSize: DesiredSize): Bitmap? { + val (targetSize, _, maxSize, maxScaleFactor) = desiredSize + val entries = decodeDirectoryEntries(data, maxSize) + + val bestEntry = entries.map { entry -> + Pair(entry.width, entry.height) + }.findBestSize(targetSize, maxSize, maxScaleFactor) ?: return null + + for (entry in entries) { + if (entry.width == bestEntry.first && entry.height == bestEntry.second) { + return entry.toBitmap(data) + } + } + + return null + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/decoder/SvgIconDecoder.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/decoder/SvgIconDecoder.kt new file mode 100644 index 0000000000..b4a845e153 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/decoder/SvgIconDecoder.kt @@ -0,0 +1,108 @@ +/* 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 mozilla.components.browser.icons.decoder + +import android.graphics.Bitmap +import android.graphics.Bitmap.Config.ARGB_8888 +import android.graphics.Canvas +import android.graphics.RectF +import androidx.annotation.VisibleForTesting +import androidx.core.graphics.createBitmap +import com.caverock.androidsvg.SVG +import com.caverock.androidsvg.SVGParseException +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.images.DesiredSize +import mozilla.components.support.images.decoder.ImageDecoder + +/** + * [ImageDecoder] that will use the AndroidSVG in order to decode the byte data. + * + * The code is largely borrowed from [coil-svg](https://github.com/coil-kt/coil/blob/2.4.0/coil-svg/src/main/java/coil/decode/SvgDecoder.kt) + * with some fixed options. + */ +class SvgIconDecoder : ImageDecoder { + private val logger = Logger("SvgIconDecoder") + + @Suppress("TooGenericExceptionCaught") + override fun decode(data: ByteArray, desiredSize: DesiredSize): Bitmap? = + try { + maybeDecode(data, desiredSize) + } catch (throwable: Throwable) { + when (throwable) { + is IllegalArgumentException, + is NullPointerException, + is SVGParseException, + -> { + logger.error("Failed to parse the byte data to Bitmap", throwable) + } + is OutOfMemoryError -> { + logger.error("Failed to decode the byte data due to OutOfMemoryError") + } + else -> { + logger.error("Failed to decode byte data: " + throwable.message.toString(), throwable) + } + } + null + } + + /** + * Decodes an SVG image. + * + * @param data Image bytes to decode. + * @param desiredSize Desired size for the image. + * @return decoded image Bitmap. + * @throws IllegalArgumentException in case the parsed SVG document is empty. + * @throws NullPointerException in case of malformed image bytes. + * @throws SVGParseException in case of incorrect SVG element. + * @throws OutOfMemoryError in case of out of memory when decoding image bytes. + */ + @Throws( + IllegalArgumentException::class, + NullPointerException::class, + SVGParseException::class, + OutOfMemoryError::class, + ) + @VisibleForTesting + internal fun maybeDecode(data: ByteArray, desiredSize: DesiredSize): Bitmap { + val svg = SVG.getFromInputStream(data.inputStream()) + + val svgWidth: Float + val svgHeight: Float + val viewBox: RectF? = svg.documentViewBox + if (viewBox != null) { + svgWidth = viewBox.width() + svgHeight = viewBox.height() + } else { + svgWidth = svg.documentWidth + svgHeight = svg.documentHeight + } + + var bitmapWidth = desiredSize.targetSize + var bitmapHeight = desiredSize.targetSize + + // Scale the bitmap to SVG maintaining the aspect ratio + if (svgWidth > 0 && svgHeight > 0) { + val widthPercent = bitmapWidth / svgWidth.toDouble() + val heightPercent = bitmapHeight / svgHeight.toDouble() + val multiplier = minOf(widthPercent, heightPercent) + + bitmapWidth = (multiplier * svgWidth).toInt() + bitmapHeight = (multiplier * svgHeight).toInt() + } + + // Set the SVG's view box to enable scaling if it is not set. + if (viewBox == null && svgWidth > 0 && svgHeight > 0) { + svg.setDocumentViewBox(0f, 0f, svgWidth, svgHeight) + } + + svg.setDocumentWidth("100%") + svg.setDocumentHeight("100%") + + val bitmap = createBitmap(bitmapWidth, bitmapHeight, ARGB_8888) + svg.renderToCanvas(Canvas(bitmap)) + + return bitmap + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/decoder/ico/IconDirectoryEntry.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/decoder/ico/IconDirectoryEntry.kt new file mode 100644 index 0000000000..b39c72d26f --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/decoder/ico/IconDirectoryEntry.kt @@ -0,0 +1,315 @@ +/* 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 mozilla.components.browser.icons.decoder.ico + +import android.graphics.Bitmap +import mozilla.components.browser.icons.decoder.HEADER_LENGTH_BYTES +import mozilla.components.browser.icons.decoder.ICON_DIRECTORY_ENTRY_LENGTH_BYTES +import mozilla.components.browser.icons.decoder.ZERO_BYTE +import mozilla.components.support.images.decoder.ImageDecoder +import mozilla.components.support.ktx.kotlin.containsAtOffset +import mozilla.components.support.ktx.kotlin.toBitmap + +const val MAX_BITS_PER_PIXEL = 32 + +internal data class IconDirectoryEntry( + val width: Int, + val height: Int, + val paletteSize: Int, + val bitsPerPixel: Int, + val payloadSize: Int, + val payloadOffset: Int, + val payloadIsPNG: Boolean, + val directoryIndex: Int, +) : Comparable<IconDirectoryEntry> { + + override fun compareTo(other: IconDirectoryEntry): Int = when { + width > other.width -> 1 + width < other.width -> -1 + + // Where both images exceed the max BPP, take the smaller of the two BPP values. + bitsPerPixel >= MAX_BITS_PER_PIXEL && other.bitsPerPixel >= MAX_BITS_PER_PIXEL && + bitsPerPixel < other.bitsPerPixel -> 1 + bitsPerPixel >= MAX_BITS_PER_PIXEL && other.bitsPerPixel >= MAX_BITS_PER_PIXEL && + bitsPerPixel > other.bitsPerPixel -> -1 + + // Otherwise, take the larger of the BPP values. + bitsPerPixel > other.bitsPerPixel -> 1 + bitsPerPixel < other.bitsPerPixel -> -1 + + // Prefer large palettes. + paletteSize > other.paletteSize -> 1 + paletteSize < other.paletteSize -> -1 + + // Prefer smaller payloads. + payloadSize < other.payloadSize -> 1 + payloadSize > other.payloadSize -> -1 + + // If all else fails, prefer PNGs over BMPs. They tend to be smaller. + payloadIsPNG && !other.payloadIsPNG -> 1 + !payloadIsPNG && other.payloadIsPNG -> -1 + + else -> 0 + } + + @Suppress("MagicNumber") + fun toBitmap(data: ByteArray): Bitmap? { + if (payloadIsPNG) { + // PNG payload. Simply extract it and let Android decode it. + return data.toBitmap(payloadOffset, payloadSize) + } + + // The payload is a BMP, so we need to do some magic to get the decoder to do what we want. + // We construct an ICO containing just the image we want, and let Android do the rest. + val decodeTarget = ByteArray(HEADER_LENGTH_BYTES + ICON_DIRECTORY_ENTRY_LENGTH_BYTES + payloadSize) + + // Set the type field in the ICO header. + decodeTarget[2] = 1.toByte() + + // Set the num-images field in the header to 1. + decodeTarget[4] = 1.toByte() + + // Copy the ICONDIRENTRY we need into the new buffer. + val offset = HEADER_LENGTH_BYTES + (directoryIndex * ICON_DIRECTORY_ENTRY_LENGTH_BYTES) + System.arraycopy(data, offset, decodeTarget, HEADER_LENGTH_BYTES, ICON_DIRECTORY_ENTRY_LENGTH_BYTES) + + val singlePayloadOffset = HEADER_LENGTH_BYTES + ICON_DIRECTORY_ENTRY_LENGTH_BYTES + + System.arraycopy(data, payloadOffset, decodeTarget, singlePayloadOffset, payloadSize) + + // Update the offset field of the ICONDIRENTRY to make the new ICO valid. + decodeTarget[HEADER_LENGTH_BYTES + 12] = singlePayloadOffset.toByte() + decodeTarget[HEADER_LENGTH_BYTES + 13] = singlePayloadOffset.ushr(8).toByte() + decodeTarget[HEADER_LENGTH_BYTES + 14] = singlePayloadOffset.ushr(16).toByte() + decodeTarget[HEADER_LENGTH_BYTES + 15] = singlePayloadOffset.ushr(24).toByte() + + return decodeTarget.toBitmap() + } +} + +/** + * The format consists of a header specifying the number, n, of images, followed by the Icon Directory. + * + * The Icon Directory consists of n Icon Directory Entries, each 16 bytes in length, specifying, for + * the corresponding image, the dimensions, colour information, payload size, and location in the file. + * + * All numerical fields follow a little-endian byte ordering. + * + * Header format: + * + * ``` + * 0 1 2 3 + * 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * | Reserved field. Must be zero | Type (1 for ICO, 2 for CUR) | + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * | Image count (n) | + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * ``` + * + * The type field is expected to always be 1. CUR format images should not be used for Favicons. + * + * + * Icon Directory Entry format: + * ``` + * 0 1 2 3 + * 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * | Image width | Image height | Palette size | Reserved (0) | + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * | Colour plane count | Bits per pixel | + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * | Size of image data, in bytes | + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * | Start of image data, as an offset from start of file | + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * ``` + * + * Image dimensions of zero are to be interpreted as image dimensions of 256. + * + * The palette size field records the number of colours in the stored BMP, if a palette is used. Zero + * if the payload is a PNG or no palette is in use. + * + * The number of colour planes is, usually, 0 (Not in use) or 1. Values greater than 1 are to be + * interpreted not as a colour plane count, but as a multiplying factor on the bits per pixel field. + * (Apparently 65535 was not deemed a sufficiently large maximum value of bits per pixel.) + * + * The Icon Directory consists of n-many Icon Directory Entries in sequence, with no gaps. + */ +@Suppress("MagicNumber", "ReturnCount", "ComplexMethod", "NestedBlockDepth", "ComplexCondition") +internal fun decodeDirectoryEntries(data: ByteArray, maxSize: Int): List<IconDirectoryEntry> { + // Fail if we don't have enough space for the header. + if (data.size < HEADER_LENGTH_BYTES) { + return emptyList() + } + + // Check that the reserved fields in the header are indeed zero, and that the type field + // specifies ICO. If not, we've probably been given something that isn't really an ICO. + if (data[0] != ZERO_BYTE || + data[1] != ZERO_BYTE || + data[2] != 1.toByte() || + data[3] != ZERO_BYTE + ) { + return emptyList() + } + + // Here, and in many other places, byte values are ANDed with 0xFF. This is because Java + // bytes are signed - to obtain a numerical value of a longer type which holds the unsigned + // interpretation of the byte of interest, we do this. + val numEncodedImages = (data[4].toInt() and 0xFF) or ((data[5].toInt() and 0xFF) shl 8) + + // Fail if there are no images or the field is corrupt. + if (numEncodedImages <= 0) { + return emptyList() + } + + val headerAndDirectorySize = HEADER_LENGTH_BYTES + numEncodedImages * ICON_DIRECTORY_ENTRY_LENGTH_BYTES + + // Fail if there is not enough space in the buffer for the stated number of icondir entries, + // let alone the data. + if (data.size < headerAndDirectorySize) { + return emptyList() + } + + // Put the pointer on the first byte of the first Icon Directory Entry. + var bufferIndex = HEADER_LENGTH_BYTES + + // We now iterate over the Icon Directory, decoding each entry as we go. We also need to + // discard all entries except one >= the maximum interesting size. + + // Size of the smallest image larger than the limit encountered. + var minimumMaximum = Integer.MAX_VALUE + + // Used to track the best entry for each size. The entries we want to keep. + val iconMap = mutableMapOf<Int, IconDirectoryEntry>() + + var i = 0 + while (i < numEncodedImages) { + // Decode the Icon Directory Entry at this offset. + val newEntry = createIconDirectoryEntry(data, bufferIndex, i) + + if (newEntry == null) { + i++ + bufferIndex += ICON_DIRECTORY_ENTRY_LENGTH_BYTES + continue + } + + if (newEntry.width > maxSize) { + // If we already have a smaller image larger than the maximum size of interest, we + // don't care about the new one which is larger than the smallest image larger than + // the maximum size. + if (newEntry.width >= minimumMaximum) { + i++ + bufferIndex += ICON_DIRECTORY_ENTRY_LENGTH_BYTES + continue + } + + // Remove the previous minimum-maximum. + iconMap.remove(minimumMaximum) + + minimumMaximum = newEntry.width + } + + val oldEntry = iconMap[newEntry.width] + if (oldEntry == null) { + iconMap[newEntry.width] = newEntry + i++ + bufferIndex += ICON_DIRECTORY_ENTRY_LENGTH_BYTES + continue + } + + if (oldEntry < newEntry) { + iconMap[newEntry.width] = newEntry + } + i++ + bufferIndex += ICON_DIRECTORY_ENTRY_LENGTH_BYTES + } + + val count = iconMap.size + + // Abort if no entries are desired (Perhaps all are corrupt?) + if (count == 0) { + return emptyList() + } + + return iconMap.values.toList() +} + +@Suppress("MagicNumber") +internal fun createIconDirectoryEntry( + data: ByteArray, + entryOffset: Int, + directoryIndex: Int, +): IconDirectoryEntry? { + // Verify that the reserved field is really zero. + if (data[entryOffset + 3] != ZERO_BYTE) { + return null + } + + // Verify that the entry points to a region that actually exists in the buffer, else bin it. + var fieldPtr = entryOffset + 8 + val entryLength = data[fieldPtr].toInt() and 0xFF or ( + (data[fieldPtr + 1].toInt() and 0xFF) shl 8 + ) or ( + (data[fieldPtr + 2].toInt() and 0xFF) shl 16 + ) or ( + (data[fieldPtr + 3].toInt() and 0xFF) shl 24 + ) + + // Advance to the offset field. + fieldPtr += 4 + + val payloadOffset = data[fieldPtr].toInt() and 0xFF or ( + (data[fieldPtr + 1].toInt() and 0xFF) shl 8 + ) or ( + (data[fieldPtr + 2].toInt() and 0xFF) shl 16 + ) or ( + (data[fieldPtr + 3].toInt() and 0xFF) shl 24 + ) + + // Fail if the entry describes a region outside the buffer. + if (payloadOffset < 0 || entryLength < 0 || payloadOffset + entryLength > data.size) { + return null + } + + // Extract the image dimensions. + var imageWidth = data[entryOffset].toInt() and 0xFF + var imageHeight = data[entryOffset + 1].toInt() and 0xFF + + // Because Microsoft, a size value of zero represents an image size of 256. + if (imageWidth == 0) { + imageWidth = 256 + } + + if (imageHeight == 0) { + imageHeight = 256 + } + + // If the image uses a colour palette, this is the number of colours, otherwise this is zero. + val paletteSize = data[entryOffset + 2].toInt() and 0xFF + + // The plane count - usually 0 or 1. When > 1, taken as multiplier on bitsPerPixel. + val colorPlanes = data[entryOffset + 4].toInt() and 0xFF + + var bitsPerPixel = (data[entryOffset + 6].toInt() and 0xFF) or ((data[entryOffset + 7].toInt() and 0xFF) shl 8) + + if (colorPlanes > 1) { + bitsPerPixel *= colorPlanes + } + + // Look for PNG magic numbers at the start of the payload. + val payloadIsPNG = data.containsAtOffset(payloadOffset, ImageDecoder.Companion.ImageMagicNumbers.PNG.value) + + return IconDirectoryEntry( + imageWidth, + imageHeight, + paletteSize, + bitsPerPixel, + entryLength, + payloadOffset, + payloadIsPNG, + directoryIndex, + ) +} diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/extension/IconMessage.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/extension/IconMessage.kt new file mode 100644 index 0000000000..155f93bbe9 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/extension/IconMessage.kt @@ -0,0 +1,118 @@ +/* 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 mozilla.components.browser.icons.extension + +import mozilla.components.browser.icons.IconRequest +import mozilla.components.concept.engine.manifest.Size +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.ktx.android.org.json.asSequence +import mozilla.components.support.ktx.android.org.json.toJSONArray +import mozilla.components.support.ktx.android.org.json.tryGetString +import mozilla.components.support.ktx.kotlin.sanitizeURL +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +private val typeMap: Map<String, IconRequest.Resource.Type> = mutableMapOf( + "manifest" to IconRequest.Resource.Type.MANIFEST_ICON, + "icon" to IconRequest.Resource.Type.FAVICON, + "shortcut icon" to IconRequest.Resource.Type.FAVICON, + "fluid-icon" to IconRequest.Resource.Type.FLUID_ICON, + "apple-touch-icon" to IconRequest.Resource.Type.APPLE_TOUCH_ICON, + "image_src" to IconRequest.Resource.Type.IMAGE_SRC, + "apple-touch-icon image_src" to IconRequest.Resource.Type.APPLE_TOUCH_ICON, + "apple-touch-icon-precomposed" to IconRequest.Resource.Type.APPLE_TOUCH_ICON, + "og:image" to IconRequest.Resource.Type.OPENGRAPH, + "og:image:url" to IconRequest.Resource.Type.OPENGRAPH, + "og:image:secure_url" to IconRequest.Resource.Type.OPENGRAPH, + "twitter:image" to IconRequest.Resource.Type.TWITTER, + "msapplication-TileImage" to IconRequest.Resource.Type.MICROSOFT_TILE, +) + +private fun Map<String, IconRequest.Resource.Type>.reverseLookup(type: IconRequest.Resource.Type): String { + forEach { (value, currentType) -> + if (currentType == type) { + return value + } + } + + throw IllegalArgumentException("Unknown type: $type") +} + +internal fun List<IconRequest.Resource>.toJSON(): JSONArray { + return mapNotNull { resource -> + if (resource.type == IconRequest.Resource.Type.TIPPY_TOP) { + // Ignore the URLs coming from the "tippy top" list. + return@mapNotNull null + } + + JSONObject().apply { + put("href", resource.url) + + resource.mimeType?.let { put("mimeType", it) } + + put("type", typeMap.reverseLookup(resource.type)) + + val sizeArray = resource.sizes.map { size -> size.toString() }.toJSONArray() + put("sizes", sizeArray) + + put("maskable", resource.maskable) + } + }.toJSONArray() +} + +internal fun JSONObject.toIconRequest(isPrivate: Boolean): IconRequest? { + return try { + val url = getString("url") + + IconRequest(url, isPrivate = isPrivate, resources = getJSONArray("icons").toIconResources()) + } catch (e: JSONException) { + Logger.warn("Could not parse message from icons extensions", e) + null + } +} + +internal fun JSONArray.toIconResources(): List<IconRequest.Resource> { + return asSequence { i -> getJSONObject(i) } + .mapNotNull { it.toIconResource() } + .toList() +} + +private fun JSONObject.toIconResource(): IconRequest.Resource? { + try { + val url = getString("href") + val type = typeMap[getString("type")] ?: return null + val sizes = optJSONArray("sizes").toResourceSizes() + val mimeType = tryGetString("mimeType") + val maskable = optBoolean("maskable", false) + + return IconRequest.Resource( + url = url.sanitizeURL(), + type = type, + sizes = sizes, + mimeType = if (mimeType.isNullOrEmpty()) null else mimeType, + maskable = maskable, + ) + } catch (e: JSONException) { + Logger.warn("Could not parse message from icons extensions", e) + return null + } +} + +private fun JSONArray?.toResourceSizes(): List<Size> { + val array = this ?: return emptyList() + + return try { + array.asSequence { i -> getString(i) } + .mapNotNull { raw -> Size.parse(raw) } + .toList() + } catch (e: JSONException) { + Logger.warn("Could not parse message from icons extensions", e) + emptyList() + } catch (e: NumberFormatException) { + Logger.warn("Could not parse message from icons extensions", e) + emptyList() + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/extension/IconMessageHandler.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/extension/IconMessageHandler.kt new file mode 100644 index 0000000000..ee147b6854 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/extension/IconMessageHandler.kt @@ -0,0 +1,53 @@ +/* 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 mozilla.components.browser.icons.extension + +import androidx.annotation.VisibleForTesting +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import mozilla.components.browser.icons.BrowserIcons +import mozilla.components.browser.icons.IconRequest +import mozilla.components.browser.state.action.ContentAction +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.webextension.MessageHandler +import org.json.JSONObject + +/** + * [MessageHandler] implementation that receives messages from the icons web extensions and performs icon loads. + */ +internal class IconMessageHandler( + private val store: BrowserStore, + private val sessionId: String, + private val private: Boolean, + private val icons: BrowserIcons, +) : MessageHandler { + private val scope = CoroutineScope(Dispatchers.IO) + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) // This only exists so that we can wait in tests. + internal var lastJob: Job? = null + + override fun onMessage(message: Any, source: EngineSession?): Any { + if (message is JSONObject) { + message.toIconRequest(private)?.let { loadRequest(it) } + } else { + throw IllegalStateException("Received unexpected message: $message") + } + + // Needs to return something that is not null and not Unit: + // https://github.com/mozilla-mobile/android-components/issues/2969 + return "" + } + + private fun loadRequest(request: IconRequest) { + lastJob = scope.launch { + val icon = icons.loadIcon(request).await() + + store.dispatch(ContentAction.UpdateIconAction(sessionId, request.url, icon.bitmap)) + } + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/extension/WebAppManifest.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/extension/WebAppManifest.kt new file mode 100644 index 0000000000..482e2b1da0 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/extension/WebAppManifest.kt @@ -0,0 +1,49 @@ +/* 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 mozilla.components.browser.icons.extension + +import android.graphics.Color +import android.os.Build +import android.os.Build.VERSION.SDK_INT +import mozilla.components.browser.icons.IconRequest +import mozilla.components.browser.icons.IconRequest.Resource.Type.MANIFEST_ICON +import mozilla.components.browser.icons.IconRequest.Size.LAUNCHER +import mozilla.components.browser.icons.IconRequest.Size.LAUNCHER_ADAPTIVE +import mozilla.components.concept.engine.manifest.WebAppManifest +import mozilla.components.concept.engine.manifest.WebAppManifest.Icon.Purpose + +/** + * Creates an [IconRequest] for retrieving the icon specified in the manifest. + */ +fun WebAppManifest.toIconRequest() = IconRequest( + url = startUrl, + size = if (SDK_INT >= Build.VERSION_CODES.O) LAUNCHER_ADAPTIVE else LAUNCHER, + resources = icons + .filter { Purpose.MASKABLE in it.purpose || Purpose.ANY in it.purpose } + .map { it.toIconResource() }, + color = backgroundColor, +) + +/** + * Creates an [IconRequest] for retrieving a monochrome icon specified in the manifest. + */ +fun WebAppManifest.toMonochromeIconRequest() = IconRequest( + url = startUrl, + size = IconRequest.Size.DEFAULT, + resources = icons + .filter { Purpose.MONOCHROME in it.purpose } + .map { it.toIconResource() }, + color = Color.WHITE, +) + +private fun WebAppManifest.Icon.toIconResource(): IconRequest.Resource { + return IconRequest.Resource( + url = src, + type = MANIFEST_ICON, + sizes = sizes, + mimeType = type, + maskable = Purpose.MASKABLE in purpose, + ) +} diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/generator/DefaultIconGenerator.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/generator/DefaultIconGenerator.kt new file mode 100644 index 0000000000..6b12009755 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/generator/DefaultIconGenerator.kt @@ -0,0 +1,106 @@ +/* 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 mozilla.components.browser.icons.generator + +import android.content.Context +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.Bitmap.Config.ARGB_8888 +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.util.TypedValue +import androidx.annotation.ArrayRes +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import androidx.annotation.DimenRes +import androidx.core.content.ContextCompat +import mozilla.components.browser.icons.Icon +import mozilla.components.browser.icons.IconRequest +import mozilla.components.browser.icons.R +import mozilla.components.support.ktx.kotlin.getRepresentativeCharacter +import mozilla.components.support.ktx.kotlin.getRepresentativeSnippet +import kotlin.math.abs + +/** + * [IconGenerator] implementation that will generate an icon with a background color, rounded corners and a letter + * representing the URL. + */ +class DefaultIconGenerator( + @DimenRes private val cornerRadiusDimen: Int? = R.dimen.mozac_browser_icons_generator_default_corner_radius, + @ColorRes private val textColorRes: Int = R.color.mozac_browser_icons_generator_default_text_color, + @ArrayRes private val backgroundColorsRes: Int = R.array.mozac_browser_icons_photon_palette, +) : IconGenerator { + + override fun generate(context: Context, request: IconRequest): Icon { + val size = context.resources.getDimension(request.size.dimen) + val sizePx = size.toInt() + + val bitmap = Bitmap.createBitmap(sizePx, sizePx, ARGB_8888) + val canvas = Canvas(bitmap) + + val backgroundColor = request.color ?: pickColor(context.resources, request.url) + + val paint = Paint() + paint.color = backgroundColor + + val sizeRect = RectF(0f, 0f, size, size) + val cornerRadius = cornerRadiusDimen?.let { context.resources.getDimension(it) } ?: 0f + canvas.drawRoundRect(sizeRect, cornerRadius, cornerRadius, paint) + + val character = request.url.getRepresentativeCharacter() + + // The text size is calculated dynamically based on the target icon size (1/8th). For an icon + // size of 112dp we'd use a text size of 14dp (112 / 8). + val textSize = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + size * TARGET_ICON_RATIO, + context.resources.displayMetrics, + ) + + paint.color = ContextCompat.getColor(context, textColorRes) + paint.textAlign = Paint.Align.CENTER + paint.textSize = textSize + paint.isAntiAlias = true + + canvas.drawText( + character, + canvas.width / 2f, + (canvas.height / 2f) - ((paint.descent() + paint.ascent()) / 2f), + paint, + ) + + return Icon( + bitmap = bitmap, + color = backgroundColor, + source = Icon.Source.GENERATOR, + maskable = cornerRadius == 0f, + ) + } + + /** + * Return a color for this [url]. Colors will be based on the host. URLs with the same host will + * return the same color. + */ + @ColorInt + internal fun pickColor(resources: Resources, url: String): Int { + val backgroundColors = resources.obtainTypedArray(backgroundColorsRes) + val color = if (url.isEmpty()) { + backgroundColors.getColor(0, 0) + } else { + val snippet = url.getRepresentativeSnippet() + val index = abs(snippet.hashCode() % backgroundColors.length()) + + backgroundColors.getColor(index, 0) + } + + backgroundColors.recycle() + return color + } + + companion object { + private const val TARGET_ICON_RATIO = 1 / 8f + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/generator/IconGenerator.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/generator/IconGenerator.kt new file mode 100644 index 0000000000..bfc98e5fa4 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/generator/IconGenerator.kt @@ -0,0 +1,18 @@ +/* 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 mozilla.components.browser.icons.generator + +import android.content.Context +import android.graphics.Bitmap +import mozilla.components.browser.icons.Icon +import mozilla.components.browser.icons.IconRequest + +/** + * A [IconGenerator] implementation can generate a [Bitmap] for an [IconRequest]. It's a fallback if no icon could be + * loaded for a specific URL. + */ +interface IconGenerator { + fun generate(context: Context, request: IconRequest): Icon +} diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/DataUriIconLoader.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/DataUriIconLoader.kt new file mode 100644 index 0000000000..5364e25b52 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/DataUriIconLoader.kt @@ -0,0 +1,36 @@ +/* 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 mozilla.components.browser.icons.loader + +import android.content.Context +import android.util.Base64 +import mozilla.components.browser.icons.Icon +import mozilla.components.browser.icons.IconRequest + +/** + * An [IconLoader] implementation that will base64 decode the image bytes from a data:image uri. + */ +class DataUriIconLoader : IconLoader { + override fun load(context: Context, request: IconRequest, resource: IconRequest.Resource): IconLoader.Result { + if (!resource.url.startsWith("data:image/")) { + return IconLoader.Result.NoResult + } + + val offset = resource.url.indexOf(',') + 1 + if (offset == 0) { + return IconLoader.Result.NoResult + } + + @Suppress("TooGenericExceptionCaught") + return try { + IconLoader.Result.BytesResult( + Base64.decode(resource.url.substring(offset), Base64.DEFAULT), + Icon.Source.INLINE, + ) + } catch (e: Exception) { + IconLoader.Result.NoResult + } + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/DiskIconLoader.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/DiskIconLoader.kt new file mode 100644 index 0000000000..f4178af68e --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/DiskIconLoader.kt @@ -0,0 +1,26 @@ +/* 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 mozilla.components.browser.icons.loader + +import android.content.Context +import mozilla.components.browser.icons.Icon +import mozilla.components.browser.icons.IconRequest + +/** + * [IconLoader] implementation that loads icons from a disk cache. + */ +class DiskIconLoader( + private val cache: LoaderDiskCache, +) : IconLoader { + interface LoaderDiskCache { + fun getIconData(context: Context, resource: IconRequest.Resource): ByteArray? + } + + override fun load(context: Context, request: IconRequest, resource: IconRequest.Resource): IconLoader.Result { + return cache.getIconData(context, resource)?.let { data -> + IconLoader.Result.BytesResult(data, Icon.Source.DISK) + } ?: IconLoader.Result.NoResult + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/HttpIconLoader.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/HttpIconLoader.kt new file mode 100644 index 0000000000..430f46f3ec --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/HttpIconLoader.kt @@ -0,0 +1,116 @@ +/* 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 mozilla.components.browser.icons.loader + +import android.content.Context +import android.os.SystemClock +import android.util.LruCache +import androidx.annotation.VisibleForTesting +import androidx.core.net.toUri +import mozilla.components.browser.icons.Icon +import mozilla.components.browser.icons.IconRequest +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Request +import mozilla.components.concept.fetch.Response +import mozilla.components.concept.fetch.isSuccess +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.ktx.android.net.isHttpOrHttps +import mozilla.components.support.ktx.kotlin.sanitizeURL +import java.io.IOException +import java.util.concurrent.TimeUnit + +private const val CONNECT_TIMEOUT = 2L // Seconds +private const val READ_TIMEOUT = 10L // Seconds + +/** + * [IconLoader] implementation that will try to download the icon for resources that point to an http(s) URL. + */ +open class HttpIconLoader( + private val httpClient: Client, +) : IconLoader { + private val logger = Logger("HttpIconLoader") + private val failureCache = FailureCache() + + override fun load(context: Context, request: IconRequest, resource: IconRequest.Resource): IconLoader.Result { + if (!shouldDownload(resource)) { + return IconLoader.Result.NoResult + } + + // Right now we always perform a download. We shouldn't retry to download from URLs that have failed just + // recently: https://github.com/mozilla-mobile/android-components/issues/2591 + + return internalLoad(request, resource) + } + + protected fun internalLoad(request: IconRequest, resource: IconRequest.Resource): IconLoader.Result { + val downloadRequest = Request( + url = resource.url.sanitizeURL(), + method = Request.Method.GET, + cookiePolicy = if (request.isPrivate) { + Request.CookiePolicy.OMIT + } else { + Request.CookiePolicy.INCLUDE + }, + connectTimeout = Pair(CONNECT_TIMEOUT, TimeUnit.SECONDS), + readTimeout = Pair(READ_TIMEOUT, TimeUnit.SECONDS), + redirect = Request.Redirect.FOLLOW, + useCaches = true, + private = request.isPrivate, + ) + + return try { + val response = httpClient.fetch(downloadRequest) + if (response.isSuccess) { + response.toIconLoaderResult() + } else { + response.close() + failureCache.rememberFailure(resource.url) + IconLoader.Result.NoResult + } + } catch (e: IOException) { + logger.debug("IOException while trying to download icon resource", e) + IconLoader.Result.NoResult + } + } + + protected fun shouldDownload(resource: IconRequest.Resource): Boolean { + return resource.url.sanitizeURL().toUri().isHttpOrHttps && !failureCache.hasFailedRecently(resource.url) + } +} + +private fun Response.toIconLoaderResult() = body.useStream { + IconLoader.Result.BytesResult(it.readBytes(), Icon.Source.DOWNLOAD) +} + +private const val MAX_FAILURE_URLS = 25 +private const val FAILURE_RETRY_MILLISECONDS = 1000 * 60 * 30 // 30 Minutes + +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal class FailureCache { + private val cache = LruCache<String, Long>(MAX_FAILURE_URLS) + + /** + * Remembers this [url] after loading from it has failed. + */ + fun rememberFailure(url: String) { + cache.put(url, now()) + } + + /** + * Has loading from this [url] failed previously and recently? + */ + fun hasFailedRecently(url: String) = + cache.get(url)?.let { failedAt -> + if (failedAt + FAILURE_RETRY_MILLISECONDS < now()) { + cache.remove(url) + false + } else { + true + } + } ?: false + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun now() = SystemClock.elapsedRealtime() +} diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/IconLoader.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/IconLoader.kt new file mode 100644 index 0000000000..963fea68e9 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/IconLoader.kt @@ -0,0 +1,34 @@ +/* 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 mozilla.components.browser.icons.loader + +import android.content.Context +import android.graphics.Bitmap +import mozilla.components.browser.icons.Icon +import mozilla.components.browser.icons.IconRequest + +/** + * A loader that can load an icon from an [IconRequest.Resource]. + */ +interface IconLoader { + /** + * Tries to load the [IconRequest.Resource] for the given [IconRequest]. + */ + fun load(context: Context, request: IconRequest, resource: IconRequest.Resource): Result + + sealed class Result { + object NoResult : Result() + + class BitmapResult( + val bitmap: Bitmap, + val source: Icon.Source, + ) : Result() + + class BytesResult( + val bytes: ByteArray, + val source: Icon.Source, + ) : Result() + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/MemoryIconLoader.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/MemoryIconLoader.kt new file mode 100644 index 0000000000..fe8f063a9f --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/MemoryIconLoader.kt @@ -0,0 +1,27 @@ +/* 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 mozilla.components.browser.icons.loader + +import android.content.Context +import android.graphics.Bitmap +import mozilla.components.browser.icons.Icon +import mozilla.components.browser.icons.IconRequest + +/** + * An [IconLoader] implementation that loads icons from an in-memory cache. + */ +class MemoryIconLoader( + private val cache: LoaderMemoryCache, +) : IconLoader { + interface LoaderMemoryCache { + fun getBitmap(request: IconRequest, resource: IconRequest.Resource): Bitmap? + } + + override fun load(context: Context, request: IconRequest, resource: IconRequest.Resource): IconLoader.Result { + return cache.getBitmap(request, resource)?.let { bitmap -> + IconLoader.Result.BitmapResult(bitmap, Icon.Source.MEMORY) + } ?: IconLoader.Result.NoResult + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/NonBlockingHttpIconLoader.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/NonBlockingHttpIconLoader.kt new file mode 100644 index 0000000000..c3203dc13f --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/NonBlockingHttpIconLoader.kt @@ -0,0 +1,43 @@ +/* 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 mozilla.components.browser.icons.loader + +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import mozilla.components.browser.icons.IconRequest +import mozilla.components.concept.fetch.Client + +/** + * [HttpIconLoader] variation that will immediately resolve a [load] request with [IconLoader.Result.NoResult] + * and then continue to actually download the icon in the background finally calling [loadCallback] + * with the actual result and details about the request. + * + * @property httpClient [Client] used for downloading the icon. + * @property scope [CoroutineScope] used for downloading the icon in the background. + * Defaults to a new scope using [Dispatchers.IO] for allowing multiple requests to block their threads + * while waiting for the download to complete. + * @property loadCallback Callback for when the network icon finished downloading or an error or timeout occurred. + */ +class NonBlockingHttpIconLoader( + httpClient: Client, + private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO), + private val loadCallback: (IconRequest, IconRequest.Resource, IconLoader.Result) -> Unit, +) : HttpIconLoader(httpClient) { + override fun load(context: Context, request: IconRequest, resource: IconRequest.Resource): IconLoader.Result { + if (!shouldDownload(resource)) { + return IconLoader.Result.NoResult + } + + scope.launch { + val icon = internalLoad(request, resource) + + loadCallback(request, resource, icon) + } + + return IconLoader.Result.NoResult + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/pipeline/IconResourceComparator.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/pipeline/IconResourceComparator.kt new file mode 100644 index 0000000000..07ff548bd8 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/pipeline/IconResourceComparator.kt @@ -0,0 +1,75 @@ +/* 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 mozilla.components.browser.icons.pipeline + +import mozilla.components.browser.icons.IconRequest + +/** + * This [Comparator] implementations compares [IconRequest.Resource] objects to determine which icon to try to load + * first. + */ +internal object IconResourceComparator : Comparator<IconRequest.Resource> { + + /** + * Compare two icon resources. If [resource] is more important, a negative number is returned. + * If [other] is more important, a positive number is returned. + * If the two resources are of equal importance, 0 is returned. + * Importance represents which icon we should try to load first. + */ + override fun compare(resource: IconRequest.Resource, other: IconRequest.Resource) = when { + // Two resources pointing to the same URL are always referencing the same icon. So treat them as equal. + resource.url == other.url -> 0 + resource.maskable != other.maskable -> -resource.maskable.compareTo(other.maskable) + resource.type != other.type -> -resource.type.rank().compareTo(other.type.rank()) + resource.maxSize != other.maxSize -> -resource.maxSize.compareTo(other.maxSize) + else -> { + // If there's no other way to choose, we prefer container types. + // They *might* contain an image larger than the size given in the <link> tag. + val isResourceContainerType = resource.isContainerType + if (isResourceContainerType != other.isContainerType) { + if (isResourceContainerType) -1 else 1 + } else { + // There's no way to know which icon might be better. However we need to pick a consistent one + // to avoid breaking set implementations (See Fennec Bug 1331808). + // Therefore we are picking one by just comparing the URLs. + resource.url.compareTo(other.url) + } + } + } +} + +@Suppress("MagicNumber", "ComplexMethod") +private fun IconRequest.Resource.Type.rank(): Int { + return when (this) { + // An icon from our "tippy top" list should always be preferred + IconRequest.Resource.Type.TIPPY_TOP -> 25 + IconRequest.Resource.Type.MANIFEST_ICON -> 20 + // We prefer touch icons because they tend to have a higher resolution than ordinary favicons. + IconRequest.Resource.Type.APPLE_TOUCH_ICON -> 15 + IconRequest.Resource.Type.FAVICON -> 10 + + // Fallback icon types: + IconRequest.Resource.Type.IMAGE_SRC -> 6 + IconRequest.Resource.Type.FLUID_ICON -> 5 + IconRequest.Resource.Type.OPENGRAPH -> 4 + IconRequest.Resource.Type.TWITTER -> 3 + IconRequest.Resource.Type.MICROSOFT_TILE -> 2 + } +} + +private val IconRequest.Resource.maxSize: Int + get() = sizes.asSequence().map { size -> size.minLength }.maxOrNull() ?: 0 + +private val IconRequest.Resource.isContainerType: Boolean + get() = mimeType != null && containerTypes.contains(mimeType) + +private val containerTypes = listOf( + "image/vnd.microsoft.icon", + "image/ico", + "image/icon", + "image/x-icon", + "text/ico", + "application/ico", +) diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparer/DiskIconPreparer.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparer/DiskIconPreparer.kt new file mode 100644 index 0000000000..f57ec6388e --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparer/DiskIconPreparer.kt @@ -0,0 +1,32 @@ +/* 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 mozilla.components.browser.icons.preparer + +import android.content.Context +import mozilla.components.browser.icons.IconRequest + +/** + * [IconPreprarer] implementation implementation that will add known resource URLs (from a disk cache) to the request + * if the request doesn't contain a list of resources yet. + */ +class DiskIconPreparer( + private val cache: PreparerDiskCache, +) : IconPreprarer { + interface PreparerDiskCache { + fun getResources(context: Context, request: IconRequest): List<IconRequest.Resource> + } + + override fun prepare(context: Context, request: IconRequest): IconRequest { + return if (request.resources.isEmpty()) { + // Only try to load resources for this request from disk if there are no resources attached to this + // request yet: Avoid disk reads for *every* request - especially if they can be satisfied by the memory + // cache. + val resources = cache.getResources(context, request) + request.copy(resources = resources) + } else { + request + } + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparer/IconPreprarer.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparer/IconPreprarer.kt new file mode 100644 index 0000000000..4d8c138bc3 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparer/IconPreprarer.kt @@ -0,0 +1,16 @@ +/* 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 mozilla.components.browser.icons.preparer + +import android.content.Context +import mozilla.components.browser.icons.IconRequest + +/** + * An [IconPreparer] implementation receives an [IconRequest] before it is getting loaded. The preparer has the option + * to rewrite the [IconRequest] and return a new instance. + */ +interface IconPreprarer { + fun prepare(context: Context, request: IconRequest): IconRequest +} diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparer/MemoryIconPreparer.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparer/MemoryIconPreparer.kt new file mode 100644 index 0000000000..ec4c5665e7 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparer/MemoryIconPreparer.kt @@ -0,0 +1,29 @@ +/* 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 mozilla.components.browser.icons.preparer + +import android.content.Context +import mozilla.components.browser.icons.IconRequest + +/** + * An [IconPreprarer] implementation that will add known resource URLs (from an in-memory cache) to the request if the + * request doesn't contain a list of resources yet. + */ +class MemoryIconPreparer( + private val cache: PreparerMemoryCache, +) : IconPreprarer { + interface PreparerMemoryCache { + fun getResources(request: IconRequest): List<IconRequest.Resource> + } + + override fun prepare(context: Context, request: IconRequest): IconRequest { + return if (request.resources.isNotEmpty()) { + request + } else { + val resources = cache.getResources(request) + request.copy(resources = resources) + } + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparer/TippyTopIconPreparer.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparer/TippyTopIconPreparer.kt new file mode 100644 index 0000000000..3763da85ad --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparer/TippyTopIconPreparer.kt @@ -0,0 +1,89 @@ +/* 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 mozilla.components.browser.icons.preparer + +import android.content.Context +import android.content.res.AssetManager +import android.net.Uri +import androidx.core.net.toUri +import mozilla.components.browser.icons.IconRequest +import mozilla.components.support.base.log.Log +import mozilla.components.support.ktx.android.net.hostWithoutCommonPrefixes +import mozilla.components.support.ktx.android.net.isHttpOrHttps +import mozilla.components.support.ktx.android.org.json.asSequence +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +private const val LIST_FILE_PATH = "mozac.browser.icons/icons-top200.json" + +// Make sure domain added here have the corresponding image_url in icons-top200.json +private val commonDomain = listOf("wikipedia.org") + +/** + * Returns the host's common domain if found, else null is returned + */ +private val Uri.hostWithCommonDomain: String? + get() { + val host = host ?: return null + for (domain in commonDomain) { + if (host.endsWith(domain)) return domain + } + return null + } + +/** + * [IconPreprarer] implementation that looks up the host in our "tippy top" list. If it can find a match then it inserts + * the icon URL into the request. + * + * The "tippy top" list is a list of "good" icons for top pages maintained by Mozilla: + * https://github.com/mozilla/tippy-top-sites + */ +class TippyTopIconPreparer( + assetManager: AssetManager, +) : IconPreprarer { + private val iconMap: Map<String, String> by lazy { parseList(assetManager) } + + override fun prepare(context: Context, request: IconRequest): IconRequest { + val uri = request.url.toUri() + if (!uri.isHttpOrHttps) { + return request + } + + val host = uri.hostWithCommonDomain ?: uri.hostWithoutCommonPrefixes + + return if (host != null && iconMap.containsKey(host)) { + val resource = IconRequest.Resource( + url = iconMap.getValue(host), + type = IconRequest.Resource.Type.TIPPY_TOP, + ) + + request.copy(resources = request.resources + resource) + } else { + request + } + } +} + +private fun parseList(assetManager: AssetManager): Map<String, String> = try { + JSONArray(assetManager.open(LIST_FILE_PATH).bufferedReader().readText()) + .asSequence() + .flatMap { entry -> + val json = entry as JSONObject + val domains = json.getJSONArray("domains") + val iconUrl = json.getString("image_url") + + domains.asSequence().map { domain -> Pair(domain.toString(), iconUrl) } + } + .toMap() +} catch (e: JSONException) { + Log.log( + priority = Log.Priority.ERROR, + tag = "TippyTopIconPreparer", + message = "Could not load tippy top list from assets", + throwable = e, + ) + emptyMap() +} diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/processor/AdaptiveIconProcessor.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/processor/AdaptiveIconProcessor.kt new file mode 100644 index 0000000000..1781b949a1 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/processor/AdaptiveIconProcessor.kt @@ -0,0 +1,76 @@ +/* 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 mozilla.components.browser.icons.processor + +import android.content.Context +import android.graphics.Paint +import android.graphics.Paint.ANTI_ALIAS_FLAG +import android.graphics.Rect +import android.os.Build +import android.os.Build.VERSION.SDK_INT +import androidx.core.graphics.applyCanvas +import androidx.core.graphics.createBitmap +import mozilla.components.browser.icons.Icon +import mozilla.components.browser.icons.IconRequest +import mozilla.components.support.images.DesiredSize +import kotlin.math.max + +/** + * [IconProcessor] implementation that builds maskable icons. + */ +class AdaptiveIconProcessor : IconProcessor { + + /** + * Creates an adaptive icon using the base icon. + * On older devices, non-maskable icons are not transformed. + */ + override fun process( + context: Context, + request: IconRequest, + resource: IconRequest.Resource?, + icon: Icon, + desiredSize: DesiredSize, + ): Icon { + val maskable = resource?.maskable == true + if (!maskable && SDK_INT < Build.VERSION_CODES.O) { + return icon + } + + val originalBitmap = icon.bitmap + + val paddingRatio = if (maskable) { + MASKABLE_ICON_PADDING_RATIO + } else { + TRANSPARENT_ICON_PADDING_RATIO + } + val maskedIconSize = max(originalBitmap.width, originalBitmap.height) + val padding = (paddingRatio * maskedIconSize).toInt() + + // The actual size of the icon asset, before masking, in pixels. + val rawIconSize = 2 * padding + maskedIconSize + val maskBounds = Rect(0, 0, maskedIconSize, maskedIconSize).apply { + offset(padding, padding) + } + + val paint = Paint(ANTI_ALIAS_FLAG).apply { isFilterBitmap = true } + + val paddedBitmap = createBitmap(rawIconSize, rawIconSize).applyCanvas { + icon.color?.also { drawColor(it) } + drawBitmap(originalBitmap, null, maskBounds, paint) + } + + return icon.copy(bitmap = paddedBitmap, maskable = true).also { originalBitmap.recycle() } + } + + companion object { + private const val MASKABLE_ICON_SAFE_ZONE = 4 / 5f + private const val ADAPTIVE_ICON_SAFE_ZONE = 66 / 108f + private const val TRANSPARENT_ICON_SAFE_ZONE = 192 / 176f + private const val MASKABLE_ICON_PADDING_RATIO = + ((MASKABLE_ICON_SAFE_ZONE / ADAPTIVE_ICON_SAFE_ZONE) - 1) / 2 + private const val TRANSPARENT_ICON_PADDING_RATIO = + ((TRANSPARENT_ICON_SAFE_ZONE / ADAPTIVE_ICON_SAFE_ZONE) - 1) / 2 + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/processor/ColorProcessor.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/processor/ColorProcessor.kt new file mode 100644 index 0000000000..02563f9ffc --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/processor/ColorProcessor.kt @@ -0,0 +1,37 @@ +/* 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 mozilla.components.browser.icons.processor + +import android.content.Context +import android.graphics.Color +import androidx.palette.graphics.Palette +import mozilla.components.browser.icons.Icon +import mozilla.components.browser.icons.IconRequest +import mozilla.components.support.images.DesiredSize + +/** + * [IconProcessor] implementation to extract the dominant color from the icon. + */ +class ColorProcessor : IconProcessor { + + override fun process( + context: Context, + request: IconRequest, + resource: IconRequest.Resource?, + icon: Icon, + desiredSize: DesiredSize, + ): Icon { + // If the icon already has a color set, just return + if (icon.color != null) return icon + // If the request already has a color set, then return. + // Some PWAs just set the background color to white (such as Twitter, Starbucks) + // but the icons would work better if we fill the background using the Palette API. + // If a PWA really want a white background a maskable icon can be used. + if (request.color != null && request.color != Color.WHITE) return icon.copy(color = request.color) + + val swatch = Palette.from(icon.bitmap).generate().dominantSwatch + return swatch?.run { icon.copy(color = rgb) } ?: icon + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/processor/DiskIconProcessor.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/processor/DiskIconProcessor.kt new file mode 100644 index 0000000000..4e0d74f49d --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/processor/DiskIconProcessor.kt @@ -0,0 +1,52 @@ +/* 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 mozilla.components.browser.icons.processor + +import android.content.Context +import mozilla.components.browser.icons.Icon +import mozilla.components.browser.icons.Icon.Source +import mozilla.components.browser.icons.IconRequest +import mozilla.components.support.images.DesiredSize + +/** + * [IconProcessor] implementation that saves icons in the disk cache. + */ +class DiskIconProcessor( + private val cache: ProcessorDiskCache, +) : IconProcessor { + interface ProcessorDiskCache { + /** + * Saves icon resources to cache. + * */ + fun putResources(context: Context, request: IconRequest) + + /** + * Saves icon bitmap to cache. + * */ + fun putIcon(context: Context, resource: IconRequest.Resource, icon: Icon) + } + + override fun process( + context: Context, + request: IconRequest, + resource: IconRequest.Resource?, + icon: Icon, + desiredSize: DesiredSize, + ): Icon { + if (resource != null && !request.isPrivate) { + cache.putResources(context, request) + if (icon.shouldCacheOnDisk) { + cache.putIcon(context, resource, icon) + } + } + return icon + } +} + +private val Icon.shouldCacheOnDisk: Boolean + get() = when (source) { + Source.DOWNLOAD, Source.INLINE -> true + Source.GENERATOR, Source.MEMORY, Source.DISK -> false + } diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/processor/IconProcessor.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/processor/IconProcessor.kt new file mode 100644 index 0000000000..afcb0dddbb --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/processor/IconProcessor.kt @@ -0,0 +1,24 @@ +/* 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 mozilla.components.browser.icons.processor + +import android.content.Context +import mozilla.components.browser.icons.Icon +import mozilla.components.browser.icons.IconRequest +import mozilla.components.support.images.DesiredSize + +/** + * An [IconProcessor] implementation receives the [Icon] with the [IconRequest] and [IconRequest.Resource] after + * the icon was loaded. The [IconProcessor] has the option to rewrite a loaded [Icon] and return a new instance. + */ +interface IconProcessor { + fun process( + context: Context, + request: IconRequest, + resource: IconRequest.Resource?, + icon: Icon, + desiredSize: DesiredSize, + ): Icon? +} diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/processor/MemoryIconProcessor.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/processor/MemoryIconProcessor.kt new file mode 100644 index 0000000000..b13d2f6134 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/processor/MemoryIconProcessor.kt @@ -0,0 +1,42 @@ +/* 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 mozilla.components.browser.icons.processor + +import android.content.Context +import mozilla.components.browser.icons.Icon +import mozilla.components.browser.icons.Icon.Source +import mozilla.components.browser.icons.IconRequest +import mozilla.components.support.images.DesiredSize + +/** + * An [IconProcessor] implementation that saves icons in the in-memory cache. + */ +class MemoryIconProcessor( + private val cache: ProcessorMemoryCache, +) : IconProcessor { + interface ProcessorMemoryCache { + fun put(request: IconRequest, resource: IconRequest.Resource, icon: Icon) + } + + override fun process( + context: Context, + request: IconRequest, + resource: IconRequest.Resource?, + icon: Icon, + desiredSize: DesiredSize, + ): Icon { + if (resource != null && icon.shouldCacheInMemory) { + cache.put(request, resource, icon) + } + + return icon + } +} + +private val Icon.shouldCacheInMemory: Boolean + get() = when (source) { + Source.DOWNLOAD, Source.INLINE, Source.DISK -> true + Source.GENERATOR, Source.MEMORY -> false + } diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/processor/ResizingProcessor.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/processor/ResizingProcessor.kt new file mode 100644 index 0000000000..d9daadf77f --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/processor/ResizingProcessor.kt @@ -0,0 +1,67 @@ +/* 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 mozilla.components.browser.icons.processor + +import android.content.Context +import android.graphics.Bitmap +import androidx.annotation.VisibleForTesting +import mozilla.components.browser.icons.Icon +import mozilla.components.browser.icons.IconRequest +import mozilla.components.support.images.DesiredSize +import kotlin.math.roundToInt + +/** + * [IconProcessor] implementation for resizing the loaded icon based on the target size. + */ +class ResizingProcessor( + private val discardSmallIcons: Boolean = true, +) : IconProcessor { + + override fun process( + context: Context, + request: IconRequest, + resource: IconRequest.Resource?, + icon: Icon, + desiredSize: DesiredSize, + ): Icon? { + val originalBitmap = icon.bitmap + val size = originalBitmap.width + val (targetSize, _, _, maxScaleFactor) = desiredSize + + // The bitmap has exactly the size we are looking for. + if (size == targetSize) return icon + + val resizedBitmap = if (size > targetSize) { + resize(originalBitmap, targetSize) + } else { + // Our largest primary is smaller than the desired size. Upscale it (to a limit)! + // 'largestSize' now reflects the maximum size we can upscale to. + val largestSize = (size * maxScaleFactor).roundToInt() + + if (largestSize > targetSize) { + // Perfect! WE can upscale and reach the needed size. + resize(originalBitmap, targetSize) + } else { + // We don't have enough information to make the target size look non-terrible. + // Best effort scale, unless we're told to throw away small icons. + if (discardSmallIcons) null else resize(originalBitmap, largestSize) + } + } + + return icon.copy(bitmap = resizedBitmap ?: return null) + } + + /** + * Create a new bitmap scaled from an existing bitmap. + */ + @VisibleForTesting + internal fun resize(bitmap: Bitmap, targetSize: Int) = try { + Bitmap.createScaledBitmap(bitmap, targetSize, targetSize, true) + } catch (e: OutOfMemoryError) { + // There's not enough memory to create a resized copy of the bitmap in memory. Let's just + // use what we have. + bitmap + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/utils/IconDiskCache.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/utils/IconDiskCache.kt new file mode 100644 index 0000000000..a34a4cdd6c --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/utils/IconDiskCache.kt @@ -0,0 +1,185 @@ +/* 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 mozilla.components.browser.icons.utils + +import android.content.Context +import android.graphics.Bitmap +import android.os.Build +import androidx.annotation.VisibleForTesting +import com.jakewharton.disklrucache.DiskLruCache +import mozilla.components.browser.icons.Icon +import mozilla.components.browser.icons.IconRequest +import mozilla.components.browser.icons.extension.toIconResources +import mozilla.components.browser.icons.extension.toJSON +import mozilla.components.browser.icons.loader.DiskIconLoader +import mozilla.components.browser.icons.preparer.DiskIconPreparer +import mozilla.components.browser.icons.processor.DiskIconProcessor +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.ktx.kotlin.sha1 +import org.json.JSONArray +import org.json.JSONException +import java.io.File +import java.io.IOException + +private const val RESOURCES_DISK_CACHE_VERSION = 1 +private const val ICON_DATA_DISK_CACHE_VERSION = 1 + +private const val MAXIMUM_CACHE_RESOURCES_BYTES: Long = 1024L * 1024L * 10L // 10 MB +private const val MAXIMUM_CACHE_ICON_DATA_BYTES: Long = 1024L * 1024L * 100L // 100 MB + +private const val WEBP_QUALITY = 90 + +/** + * Caching bitmaps and resource URLs on disk. + */ +class IconDiskCache : + DiskIconLoader.LoaderDiskCache, + DiskIconPreparer.PreparerDiskCache, + DiskIconProcessor.ProcessorDiskCache { + private val logger = Logger("Icons/IconDiskCache") + + @VisibleForTesting + internal var iconResourcesCache: DiskLruCache? = null + + @VisibleForTesting + internal var iconDataCache: DiskLruCache? = null + private val iconResourcesCacheWriteLock = Any() + private val iconDataCacheWriteLock = Any() + + override fun getResources(context: Context, request: IconRequest): List<IconRequest.Resource> { + val key = createKey(request.url) + val snapshot: DiskLruCache.Snapshot = getIconResourcesCache(context).get(key) + ?: return emptyList() + + try { + val data = snapshot.getInputStream(0).use { + it.buffered().reader().readText() + } + + return JSONArray(data).toIconResources() + } catch (e: IOException) { + logger.info("Failed to load resources from disk", e) + } catch (e: JSONException) { + logger.warn("Failed to parse resources from disk", e) + } + + return emptyList() + } + + override fun putResources(context: Context, request: IconRequest) { + try { + synchronized(iconResourcesCacheWriteLock) { + val key = createKey(request.url) + val editor = getIconResourcesCache(context) + .edit(key) ?: return + + val data = request.resources.toJSON().toString() + editor.set(0, data) + + editor.commit() + } + } catch (e: IOException) { + logger.info("Failed to save resources to disk", e) + } catch (e: JSONException) { + logger.warn("Failed to serialize resources") + } + } + + override fun putIcon(context: Context, resource: IconRequest.Resource, icon: Icon) { + putIconBitmap(context, resource, icon.bitmap) + } + + override fun getIconData(context: Context, resource: IconRequest.Resource): ByteArray? { + val key = createKey(resource.url) + + val snapshot = getIconDataCache(context).get(key) + ?: return null + + return try { + snapshot.getInputStream(0).use { + it.buffered().readBytes() + } + } catch (e: IOException) { + logger.info("Failed to read icon bitmap from disk", e) + null + } + } + + internal fun putIconBitmap(context: Context, resource: IconRequest.Resource, bitmap: Bitmap) { + val compressFormat = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Bitmap.CompressFormat.WEBP_LOSSY + } else { + @Suppress("DEPRECATION") + Bitmap.CompressFormat.WEBP + } + try { + synchronized(iconDataCacheWriteLock) { + val editor = getIconDataCache(context) + .edit(createKey(resource.url)) ?: return + + editor.newOutputStream(0).use { stream -> + bitmap.compress(compressFormat, WEBP_QUALITY, stream) + } + + editor.commit() + } + } catch (e: IOException) { + logger.info("Failed to save icon bitmap to disk", e) + } + } + + internal fun clear(context: Context) { + try { + getIconResourcesCache(context).delete() + } catch (e: IOException) { + logger.warn("Icon resource cache could not be cleared. Perhaps there is none?") + } + + try { + getIconDataCache(context).delete() + } catch (e: IOException) { + logger.warn("Icon data cache could not be cleared. Perhaps there is none?") + } + + iconResourcesCache = null + iconDataCache = null + } + + @Synchronized + private fun getIconResourcesCache(context: Context): DiskLruCache { + iconResourcesCache?.let { return it } + + return DiskLruCache.open( + getIconResourcesCacheDirectory(context), + RESOURCES_DISK_CACHE_VERSION, + 1, + MAXIMUM_CACHE_RESOURCES_BYTES, + ).also { iconResourcesCache = it } + } + + private fun getIconResourcesCacheDirectory(context: Context): File { + val cacheDirectory = File(context.cacheDir, "mozac_browser_icons") + return File(cacheDirectory, "resources") + } + + @Synchronized + private fun getIconDataCache(context: Context): DiskLruCache { + iconDataCache?.let { return it } + + return DiskLruCache.open( + getIconDataCacheDirectory(context), + ICON_DATA_DISK_CACHE_VERSION, + 1, + MAXIMUM_CACHE_ICON_DATA_BYTES, + ).also { iconDataCache = it } + } + + private fun getIconDataCacheDirectory(context: Context): File { + val cacheDirectory = File(context.cacheDir, "mozac_browser_icons") + return File(cacheDirectory, "icons") + } +} + +private fun createKey(rawKey: String): String = rawKey.sha1() diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/utils/IconMemoryCache.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/utils/IconMemoryCache.kt new file mode 100644 index 0000000000..33b4864a37 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/utils/IconMemoryCache.kt @@ -0,0 +1,62 @@ +/* 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 mozilla.components.browser.icons.utils + +import android.graphics.Bitmap +import android.util.LruCache +import mozilla.components.browser.icons.Icon +import mozilla.components.browser.icons.IconRequest +import mozilla.components.browser.icons.loader.MemoryIconLoader.LoaderMemoryCache +import mozilla.components.browser.icons.preparer.MemoryIconPreparer +import mozilla.components.browser.icons.processor.MemoryIconProcessor.ProcessorMemoryCache + +private const val MAXIMUM_CACHE_URLS = 1000 + +// Use a better mechanism for determining the cache size: +// https://github.com/mozilla-mobile/android-components/issues/2764 +private const val MAXIMUM_CACHE_BITMAP_BYTES = 1024 * 1024 * 25 // 25 MB + +class IconMemoryCache : ProcessorMemoryCache, LoaderMemoryCache, MemoryIconPreparer.PreparerMemoryCache { + private val iconResourcesCache = LruCache<String, List<IconRequest.Resource>>(MAXIMUM_CACHE_URLS) + private val iconBitmapCache = object : LruCache<String, Bitmap>(MAXIMUM_CACHE_BITMAP_BYTES) { + override fun sizeOf(key: String, value: Bitmap): Int { + return value.byteCount + } + } + + override fun getResources(request: IconRequest): List<IconRequest.Resource> { + return iconResourcesCache[request.url] ?: emptyList() + } + + override fun getBitmap(request: IconRequest, resource: IconRequest.Resource): Bitmap? { + return iconBitmapCache[resource.url] + } + + override fun put(request: IconRequest, resource: IconRequest.Resource, icon: Icon) { + if (icon.source.shouldCacheInMemory) { + iconBitmapCache.put(resource.url, icon.bitmap) + } + + if (request.resources.isNotEmpty()) { + iconResourcesCache.put(request.url, request.resources) + } + } + + internal fun clear() { + iconResourcesCache.evictAll() + iconBitmapCache.evictAll() + } +} + +private val Icon.Source.shouldCacheInMemory: Boolean + get() { + return when (this) { + Icon.Source.DOWNLOAD -> true + Icon.Source.INLINE -> true + Icon.Source.GENERATOR -> false + Icon.Source.MEMORY -> false + Icon.Source.DISK -> true + } + } diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/utils/Utils.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/utils/Utils.kt new file mode 100644 index 0000000000..0ef339f2c8 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/utils/Utils.kt @@ -0,0 +1,64 @@ +/* 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 mozilla.components.browser.icons.utils + +/** + * For a list of size [Pair]s (width, height) returns the best [Pair] for the given [targetSize], [maxSize] and + * [maxScaleFactor]. + */ +@Suppress("ReturnCount") +internal fun List<Pair<Int, Int>>.findBestSize(targetSize: Int, maxSize: Int, maxScaleFactor: Float): Pair<Int, Int>? { + // First look for a pair that is close to (but not smaller than) our target size. + val ideal = filter { (x, y) -> + x >= targetSize && y >= targetSize && x <= maxSize && y <= maxSize + }.filter { (x, y) -> + x == y + }.minByOrNull { (x, _) -> + x + } + + if (ideal != null) { + // We found an icon that is exactly in the range we want, yay! + return ideal + } + + // Next try to find a pair that is larger than our target size but that we can downscale to be in our target range. + val downscalable = filter { (x, y) -> + val downScaledX = (x.toFloat() * (1.0f / maxScaleFactor)).toInt() + val downScaledY = (y.toFloat() * (1.0f / maxScaleFactor)).toInt() + + downScaledX >= targetSize && + downScaledY >= targetSize && + downScaledX <= maxSize && + downScaledY <= maxSize + }.minByOrNull { (x, _) -> + x + } + + if (downscalable != null) { + // We found an icon we can downscale to our desired size. + return downscalable + } + + // Finally try to find a pair that is smaller than our target size but that we can upscale to our target range. + val upscalable = filter { (x, y) -> + val upscaledX = x * maxScaleFactor + val upscaledY = y * maxScaleFactor + + upscaledX >= targetSize && + upscaledY >= targetSize && + upscaledX <= maxSize && + upscaledY <= maxSize + }.maxByOrNull { (x, _) -> + x + } + + if (upscalable != null) { + // We found an icon we can upscale to our desired size. + return upscalable + } + + return null +} diff --git a/mobile/android/android-components/components/browser/icons/src/main/res/values/colors.xml b/mobile/android/android-components/components/browser/icons/src/main/res/values/colors.xml new file mode 100644 index 0000000000..809f1d7a80 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/res/values/colors.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- 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/. --> +<resources> + <color name="mozac_browser_icons_generator_default_text_color">@android:color/white</color> + <!-- Mozilla's Visual Design Colour Palette: http://firefoxux.github.io/StyleGuide/#/visualDesign/colours --> + <array name="mozac_browser_icons_photon_palette"> + <item>#9A4C00</item> + <item>#AB008D</item> + <item>#4C009C</item> + <item>#002E9C</item> + <item>#009EC2</item> + <item>#009D02</item> + <item>#51AB00</item> + <item>#36385A</item> + </array> +</resources> diff --git a/mobile/android/android-components/components/browser/icons/src/main/res/values/dimens.xml b/mobile/android/android-components/components/browser/icons/src/main/res/values/dimens.xml new file mode 100644 index 0000000000..03f785dbdc --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/res/values/dimens.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- 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/. --> +<resources xmlns:tools="http://schemas.android.com/tools"> + <dimen name="mozac_browser_icons_size_default">32dp</dimen> + <dimen name="mozac_browser_icons_size_launcher">48dp</dimen> + <dimen name="mozac_browser_icons_size_launcher_adaptive">102dp</dimen> + + <dimen name="mozac_browser_icons_maximum_size" tools:ignore="PxUsage">1024px</dimen> + <dimen name="mozac_browser_icons_minimum_size" tools:ignore="PxUsage">32px</dimen> + <dimen name="mozac_browser_icons_generator_default_corner_radius">2dp</dimen> +</resources> diff --git a/mobile/android/android-components/components/browser/icons/src/main/res/values/tags.xml b/mobile/android/android-components/components/browser/icons/src/main/res/values/tags.xml new file mode 100644 index 0000000000..de0c5625d6 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/main/res/values/tags.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- 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/. --> +<resources> + <item name="mozac_browser_icons_tag_job" type="id" /> +</resources> diff --git a/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/BrowserIconsTest.kt b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/BrowserIconsTest.kt new file mode 100644 index 0000000000..67a392d0a2 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/BrowserIconsTest.kt @@ -0,0 +1,339 @@ +/* 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 mozilla.components.browser.icons + +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.os.Looper.getMainLooper +import android.widget.ImageView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import mozilla.components.browser.icons.generator.IconGenerator +import mozilla.components.concept.engine.manifest.Size +import mozilla.components.lib.fetch.httpurlconnection.HttpURLConnectionClient +import mozilla.components.support.test.any +import mozilla.components.support.test.eq +import mozilla.components.support.test.ext.joinBlocking +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.rule.MainCoroutineRule +import mozilla.components.support.test.rule.runTestOnMain +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okio.buffer +import okio.source +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertSame +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.never +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.robolectric.Shadows.shadowOf +import java.io.OutputStream + +@ExperimentalCoroutinesApi // for runTestOnMain +@RunWith(AndroidJUnit4::class) +class BrowserIconsTest { + + @get:Rule + val coroutinesTestRule = MainCoroutineRule() + + @Before + @After + fun cleanUp() { + sharedDiskCache.clear(testContext) + sharedMemoryCache.clear() + } + + @Test + fun `Uses generator`() = runTestOnMain { + val mockedIcon: Icon = mock() + + val generator: IconGenerator = mock() + `when`(generator.generate(any(), any())).thenReturn(mockedIcon) + + val request = IconRequest(url = "https://www.mozilla_test.org") + val icon = BrowserIcons(testContext, httpClient = mock(), generator = generator) + .loadIcon(request) + + assertEquals(mockedIcon, icon.await()) + + verify(generator).generate(testContext, request) + } + + @Test + fun `WHEN resources are provided THEN an icon will be downloaded from one of them`() = runTestOnMain { + val server = MockWebServer() + + server.enqueue( + MockResponse().setBody( + + javaClass.getResourceAsStream("/png/mozac.png")!!.source().buffer().buffer, + ), + ) + + server.start() + + try { + val request = IconRequest( + url = server.url("/").toString(), + size = IconRequest.Size.DEFAULT, + resources = listOf( + IconRequest.Resource( + url = server.url("icon64.png").toString(), + sizes = listOf(Size(64, 64)), + mimeType = "image/png", + type = IconRequest.Resource.Type.FAVICON, + ), + IconRequest.Resource( + url = server.url("icon128.png").toString(), + sizes = listOf(Size(128, 128)), + mimeType = "image/png", + type = IconRequest.Resource.Type.FAVICON, + ), + IconRequest.Resource( + url = server.url("icon128.png").toString(), + sizes = listOf(Size(180, 180)), + type = IconRequest.Resource.Type.APPLE_TOUCH_ICON, + ), + ), + ) + + val icon = BrowserIcons( + testContext, + httpClient = HttpURLConnectionClient(), + ).loadIcon(request).await() + + assertNotNull(icon) + assertNotNull(icon.bitmap) + + val serverRequest = server.takeRequest() + assertEquals("/icon128.png", serverRequest.requestUrl?.encodedPath) + } finally { + server.shutdown() + } + } + + @Test + fun `WHEN icon is loaded twice THEN second load is delivered from memory cache`() = runTestOnMain { + val server = MockWebServer() + + server.enqueue( + MockResponse().setBody( + javaClass.getResourceAsStream("/png/mozac.png")!!.source().buffer().buffer, + ), + ) + + server.start() + + try { + val icons = BrowserIcons(testContext, httpClient = HttpURLConnectionClient()) + + val request = IconRequest( + url = "https://www.mozilla.org", + resources = listOf( + IconRequest.Resource( + url = server.url("icon64.png").toString(), + type = IconRequest.Resource.Type.FAVICON, + ), + ), + ) + + val icon = icons.loadIcon(request).await() + + assertEquals(Icon.Source.DOWNLOAD, icon.source) + assertNotNull(icon.bitmap) + + val secondIcon = icons.loadIcon( + IconRequest("https://www.mozilla.org"), // Without resources! + ).await() + + assertEquals(Icon.Source.MEMORY, secondIcon.source) + assertNotNull(secondIcon.bitmap) + + assertSame(icon.bitmap, secondIcon.bitmap) + } finally { + server.shutdown() + } + } + + @Test + fun `WHEN icon is loaded again and not in memory cache THEN second load is delivered from disk cache`() = runTestOnMain { + val server = MockWebServer() + + server.enqueue( + MockResponse().setBody( + javaClass.getResourceAsStream("/png/mozac.png")!!.source().buffer().buffer, + ), + ) + + server.start() + + try { + val icons = BrowserIcons(testContext, httpClient = HttpURLConnectionClient()) + + val request = IconRequest( + url = "https://www.mozilla.org", + resources = listOf( + IconRequest.Resource( + url = server.url("icon64.png").toString(), + type = IconRequest.Resource.Type.FAVICON, + ), + ), + ) + + val icon = icons.loadIcon(request).await() + + assertEquals(Icon.Source.DOWNLOAD, icon.source) + assertNotNull(icon.bitmap) + + sharedMemoryCache.clear() + + val secondIcon = icons.loadIcon( + IconRequest("https://www.mozilla.org"), // Without resources! + ).await() + + assertEquals(Icon.Source.DISK, secondIcon.source) + assertNotNull(secondIcon.bitmap) + } finally { + server.shutdown() + } + } + + @Test + fun `Automatically load icon into image view`() { + val mockedBitmap: Bitmap = mock() + val mockedIcon: Icon = mock() + val result = CompletableDeferred<Icon>() + val view: ImageView = mock() + val icons = spy(BrowserIcons(testContext, httpClient = mock())) + + val request = IconRequest(url = "https://www.mozilla.org") + + doReturn(mockedBitmap).`when`(mockedIcon).bitmap + doReturn(result).`when`(icons).loadIconInternalAsync(eq(request), any()) + + val job = icons.loadIntoView(view, request) + + verify(view).setImageDrawable(null) + verify(view).addOnAttachStateChangeListener(any()) + verify(view).setTag(eq(R.id.mozac_browser_icons_tag_job), any()) + verify(view, never()).setImageBitmap(any()) + + result.complete(mockedIcon) + shadowOf(getMainLooper()).idle() + job.joinBlocking() + + verify(view).setImageBitmap(mockedBitmap) + verify(view).removeOnAttachStateChangeListener(any()) + verify(view).setTag(R.id.mozac_browser_icons_tag_job, null) + } + + @Test + fun `loadIntoView sets drawable to error if cancelled`() { + val result = CompletableDeferred<Icon>() + val view: ImageView = mock() + val placeholder: Drawable = mock() + val error: Drawable = mock() + val icons = spy(BrowserIcons(testContext, httpClient = mock())) + + val request = IconRequest(url = "https://www.mozilla.org") + + doReturn(result).`when`(icons).loadIconInternalAsync(eq(request), any()) + + val job = icons.loadIntoView(view, request, placeholder = placeholder, error = error) + + verify(view).setImageDrawable(placeholder) + + result.cancel() + shadowOf(getMainLooper()).idle() + job.joinBlocking() + + verify(view).setImageDrawable(error) + verify(view).removeOnAttachStateChangeListener(any()) + verify(view).setTag(R.id.mozac_browser_icons_tag_job, null) + } + + @Test + fun `loadIntoView cancels previous jobs`() { + val result = CompletableDeferred<Icon>() + val view: ImageView = mock() + val previousJob: Job = mock() + val icons = spy(BrowserIcons(testContext, httpClient = mock())) + + val request = IconRequest(url = "https://www.mozilla.org") + + doReturn(previousJob).`when`(view).getTag(R.id.mozac_browser_icons_tag_job) + doReturn(result).`when`(icons).loadIconInternalAsync(eq(request), any()) + + icons.loadIntoView(view, request) + + verify(previousJob).cancel() + + result.cancel() + } + + @Test + fun `clear should delete all disk and memory data`() { + // Test the effect of clear by first adding some icons data + val icons = BrowserIcons(testContext, httpClient = HttpURLConnectionClient()) + val resource = IconRequest.Resource( + url = "https://www.mozilla.org/icon64.png", + sizes = listOf(Size(64, 64)), + mimeType = "image/png", + type = IconRequest.Resource.Type.FAVICON, + ) + val request = IconRequest(url = "https://www.mozilla.org", resources = listOf(resource)) + sharedDiskCache.putResources(testContext, request) + val bitmap: Bitmap = mock() + `when`(bitmap.compress(any(), ArgumentMatchers.anyInt(), any())).thenAnswer { + @Suppress("DEPRECATION") + // Deprecation will be handled in https://github.com/mozilla-mobile/android-components/issues/9555 + assertEquals(Bitmap.CompressFormat.WEBP, it.arguments[0] as Bitmap.CompressFormat) + assertEquals(90, it.arguments[1] as Int) // Quality + + val stream = it.arguments[2] as OutputStream + stream.write("Hello World".toByteArray()) + true + } + val icon = Icon(bitmap, source = Icon.Source.DOWNLOAD) + sharedMemoryCache.put(request, resource, icon) + + // Verifying it's all there + assertEquals(listOf(resource), sharedDiskCache.getResources(testContext, request)) + assertEquals(listOf(resource), sharedMemoryCache.getResources(request)) + + icons.clear() + + // Verifying it's not anymore + assertEquals(0, sharedDiskCache.getResources(testContext, request).size) + assertEquals(0, sharedMemoryCache.getResources(request).size) + } + + @Test + fun `GIVEN an IconRequest WHEN getDesiredSize is called THEN set min and max bounds to the request target size`() { + val request = IconRequest("https://mozilla.org", IconRequest.Size.LAUNCHER_ADAPTIVE) + + val result = request.getDesiredSize(testContext, 11, 101) + + assertEquals( + testContext.resources.getDimensionPixelSize(IconRequest.Size.LAUNCHER_ADAPTIVE.dimen), + result.targetSize, + ) + assertEquals(11, result.minSize) + assertEquals(101, result.maxSize) + assertEquals(MAXIMUM_SCALE_FACTOR, result.maxScaleFactor) + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/decoder/ICOIconDecoderTest.kt b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/decoder/ICOIconDecoderTest.kt new file mode 100644 index 0000000000..bd8ec59cd8 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/decoder/ICOIconDecoderTest.kt @@ -0,0 +1,238 @@ +/* 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 mozilla.components.browser.icons.decoder + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.browser.icons.decoder.ico.IconDirectoryEntry +import mozilla.components.browser.icons.decoder.ico.decodeDirectoryEntries +import mozilla.components.support.images.DesiredSize +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ICOIconDecoderTest { + + @Test + fun `Decoding microsoft favicon`() { + val icon = loadIcon("microsoft_favicon.ico") + val decoder = ICOIconDecoder() + + val bitmap = decoder.decode(icon, DesiredSize(192, 192, 1024, 2.0f)) + assertNotNull(bitmap) + } + + @Test + fun `Decoding golem favicon`() { + val icon = loadIcon("golem_favicon.ico") + val decoder = ICOIconDecoder() + + val bitmap = decoder.decode(icon, DesiredSize(192, 192, 1024, 2.0f)) + assertNotNull(bitmap) + } + + @Test + fun `Decoding nivida favicon`() { + val icon = loadIcon("nvidia_favicon.ico") + val decoder = ICOIconDecoder() + + val bitmap = decoder.decode(icon, DesiredSize(96, 96, 1024, 2.0f)) + assertNotNull(bitmap) + } + + @Test + fun testGolemIconEntries() { + val icon = loadIcon("golem_favicon.ico") + + val entries = decodeDirectoryEntries(icon, 1024) + assertEquals(5, entries.size) + + val expectedEntries = arrayOf( + IconDirectoryEntry( + 16, + 16, + 0, + 32, + 1128, + 39520, + false, + 4, + ), + IconDirectoryEntry( + 24, + 24, + 0, + 32, + 2488, + 37032, + false, + 3, + ), + IconDirectoryEntry( + 32, + 32, + 0, + 32, + 4392, + 32640, + false, + 2, + ), + IconDirectoryEntry( + 48, + 48, + 0, + 32, + 9832, + 22808, + false, + 1, + ), + IconDirectoryEntry( + 256, + 256, + 0, + 32, + 22722, + 86, + true, + 0, + ), + ) + + entries + .sortedBy { entry -> entry.width } + .forEachIndexed { index, entry -> + assertEquals(expectedEntries[index], entry) + } + } + + @Test + fun testNvidiaIconEntries() { + val icon = loadIcon("nvidia_favicon.ico") + + val entries = decodeDirectoryEntries(icon, 1024) + assertEquals(3, entries.size) + + // Verify the best entry is correctly chosen for each width. We expect 32 bpp in all cases. + val expectedEntries = arrayOf( + IconDirectoryEntry( + 16, + 16, + 0, + 32, + 1128, + 24086, + false, + 8, + ), + IconDirectoryEntry( + 32, + 32, + 0, + 32, + 4264, + 19822, + false, + 7, + ), + IconDirectoryEntry( + 48, + 48, + 0, + 32, + 9640, + 10182, + false, + 6, + ), + ) + + entries + .sortedBy { entry -> entry.width } + .forEachIndexed { index, entry -> assertEquals(expectedEntries[index], entry) } + } + + @Test + fun testMicrosoftIconEntries() { + val icon = loadIcon("microsoft_favicon.ico") + + val entries = decodeDirectoryEntries(icon, 1024) + assertEquals(6, entries.size) + + val expectedEntries = arrayOf( + IconDirectoryEntry( + 128, + 128, + 16, + 0, + 10344, + 102, + false, + 0, + ), + IconDirectoryEntry( + 72, + 72, + 16, + 0, + 3560, + 10446, + false, + 1, + ), + IconDirectoryEntry( + 48, + 48, + 16, + 0, + 1640, + 14006, + false, + 2, + ), + IconDirectoryEntry( + 32, + 32, + 16, + 0, + 744, + 15646, + false, + 3, + ), + IconDirectoryEntry( + 24, + 24, + 16, + 0, + 488, + 16390, + false, + 4, + ), + IconDirectoryEntry( + 16, + 16, + 16, + 0, + 296, + 16878, + false, + 5, + ), + ) + + entries + .sortedByDescending { entry -> entry.width } + .forEachIndexed { index, entry -> assertEquals(expectedEntries[index], entry) } + } + + private fun loadIcon(fileName: String): ByteArray = + javaClass.getResourceAsStream("/ico/$fileName")!! + .buffered() + .readBytes() +} diff --git a/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/decoder/SvgIconDecoderTest.kt b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/decoder/SvgIconDecoderTest.kt new file mode 100644 index 0000000000..0be22538ee --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/decoder/SvgIconDecoderTest.kt @@ -0,0 +1,156 @@ +/* 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 mozilla.components.browser.icons.decoder + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.caverock.androidsvg.SVGParseException +import mozilla.components.support.images.DesiredSize +import mozilla.components.support.test.any +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doThrow +import org.mockito.Mockito.spy + +@RunWith(AndroidJUnit4::class) +class SvgIconDecoderTest { + private val desiredSize = DesiredSize( + targetSize = 32, + minSize = 32, + maxSize = 256, + maxScaleFactor = 2.0f, + ) + + private val validSvg: ByteArray = """<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"></svg>""".toByteArray() + + // SVGParseException for unbalancedClose + private val invalidSvg: ByteArray = """<svg xmlns="http://www.w3.org/2000/svg"></svg></svg>""".toByteArray() + + // IllegalArgumentException: SVG document is empty + private val illegalArgumentSvg: ByteArray = """<html lang="en"></html>""".toByteArray() + + // NullPointerException: Attempt to read from field on a null object reference in method + private val nullPointerSvg: ByteArray = """<!DOCTYPE html><html lang="en"><style>a{background-image:url('data:image/svg+xml,<svg width="24" height="25" viewBox="0 0 24 25" xmlns="http://www.w3.org/2000/svg"><path fill="black" fill-rule="evenodd"/></svg>')}</style></html>""".toByteArray() + + // ------------------------------------------------------------------------------------- + // Tests for SvgIconDecoder.decode + // ------------------------------------------------------------------------------------- + @Test + fun `WHEN SVG is valid THEN decode returns non-null bitmap`() { + val decoder = SvgIconDecoder() + + val bitmap = decoder.decode( + validSvg, + desiredSize, + ) + + assertNotNull(bitmap!!) + } + + @Test + fun `WHEN decoder throwing NullPointerException THEN returns null`() { + val decoder = spy(SvgIconDecoder()) + doThrow(NullPointerException()).`when`(decoder).maybeDecode(any(), any()) + + val bitmap = decoder.decode( + ByteArray(0), + desiredSize, + ) + + assertNull(bitmap) + } + + @Test + fun `WHEN decoder throwing IllegalArgumentException THEN returns null`() { + val decoder = spy(SvgIconDecoder()) + doThrow(IllegalArgumentException()).`when`(decoder).maybeDecode(any(), any()) + + val bitmap = decoder.decode( + ByteArray(0), + desiredSize, + ) + + assertNull(bitmap) + } + + @Test + fun `WHEN out of memory THEN returns null`() { + val decoder = spy(SvgIconDecoder()) + doThrow(OutOfMemoryError()).`when`(decoder).maybeDecode(any(), any()) + + val bitmap = decoder.decode( + ByteArray(0), + desiredSize, + ) + + assertNull(bitmap) + } + + // We couldn't instantiate SVGParseException since it doesn't have public constructor + @Test + fun `WHEN SVGParseException THEN returns null`() { + val decoder = SvgIconDecoder() + + val bitmap = decoder.decode( + invalidSvg, + desiredSize, + ) + + assertNull(bitmap) + } + + // ------------------------------------------------------------------------------------- + // Tests for SvgIconDecoder.maybeDecode + // ------------------------------------------------------------------------------------- + @Test + fun `WHEN SVG is valid THEN maybeDecode returns non-null bitmap`() { + val decoder = SvgIconDecoder() + + val bitmap = decoder.maybeDecode( + validSvg, + desiredSize, + ) + + assertNotNull(bitmap) + } + + @Test + fun `WHEN parsing error THEN throws SVGParseException`() { + val decoder = SvgIconDecoder() + + assertThrows(SVGParseException::class.java) { + decoder.maybeDecode( + invalidSvg, + desiredSize, + ) + } + } + + @Test + fun `WHEN input is malformed THEN throws NullPointerException`() { + val decoder = SvgIconDecoder() + + assertThrows(NullPointerException::class.java) { + decoder.maybeDecode( + nullPointerSvg, + desiredSize, + ) + } + } + + @Test + fun `WHEN input's document is empty THEN throws IllegalArgumentException`() { + val decoder = SvgIconDecoder() + + assertThrows(IllegalArgumentException::class.java) { + decoder.maybeDecode( + illegalArgumentSvg, + desiredSize, + ) + } + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/extension/IconMessageHandlerTest.kt b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/extension/IconMessageHandlerTest.kt new file mode 100644 index 0000000000..5f9120d986 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/extension/IconMessageHandlerTest.kt @@ -0,0 +1,223 @@ +/* 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 mozilla.components.browser.icons.extension + +import android.graphics.Bitmap +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runTest +import mozilla.components.browser.icons.BrowserIcons +import mozilla.components.browser.icons.Icon +import mozilla.components.browser.icons.IconRequest +import mozilla.components.browser.state.action.TrackingProtectionAction +import mozilla.components.browser.state.selector.findTab +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.state.createTab +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.engine.manifest.Size +import mozilla.components.support.test.any +import mozilla.components.support.test.argumentCaptor +import mozilla.components.support.test.mock +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.verify + +@RunWith(AndroidJUnit4::class) +class IconMessageHandlerTest { + + @ExperimentalCoroutinesApi + @OptIn(DelicateCoroutinesApi::class) + @Test + fun `Complex message (TheVerge) is transformed into IconRequest and loaded`() { + runTest { + val bitmap: Bitmap = mock() + val icon = Icon(bitmap, source = Icon.Source.DOWNLOAD) + val deferredIcon = GlobalScope.async { icon } + + val store: BrowserStore = BrowserStore( + BrowserState( + tabs = listOf( + createTab(url = "https://www.theverge.com/", id = "test-url"), + ), + ), + ) + + store.state.findTab("test-url")!!.apply { + assertNotNull(this) + assertNull(content.icon) + } + + val icons: BrowserIcons = mock() + doReturn(deferredIcon).`when`(icons).loadIcon(any()) + + val handler = IconMessageHandler(store, "test-url", false, icons) + + val message = """ + { + "url": "https:\/\/www.theverge.com\/", + "icons": [ + { + "mimeType": "image\/png", + "href": "https:\/\/cdn.vox-cdn.com\/uploads\/chorus_asset\/file\/7395367\/favicon-16x16.0.png", + "type": "icon", + "sizes": [ + "16x16" + ] + }, + { + "mimeType": "image\/png", + "href": "https:\/\/cdn.vox-cdn.com\/uploads\/chorus_asset\/file\/7395363\/favicon-32x32.0.png", + "type": "icon", + "sizes": [ + "32x32" + ] + }, + { + "mimeType": "image\/png", + "href": "https:\/\/cdn.vox-cdn.com\/uploads\/chorus_asset\/file\/7395365\/favicon-96x96.0.png", + "type": "icon", + "sizes": [ + "96x96" + ] + }, + { + "mimeType": "image\/png", + "href": "https:\/\/cdn.vox-cdn.com\/uploads\/chorus_asset\/file\/7395351\/android-chrome-192x192.0.png", + "type": "icon", + "sizes": [ + "192x192" + ] + }, + { + "mimeType": "", + "href": "https:\/\/cdn.vox-cdn.com\/uploads\/chorus_asset\/file\/7395361\/favicon-64x64.0.ico", + "type": "shortcut icon", + "sizes": [] + }, + { + "mimeType": "", + "href": "https:\/\/cdn.vox-cdn.com\/uploads\/chorus_asset\/file\/7395359\/ios-icon.0.png", + "type": "apple-touch-icon", + "sizes": [ + "180x180" + ] + }, + { + "href": "https:\/\/cdn.vox-cdn.com\/uploads\/chorus_asset\/file\/9672633\/VergeOG.0_1200x627.0.png", + "type": "og:image" + }, + { + "href": "https:\/\/cdn.vox-cdn.com\/community_logos\/52803\/VER_Logomark_175x92..png", + "type": "twitter:image" + }, + { + "href": "https:\/\/cdn.vox-cdn.com\/uploads\/chorus_asset\/file\/7396113\/221a67c8-a10f-11e6-8fae-983107008690.0.png", + "type": "msapplication-TileImage" + } + ] + } + """.trimIndent() + + handler.onMessage(JSONObject(message), source = null) + + assertNotNull(handler.lastJob) + handler.lastJob!!.join() + + // Examine IconRequest + val captor = argumentCaptor<IconRequest>() + verify(icons).loadIcon(captor.capture()) + + val request = captor.value + assertEquals("https://www.theverge.com/", request.url) + assertEquals(9, request.resources.size) + + with(request.resources[0]) { + assertEquals("image/png", mimeType) + assertEquals("https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395367/favicon-16x16.0.png", url) + assertEquals(IconRequest.Resource.Type.FAVICON, type) + assertEquals(1, sizes.size) + assertEquals(Size(16, 16), sizes[0]) + } + + with(request.resources[1]) { + assertEquals("image/png", mimeType) + assertEquals("https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395363/favicon-32x32.0.png", url) + assertEquals(IconRequest.Resource.Type.FAVICON, type) + assertEquals(1, sizes.size) + assertEquals(Size(32, 32), sizes[0]) + } + + with(request.resources[2]) { + assertEquals("image/png", mimeType) + assertEquals("https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395365/favicon-96x96.0.png", url) + assertEquals(IconRequest.Resource.Type.FAVICON, type) + assertEquals(1, sizes.size) + assertEquals(Size(96, 96), sizes[0]) + } + + with(request.resources[3]) { + assertEquals("image/png", mimeType) + assertEquals("https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395351/android-chrome-192x192.0.png", url) + assertEquals(IconRequest.Resource.Type.FAVICON, type) + assertEquals(1, sizes.size) + assertEquals(Size(192, 192), sizes[0]) + } + + with(request.resources[4]) { + assertNull(mimeType) + assertEquals("https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395361/favicon-64x64.0.ico", url) + assertEquals(IconRequest.Resource.Type.FAVICON, type) + assertEquals(0, sizes.size) + } + + with(request.resources[5]) { + assertNull(mimeType) + assertEquals("https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395359/ios-icon.0.png", url) + assertEquals(IconRequest.Resource.Type.APPLE_TOUCH_ICON, type) + assertEquals(1, sizes.size) + assertEquals(Size(180, 180), sizes[0]) + } + + with(request.resources[6]) { + assertNull(mimeType) + assertEquals("https://cdn.vox-cdn.com/uploads/chorus_asset/file/9672633/VergeOG.0_1200x627.0.png", url) + assertEquals(IconRequest.Resource.Type.OPENGRAPH, type) + assertEquals(0, sizes.size) + } + + with(request.resources[7]) { + assertNull(mimeType) + assertEquals("https://cdn.vox-cdn.com/community_logos/52803/VER_Logomark_175x92..png", url) + assertEquals(IconRequest.Resource.Type.TWITTER, type) + assertEquals(0, sizes.size) + } + + with(request.resources[8]) { + assertNull(mimeType) + assertEquals("https://cdn.vox-cdn.com/uploads/chorus_asset/file/7396113/221a67c8-a10f-11e6-8fae-983107008690.0.png", url) + assertEquals(IconRequest.Resource.Type.MICROSOFT_TILE, type) + assertEquals(0, sizes.size) + } + + store.dispatch(TrackingProtectionAction.ClearTrackersAction("test-url")) + .join() + + // Loaded icon will be set on session + + store.state.findTab("test-url")!!.apply { + assertNotNull(this) + assertNotNull(content.icon) + } + } + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/extension/IconMessageKtTest.kt b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/extension/IconMessageKtTest.kt new file mode 100644 index 0000000000..5781463183 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/extension/IconMessageKtTest.kt @@ -0,0 +1,62 @@ +/* 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 mozilla.components.browser.icons.extension + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.browser.icons.IconRequest +import mozilla.components.concept.engine.manifest.Size +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class IconMessageKtTest { + + @Test + fun `Serializing and deserializing icon resources`() { + val resources = listOf( + IconRequest.Resource( + url = "https://www.mozilla.org/icon64.png", + sizes = listOf(Size(64, 64)), + mimeType = "image/png", + type = IconRequest.Resource.Type.FAVICON, + ), + IconRequest.Resource( + url = "https://www.mozilla.org/icon128.png", + sizes = listOf(Size(128, 128)), + mimeType = "image/png", + type = IconRequest.Resource.Type.FAVICON, + ), + IconRequest.Resource( + url = "https://www.mozilla.org/icon128.png", + sizes = listOf(Size(180, 180)), + type = IconRequest.Resource.Type.APPLE_TOUCH_ICON, + ), + ) + + val json = resources.toJSON() + assertEquals(3, json.length()) + + val restoredResources = json.toIconResources() + assertEquals(resources, restoredResources) + } + + @Test + fun `Url must be sanitized`() { + val resources = listOf( + IconRequest.Resource( + url = "\nhttps://www.mozilla.org/icon64.png\n", + sizes = listOf(Size(64, 64)), + mimeType = "image/png", + type = IconRequest.Resource.Type.FAVICON, + ), + ) + + val json = resources.toJSON() + + val restoredResource = json.toIconResources().first() + assertEquals("https://www.mozilla.org/icon64.png", restoredResource.url) + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/generator/DefaultIconGeneratorTest.kt b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/generator/DefaultIconGeneratorTest.kt new file mode 100644 index 0000000000..16d7ff3564 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/generator/DefaultIconGeneratorTest.kt @@ -0,0 +1,66 @@ +/* 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 mozilla.components.browser.icons.generator + +import android.graphics.Bitmap +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.browser.icons.Icon +import mozilla.components.browser.icons.IconRequest +import mozilla.components.support.ktx.android.util.dpToPx +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DefaultIconGeneratorTest { + + @Test + fun pickColor() { + val generator = DefaultIconGenerator() + val res = testContext.resources + + val color = generator.pickColor(res, "http://m.facebook.com") + + // Color does not change + for (i in 0..99) { + assertEquals(color, generator.pickColor(res, "http://m.facebook.com")) + } + + // Color is stable for "similar" hosts. + assertEquals(color, generator.pickColor(res, "https://m.facebook.com")) + assertEquals(color, generator.pickColor(res, "http://facebook.com")) + assertEquals(color, generator.pickColor(res, "http://www.facebook.com")) + assertEquals(color, generator.pickColor(res, "http://www.facebook.com/foo/bar/foobar?mobile=1")) + + // Returns a color for an empty string + assertNotEquals(0, generator.pickColor(res, "")) + } + + @Test + fun generate() { + val generator = DefaultIconGenerator() + + val icon = generator.generate( + testContext, + IconRequest( + url = "https://m.facebook.com", + ), + ) + + assertNotNull(icon.bitmap) + assertNotNull(icon.color) + + val dp32 = 32.dpToPx(testContext.resources.displayMetrics) + assertEquals(dp32, icon.bitmap.width) + assertEquals(dp32, icon.bitmap.height) + + assertEquals(Bitmap.Config.ARGB_8888, icon.bitmap.config) + + assertEquals(Icon.Source.GENERATOR, icon.source) + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/DataUriIconLoaderTest.kt b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/DataUriIconLoaderTest.kt new file mode 100644 index 0000000000..7fe5ec3e0a --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/DataUriIconLoaderTest.kt @@ -0,0 +1,93 @@ +/* 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 mozilla.components.browser.icons.loader + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.browser.icons.Icon +import mozilla.components.browser.icons.IconRequest +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DataUriIconLoaderTest { + + @Test + fun `Loader returns null for http(s) urls`() { + val loader = DataUriIconLoader() + + assertEquals( + IconLoader.Result.NoResult, + loader.load( + mock(), + mock(), + IconRequest.Resource( + url = "https://www.mozilla.org", + type = IconRequest.Resource.Type.FAVICON, + ), + ), + ) + + assertEquals( + IconLoader.Result.NoResult, + loader.load( + mock(), + mock(), + IconRequest.Resource( + url = "http://example.org", + type = IconRequest.Resource.Type.FAVICON, + ), + ), + ) + } + + @Test + fun `Loader returns bytes for data uri containing png`() { + val loader = DataUriIconLoader() + + val result = loader.load( + mock(), + mock(), + IconRequest.Resource( + url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91Jpz" + + "AAAAEklEQVR4AWP4z8AAxCDiP8N/AB3wBPxcBee7AAAAAElFTkSuQmCC", + type = IconRequest.Resource.Type.FAVICON, + ), + ) + + assertTrue(result is IconLoader.Result.BytesResult) + + val data = (result as IconLoader.Result.BytesResult).bytes + assertEquals(Icon.Source.INLINE, result.source) + + assertNotNull(data) + assertEquals(75, data.size) + } + + @Test + fun `Loader returns base64 decoded data`() { + val loader = DataUriIconLoader() + + val result = loader.load( + mock(), + mock(), + IconRequest.Resource( + url = "data:image/png;base64,dGhpcyBpcyBhIHRlc3Q=", + type = IconRequest.Resource.Type.FAVICON, + ), + ) + + assertTrue(result is IconLoader.Result.BytesResult) + + val data = (result as IconLoader.Result.BytesResult).bytes + assertEquals(Icon.Source.INLINE, result.source) + + val text = String(data, Charsets.UTF_8) + assertEquals("this is a test", text) + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/DiskIconLoaderTest.kt b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/DiskIconLoaderTest.kt new file mode 100644 index 0000000000..b3c7c5f028 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/DiskIconLoaderTest.kt @@ -0,0 +1,60 @@ +/* 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 mozilla.components.browser.icons.loader + +import android.content.Context +import mozilla.components.browser.icons.IconRequest +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class DiskIconLoaderTest { + @Test + fun `DiskIconLoader returns bitmap from cache`() { + val cache = object : DiskIconLoader.LoaderDiskCache { + override fun getIconData(context: Context, resource: IconRequest.Resource): ByteArray? { + return "Hello World".toByteArray() + } + } + + val loader = DiskIconLoader(cache) + + val request = IconRequest("https://www.mozilla.org") + val resource = IconRequest.Resource( + url = "https://www.mozilla.org/favicon.ico", + type = IconRequest.Resource.Type.FAVICON, + ) + + val result = loader.load(mock(), request, resource) + + assertTrue(result is IconLoader.Result.BytesResult) + + val bytesResult = result as IconLoader.Result.BytesResult + + assertEquals("Hello World", String(bytesResult.bytes)) + } + + @Test + fun `DiskIconLoader returns NoResult if cache does not contain entry`() { + val cache = object : DiskIconLoader.LoaderDiskCache { + override fun getIconData(context: Context, resource: IconRequest.Resource): ByteArray? { + return null + } + } + + val loader = DiskIconLoader(cache) + + val request = IconRequest("https://www.mozilla.org") + val resource = IconRequest.Resource( + url = "https://www.mozilla.org/favicon.ico", + type = IconRequest.Resource.Type.FAVICON, + ) + + val result = loader.load(mock(), request, resource) + + assertTrue(result is IconLoader.Result.NoResult) + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/FailureCacheTest.kt b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/FailureCacheTest.kt new file mode 100644 index 0000000000..9240e4514f --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/FailureCacheTest.kt @@ -0,0 +1,51 @@ +/* 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 mozilla.components.browser.icons.loader + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.spy + +@RunWith(AndroidJUnit4::class) +class FailureCacheTest { + + @Test + fun `Cache should remember URLs for limited amount of time`() { + val cache = spy(FailureCache()) + + cache.withFixedTime(0L) { + assertFalse(hasFailedRecently("https://www.mozilla.org")) + assertFalse(hasFailedRecently("https://www.firefox.com")) + } + + cache.withFixedTime(50L) { + rememberFailure("https://www.mozilla.org") + + assertTrue(hasFailedRecently("https://www.mozilla.org")) + assertFalse(hasFailedRecently("https://www.firefox.com")) + } + + // 15 Minutes later + cache.withFixedTime(50L + 1000L * 60L * 15L) { + assertTrue(hasFailedRecently("https://www.mozilla.org")) + assertFalse(hasFailedRecently("https://www.firefox.com")) + } + + // 40 Minutes later + cache.withFixedTime(50L + 1000L * 60L * 40L) { + assertFalse(hasFailedRecently("https://www.mozilla.org")) + assertFalse(hasFailedRecently("https://www.firefox.com")) + } + } +} + +private fun FailureCache.withFixedTime(now: Long, block: FailureCache.() -> Unit) { + doReturn(now).`when`(this).now() + block() +} diff --git a/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/HttpIconLoaderTest.kt b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/HttpIconLoaderTest.kt new file mode 100644 index 0000000000..3670066921 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/HttpIconLoaderTest.kt @@ -0,0 +1,273 @@ +/* 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 mozilla.components.browser.icons.loader + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.browser.icons.IconRequest +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.MutableHeaders +import mozilla.components.concept.fetch.Request +import mozilla.components.concept.fetch.Response +import mozilla.components.lib.fetch.httpurlconnection.HttpURLConnectionClient +import mozilla.components.lib.fetch.okhttp.OkHttpClient +import mozilla.components.support.test.any +import mozilla.components.support.test.argumentCaptor +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.doThrow +import org.mockito.Mockito.never +import org.mockito.Mockito.reset +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoMoreInteractions +import java.io.IOException +import java.io.InputStream + +@RunWith(AndroidJUnit4::class) +class HttpIconLoaderTest { + + @Test + fun `Loader downloads data and uses appropriate headers`() { + val clients = listOf( + HttpURLConnectionClient(), + OkHttpClient(), + ) + + clients.forEach { client -> + val server = MockWebServer() + + server.enqueue( + MockResponse().setBody( + javaClass.getResourceAsStream("/misc/test.txt")!! + .bufferedReader() + .use { it.readText() }, + ), + ) + + server.start() + + try { + val loader = HttpIconLoader(client) + val result = loader.load( + mock(), + mock(), + IconRequest.Resource( + url = server.url("/some/path").toString(), + type = IconRequest.Resource.Type.APPLE_TOUCH_ICON, + ), + ) + + assertTrue(result is IconLoader.Result.BytesResult) + + val data = (result as IconLoader.Result.BytesResult).bytes + + assertTrue(data.isNotEmpty()) + + val text = String(data, Charsets.UTF_8) + + assertEquals("Hello World!", text) + + val request = server.takeRequest() + + assertEquals("GET", request.method) + + val headers = request.headers + for (i in 0 until headers.size) { + println(headers.name(i) + ": " + headers.value(i)) + } + } finally { + server.shutdown() + } + } + } + + @Test + fun `Loader will not perform any requests for data uris`() { + val client: Client = mock() + + val result = HttpIconLoader(client).load( + mock(), + mock(), + IconRequest.Resource( + url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAA" + + "AAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + type = IconRequest.Resource.Type.FAVICON, + ), + ) + + assertEquals(IconLoader.Result.NoResult, result) + verify(client, never()).fetch(any()) + } + + @Test + fun `Request has timeouts applied`() { + val client: Client = mock() + + val loader = HttpIconLoader(client) + doReturn( + Response( + url = "https://www.example.org", + headers = MutableHeaders(), + status = 404, + body = Response.Body.empty(), + ), + ).`when`(client).fetch(any()) + + loader.load( + mock(), + mock(), + IconRequest.Resource( + url = "https://www.example.org", + type = IconRequest.Resource.Type.APPLE_TOUCH_ICON, + ), + ) + + val captor = argumentCaptor<Request>() + verify(client).fetch(captor.capture()) + + val request = captor.value + assertNotNull(request) + assertNotNull(request.connectTimeout) + assertNotNull(request.readTimeout) + } + + @Test + fun `NoResult is returned for non-successful requests`() { + val client: Client = mock() + + val loader = HttpIconLoader(client) + doReturn( + Response( + url = "https://www.example.org", + headers = MutableHeaders(), + status = 404, + body = Response.Body.empty(), + ), + ).`when`(client).fetch(any()) + + val result = loader.load( + mock(), + mock(), + IconRequest.Resource( + url = "https://www.example.org", + type = IconRequest.Resource.Type.APPLE_TOUCH_ICON, + ), + ) + + assertEquals(IconLoader.Result.NoResult, result) + } + + @Test + fun `Loader will not try to load URL again that just recently failed`() { + val client: Client = mock() + + val loader = HttpIconLoader(client) + doReturn( + Response( + url = "https://www.example.org", + headers = MutableHeaders(), + status = 404, + body = Response.Body.empty(), + ), + ).`when`(client).fetch(any()) + + val resource = IconRequest.Resource( + url = "https://www.example.org", + type = IconRequest.Resource.Type.APPLE_TOUCH_ICON, + ) + + assertEquals(IconLoader.Result.NoResult, loader.load(mock(), mock(), resource)) + + // First load tries to fetch, but load fails (404) + verify(client).fetch(any()) + verifyNoMoreInteractions(client) + reset(client) + + assertEquals(IconLoader.Result.NoResult, loader.load(mock(), mock(), resource)) + + // Second load does not try to fetch again. + verify(client, never()).fetch(any()) + } + + @Test + fun `Loader will return NoResult for IOExceptions happening during fetch`() { + val client: Client = mock() + doThrow(IOException("Mock")).`when`(client).fetch(any()) + + val loader = HttpIconLoader(client) + + val resource = IconRequest.Resource( + url = "https://www.example.org", + type = IconRequest.Resource.Type.APPLE_TOUCH_ICON, + ) + + assertEquals(IconLoader.Result.NoResult, loader.load(testContext, mock(), resource)) + } + + @Test + fun `Loader will return NoResult for IOExceptions happening during toIconLoaderResult`() { + val client: Client = mock() + + val failingStream: InputStream = object : InputStream() { + override fun read(): Int { + throw IOException("Kaboom") + } + } + + val loader = HttpIconLoader(client) + doReturn( + Response( + url = "https://www.example.org", + headers = MutableHeaders(), + status = 200, + body = Response.Body(failingStream), + ), + ).`when`(client).fetch(any()) + + val resource = IconRequest.Resource( + url = "https://www.example.org", + type = IconRequest.Resource.Type.APPLE_TOUCH_ICON, + ) + + assertEquals(IconLoader.Result.NoResult, loader.load(mock(), mock(), resource)) + } + + @Test + fun `Loader will sanitize URL`() { + val client: Client = mock() + + val loader = HttpIconLoader(client) + doReturn( + Response( + url = "https://www.example.org", + headers = MutableHeaders(), + status = 404, + body = Response.Body.empty(), + ), + ).`when`(client).fetch(any()) + + loader.load( + mock(), + mock(), + IconRequest.Resource( + url = " \n\n https://www.example.org \n\n ", + type = IconRequest.Resource.Type.APPLE_TOUCH_ICON, + ), + ) + + val captor = argumentCaptor<Request>() + verify(client).fetch(captor.capture()) + + val request = captor.value + assertEquals("https://www.example.org", request.url) + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/MemoryIconLoaderTest.kt b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/MemoryIconLoaderTest.kt new file mode 100644 index 0000000000..74854818a6 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/MemoryIconLoaderTest.kt @@ -0,0 +1,62 @@ +/* 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 mozilla.components.browser.icons.loader + +import android.graphics.Bitmap +import mozilla.components.browser.icons.IconRequest +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MemoryIconLoaderTest { + @Test + fun `MemoryIconLoader returns bitmap from cache`() { + val bitmap: Bitmap = mock() + + val cache = object : MemoryIconLoader.LoaderMemoryCache { + override fun getBitmap(request: IconRequest, resource: IconRequest.Resource): Bitmap? { + return bitmap + } + } + + val loader = MemoryIconLoader(cache) + + val request = IconRequest("https://www.mozilla.org") + val resource = IconRequest.Resource( + url = "https://www.mozilla.org/favicon.ico", + type = IconRequest.Resource.Type.FAVICON, + ) + + val result = loader.load(mock(), request, resource) + + assertTrue(result is IconLoader.Result.BitmapResult) + + val bitmapResult = result as IconLoader.Result.BitmapResult + + assertEquals(bitmap, bitmapResult.bitmap) + } + + @Test + fun `MemoryIconLoader returns NoResult if cache does not contain entry`() { + val cache = object : MemoryIconLoader.LoaderMemoryCache { + override fun getBitmap(request: IconRequest, resource: IconRequest.Resource): Bitmap? { + return null + } + } + + val loader = MemoryIconLoader(cache) + + val request = IconRequest("https://www.mozilla.org") + val resource = IconRequest.Resource( + url = "https://www.mozilla.org/favicon.ico", + type = IconRequest.Resource.Type.FAVICON, + ) + + val result = loader.load(mock(), request, resource) + + assertTrue(result is IconLoader.Result.NoResult) + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/NonBlockingHttpIconLoaderTest.kt b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/NonBlockingHttpIconLoaderTest.kt new file mode 100644 index 0000000000..6b2fe8d8ad --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/NonBlockingHttpIconLoaderTest.kt @@ -0,0 +1,318 @@ +/* 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 mozilla.components.browser.icons.loader + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import mozilla.components.browser.icons.Icon +import mozilla.components.browser.icons.IconRequest +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.MutableHeaders +import mozilla.components.concept.fetch.Request +import mozilla.components.concept.fetch.Response +import mozilla.components.lib.fetch.httpurlconnection.HttpURLConnectionClient +import mozilla.components.lib.fetch.okhttp.OkHttpClient +import mozilla.components.support.test.any +import mozilla.components.support.test.argumentCaptor +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.rule.MainCoroutineRule +import mozilla.components.support.test.rule.runTestOnMain +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.doThrow +import org.mockito.Mockito.never +import org.mockito.Mockito.reset +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoMoreInteractions +import java.io.IOException +import java.io.InputStream + +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +class NonBlockingHttpIconLoaderTest { + @get:Rule + val coroutinesTestRule = MainCoroutineRule() + private val scope = coroutinesTestRule.scope + + @Test + fun `Loader will return IconLoader#Result#NoResult for a load request and respond with the result through a callback`() = runTestOnMain { + val clients = listOf( + HttpURLConnectionClient(), + OkHttpClient(), + ) + + clients.forEach { client -> + + val server = MockWebServer() + + server.enqueue( + MockResponse().setBody( + javaClass.getResourceAsStream("/misc/test.txt")!! + .bufferedReader() + .use { it.readText() }, + ), + ) + + server.start() + + try { + var callbackIconRequest: IconRequest? = null + var callbackResource: IconRequest.Resource? = null + var callbackIcon: IconLoader.Result? = null + val loader = NonBlockingHttpIconLoader(client, scope) { request, resource, icon -> + callbackIconRequest = request + callbackResource = resource + callbackIcon = icon + } + val iconRequest: IconRequest = mock() + + val result = loader.load( + mock(), + iconRequest, + IconRequest.Resource( + url = server.url("/some/path").toString(), + type = IconRequest.Resource.Type.APPLE_TOUCH_ICON, + ), + ) + + assertTrue(result is IconLoader.Result.NoResult) + val downloadedResource = String(((callbackIcon as IconLoader.Result.BytesResult).bytes), Charsets.UTF_8) + assertEquals("Hello World!", downloadedResource) + assertSame(Icon.Source.DOWNLOAD, ((callbackIcon as IconLoader.Result.BytesResult).source)) + assertTrue(callbackResource!!.url.endsWith("/some/path")) + assertSame(IconRequest.Resource.Type.APPLE_TOUCH_ICON, callbackResource?.type) + assertSame(iconRequest, callbackIconRequest) + } finally { + server.shutdown() + } + } + } + + @Test + fun `Loader will not perform any requests for data uris`() = runTestOnMain { + val client: Client = mock() + var callbackIconRequest: IconRequest? = null + var callbackResource: IconRequest.Resource? = null + var callbackIcon: IconLoader.Result? = null + val loader = NonBlockingHttpIconLoader(client, scope) { request, resource, icon -> + callbackIconRequest = request + callbackResource = resource + callbackIcon = icon + } + + val result = loader.load( + mock(), + mock(), + IconRequest.Resource( + url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAA" + + "AAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + type = IconRequest.Resource.Type.FAVICON, + ), + ) + + assertEquals(IconLoader.Result.NoResult, result) + assertNull(callbackIconRequest) + assertNull(callbackResource) + assertNull(callbackIcon) + verify(client, never()).fetch(any()) + } + + @Test + fun `Request has timeouts applied`() = runTestOnMain { + val client: Client = mock() + val loader = NonBlockingHttpIconLoader(client, scope) { _, _, _ -> } + doReturn( + Response( + url = "https://www.example.org", + headers = MutableHeaders(), + status = 404, + body = Response.Body.empty(), + ), + ).`when`(client).fetch(any()) + + loader.load( + mock(), + mock(), + IconRequest.Resource( + url = "https://www.example.org", + type = IconRequest.Resource.Type.APPLE_TOUCH_ICON, + ), + ) + + val captor = argumentCaptor<Request>() + verify(client).fetch(captor.capture()) + val request = captor.value + assertNotNull(request) + assertNotNull(request.connectTimeout) + assertNotNull(request.readTimeout) + } + + @Test + fun `NoResult is returned for non-successful requests`() = runTestOnMain { + val client: Client = mock() + var callbackIconRequest: IconRequest? = null + var callbackResource: IconRequest.Resource? = null + var callbackIcon: IconLoader.Result? = null + val loader = NonBlockingHttpIconLoader(client, scope) { request, resource, icon -> + callbackIconRequest = request + callbackResource = resource + callbackIcon = icon + } + doReturn( + Response( + url = "https://www.example.org", + headers = MutableHeaders(), + status = 404, + body = Response.Body.empty(), + ), + ).`when`(client).fetch(any()) + + val result = loader.load( + mock(), + mock(), + IconRequest.Resource( + url = "https://www.example.org", + type = IconRequest.Resource.Type.APPLE_TOUCH_ICON, + ), + ) + + assertEquals(IconLoader.Result.NoResult, result) + assertEquals(IconLoader.Result.NoResult, callbackIcon) + assertNotNull(callbackIconRequest) + assertEquals("https://www.example.org", callbackResource!!.url) + assertSame(IconRequest.Resource.Type.APPLE_TOUCH_ICON, callbackResource?.type) + } + + @Test + fun `Loader will not try to load URL again that just recently failed`() = runTestOnMain { + val client: Client = mock() + val loader = NonBlockingHttpIconLoader(client, scope) { _, _, _ -> } + doReturn( + Response( + url = "https://www.example.org", + headers = MutableHeaders(), + status = 404, + body = Response.Body.empty(), + ), + ).`when`(client).fetch(any()) + val resource = IconRequest.Resource( + url = "https://www.example.org", + type = IconRequest.Resource.Type.APPLE_TOUCH_ICON, + ) + + val result = loader.load(mock(), mock(), resource) + + assertEquals(IconLoader.Result.NoResult, result) + // First load tries to fetch, but load fails (404) + verify(client).fetch(any()) + verifyNoMoreInteractions(client) + reset(client) + assertEquals(IconLoader.Result.NoResult, loader.load(mock(), mock(), resource)) + // Second load does not try to fetch again. + verify(client, never()).fetch(any()) + } + + @Test + fun `Loader will return NoResult for IOExceptions happening during fetch`() = runTestOnMain { + val client: Client = mock() + doThrow(IOException("Mock")).`when`(client).fetch(any()) + var callbackIconRequest: IconRequest? = null + var callbackResource: IconRequest.Resource? = null + var callbackIcon: IconLoader.Result? = null + val loader = NonBlockingHttpIconLoader(client, scope) { request, resource, icon -> + callbackIconRequest = request + callbackResource = resource + callbackIcon = icon + } + + val resource = IconRequest.Resource( + url = "https://www.example.org", + type = IconRequest.Resource.Type.APPLE_TOUCH_ICON, + ) + + val result = loader.load(testContext, mock(), resource) + assertEquals(IconLoader.Result.NoResult, result) + assertEquals(IconLoader.Result.NoResult, callbackIcon) + assertNotNull(callbackIconRequest) + assertEquals("https://www.example.org", callbackResource!!.url) + assertSame(IconRequest.Resource.Type.APPLE_TOUCH_ICON, callbackResource?.type) + } + + @Test + fun `Loader will return NoResult for IOExceptions happening during toIconLoaderResult`() = runTestOnMain { + val client: Client = mock() + var callbackIconRequest: IconRequest? = null + var callbackResource: IconRequest.Resource? = null + var callbackIcon: IconLoader.Result? = null + val loader = NonBlockingHttpIconLoader(client, scope) { request, resource, icon -> + callbackIconRequest = request + callbackResource = resource + callbackIcon = icon + } + val failingStream: InputStream = object : InputStream() { + override fun read(): Int { + throw IOException("Kaboom") + } + } + doReturn( + Response( + url = "https://www.example.org", + headers = MutableHeaders(), + status = 200, + body = Response.Body(failingStream), + ), + ).`when`(client).fetch(any()) + val resource = IconRequest.Resource( + url = "https://www.example.org", + type = IconRequest.Resource.Type.APPLE_TOUCH_ICON, + ) + + val result = loader.load(testContext, mock(), resource) + + assertEquals(IconLoader.Result.NoResult, result) + assertEquals(IconLoader.Result.NoResult, callbackIcon) + assertNotNull(callbackIconRequest) + assertEquals("https://www.example.org", callbackResource!!.url) + assertSame(IconRequest.Resource.Type.APPLE_TOUCH_ICON, callbackResource?.type) + } + + @Test + fun `Loader will sanitize URL`() = runTestOnMain { + val client: Client = mock() + val captor = argumentCaptor<Request>() + val loader = NonBlockingHttpIconLoader(client, scope) { _, _, _ -> } + doReturn( + Response( + url = "https://www.example.org", + headers = MutableHeaders(), + status = 404, + body = Response.Body.empty(), + ), + ).`when`(client).fetch(any()) + + loader.load( + mock(), + mock(), + IconRequest.Resource( + url = " \n\n https://www.example.org \n\n ", + type = IconRequest.Resource.Type.APPLE_TOUCH_ICON, + ), + ) + + verify(client).fetch(captor.capture()) + val request = captor.value + assertEquals("https://www.example.org", request.url) + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/pipeline/IconResourceComparatorTest.kt b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/pipeline/IconResourceComparatorTest.kt new file mode 100644 index 0000000000..d3fd7cd222 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/pipeline/IconResourceComparatorTest.kt @@ -0,0 +1,354 @@ +/* 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 mozilla.components.browser.icons.pipeline + +import mozilla.components.browser.icons.IconRequest +import mozilla.components.concept.engine.manifest.Size +import org.junit.Assert.assertEquals +import org.junit.Test + +class IconResourceComparatorTest { + @Test + fun `compare mozilla-org icons`() { + val resources = listOf( + IconRequest.Resource( + url = "https://www.mozilla.org/media/img/favicon/favicon-196x196.c80e6abe0767.png", + type = IconRequest.Resource.Type.FAVICON, + sizes = listOf(Size(196, 196)), + ), + IconRequest.Resource( + url = "https://www.mozilla.org/media/img/favicon.d4f1f46b91f4.ico", + type = IconRequest.Resource.Type.FAVICON, + ), + IconRequest.Resource( + url = "https://www.mozilla.org/media/img/favicon/apple-touch-icon-180x180.8772ec154918.png", + type = IconRequest.Resource.Type.APPLE_TOUCH_ICON, + sizes = listOf(Size(180, 180)), + ), + IconRequest.Resource( + url = "https://www.mozilla.org/media/img/mozorg/mozilla-256.4720741d4108.jpg", + type = IconRequest.Resource.Type.OPENGRAPH, + ), + ) + + val urls = resources.sortedWith(IconResourceComparator).map { it.url } + + assertEquals( + listOf( + "https://www.mozilla.org/media/img/favicon/apple-touch-icon-180x180.8772ec154918.png", + "https://www.mozilla.org/media/img/favicon/favicon-196x196.c80e6abe0767.png", + "https://www.mozilla.org/media/img/favicon.d4f1f46b91f4.ico", + "https://www.mozilla.org/media/img/mozorg/mozilla-256.4720741d4108.jpg", + ), + urls, + ) + } + + @Test + fun `compare m-youtube-com icons`() { + val resources = listOf( + IconRequest.Resource( + url = "https://s.ytimg.com/yts/img/favicon-vfl8qSV2F.ico", + type = IconRequest.Resource.Type.FAVICON, + mimeType = "image/x-icon", + ), + IconRequest.Resource( + url = "https://s.ytimg.com/yts/img/favicon-vfl8qSV2F.ico", + type = IconRequest.Resource.Type.FAVICON, + mimeType = "image/x-icon", + ), + ) + + val urls = resources.sortedWith(IconResourceComparator).map { it.url } + + assertEquals( + listOf( + "https://s.ytimg.com/yts/img/favicon-vfl8qSV2F.ico", + "https://s.ytimg.com/yts/img/favicon-vfl8qSV2F.ico", + ), + urls, + ) + } + + @Test + fun `compare m-facebook-com icons`() { + val resources = listOf( + IconRequest.Resource( + url = "https://static.xx.fbcdn.net/rsrc.php/v3/ya/r/O2aKM2iSbOw.png", + type = IconRequest.Resource.Type.FAVICON, + sizes = listOf(Size(196, 196)), + ), + ) + + val urls = resources.sortedWith(IconResourceComparator).map { it.url } + + assertEquals( + listOf( + "https://static.xx.fbcdn.net/rsrc.php/v3/ya/r/O2aKM2iSbOw.png", + ), + urls, + ) + } + + @Test + fun `compare baidu-com icons`() { + val resources = listOf( + IconRequest.Resource( + url = "http://sm.bdimg.com/static/wiseindex/img/favicon64.ico", + type = IconRequest.Resource.Type.FAVICON, + ), + IconRequest.Resource( + url = "http://sm.bdimg.com/static/wiseindex/img/screen_icon_new.png", + type = IconRequest.Resource.Type.APPLE_TOUCH_ICON, + ), + ) + + val urls = resources.sortedWith(IconResourceComparator).map { it.url } + + assertEquals( + listOf( + "http://sm.bdimg.com/static/wiseindex/img/screen_icon_new.png", + "http://sm.bdimg.com/static/wiseindex/img/favicon64.ico", + ), + urls, + ) + } + + @Test + fun `compare wikipedia-org icons`() { + val resources = listOf( + IconRequest.Resource( + url = "https://www.wikipedia.org/static/favicon/wikipedia.ico", + type = IconRequest.Resource.Type.FAVICON, + ), + IconRequest.Resource( + url = "https://www.wikipedia.org/static/apple-touch/wikipedia.png", + type = IconRequest.Resource.Type.APPLE_TOUCH_ICON, + ), + ) + + val urls = resources.sortedWith(IconResourceComparator).map { it.url } + + assertEquals( + listOf( + "https://www.wikipedia.org/static/apple-touch/wikipedia.png", + "https://www.wikipedia.org/static/favicon/wikipedia.ico", + ), + urls, + ) + } + + @Test + fun `compare amazon-com icons`() { + val resources = listOf( + IconRequest.Resource( + url = "https://images-na.ssl-images-amazon.com/images/G/01/anywhere/a_smile_57x57._CB368212015_.png", + type = IconRequest.Resource.Type.APPLE_TOUCH_ICON, + sizes = listOf(Size(57, 57)), + ), + IconRequest.Resource( + url = "https://images-na.ssl-images-amazon.com/images/G/01/anywhere/a_smile_72x72._CB368212002_.png", + type = IconRequest.Resource.Type.APPLE_TOUCH_ICON, + sizes = listOf(Size(72, 72)), + ), + IconRequest.Resource( + url = "https://images-na.ssl-images-amazon.com/images/G/01/anywhere/a_smile_114x114._CB368212020_.png", + type = IconRequest.Resource.Type.APPLE_TOUCH_ICON, + sizes = listOf(Size(114, 114)), + ), + IconRequest.Resource( + url = "https://images-na.ssl-images-amazon.com/images/G/01/anywhere/a_smile_120x120._CB368246573_.png", + type = IconRequest.Resource.Type.APPLE_TOUCH_ICON, + sizes = listOf(Size(120, 120)), + ), + IconRequest.Resource( + url = "https://images-na.ssl-images-amazon.com/images/G/01/anywhere/a_smile_144x144._CB368211973_.png", + type = IconRequest.Resource.Type.APPLE_TOUCH_ICON, + sizes = listOf(Size(144, 144)), + ), + IconRequest.Resource( + url = "https://images-na.ssl-images-amazon.com/images/G/01/anywhere/a_smile_152x152._CB368246573_.png", + type = IconRequest.Resource.Type.APPLE_TOUCH_ICON, + sizes = listOf(Size(152, 152)), + ), + IconRequest.Resource( + url = "https://images-na.ssl-images-amazon.com/images/G/01/anywhere/a_smile_196x196._CB368246573_.png", + type = IconRequest.Resource.Type.APPLE_TOUCH_ICON, + sizes = listOf(Size(196, 196)), + ), + ) + + val urls = resources.sortedWith(IconResourceComparator).map { it.url } + + assertEquals( + listOf( + "https://images-na.ssl-images-amazon.com/images/G/01/anywhere/a_smile_196x196._CB368246573_.png", + "https://images-na.ssl-images-amazon.com/images/G/01/anywhere/a_smile_152x152._CB368246573_.png", + "https://images-na.ssl-images-amazon.com/images/G/01/anywhere/a_smile_144x144._CB368211973_.png", + "https://images-na.ssl-images-amazon.com/images/G/01/anywhere/a_smile_120x120._CB368246573_.png", + "https://images-na.ssl-images-amazon.com/images/G/01/anywhere/a_smile_114x114._CB368212020_.png", + "https://images-na.ssl-images-amazon.com/images/G/01/anywhere/a_smile_72x72._CB368212002_.png", + "https://images-na.ssl-images-amazon.com/images/G/01/anywhere/a_smile_57x57._CB368212015_.png", + ), + urls, + ) + } + + @Test + fun `compare twitter-com icons`() { + val resources = listOf( + IconRequest.Resource( + url = "https://abs.twimg.com/favicons/favicon.ico", + type = IconRequest.Resource.Type.FAVICON, + ), + IconRequest.Resource( + url = "https://abs.twimg.com/responsive-web/web/icon-ios.8ea219d08eafdfa41.png", + type = IconRequest.Resource.Type.APPLE_TOUCH_ICON, + ), + ) + + val urls = resources.sortedWith(IconResourceComparator).map { it.url } + + assertEquals( + listOf( + "https://abs.twimg.com/responsive-web/web/icon-ios.8ea219d08eafdfa41.png", + "https://abs.twimg.com/favicons/favicon.ico", + ), + urls, + ) + } + + @Test + fun `compare github-com icons`() { + val resources = listOf( + IconRequest.Resource( + url = "https://github.githubassets.com/favicon.ico", + type = IconRequest.Resource.Type.FAVICON, + ), + IconRequest.Resource( + url = "https://github.com/fluidicon.png", + type = IconRequest.Resource.Type.FLUID_ICON, + ), + IconRequest.Resource( + url = "https://github.githubassets.com/images/modules/open_graph/github-logo.png", + type = IconRequest.Resource.Type.OPENGRAPH, + ), + IconRequest.Resource( + url = "https://github.githubassets.com/images/modules/open_graph/github-mark.png", + type = IconRequest.Resource.Type.OPENGRAPH, + ), + IconRequest.Resource( + url = "https://github.githubassets.com/images/modules/open_graph/github-octocat.png", + type = IconRequest.Resource.Type.OPENGRAPH, + ), + ) + + val urls = resources.sortedWith(IconResourceComparator).map { it.url } + + assertEquals( + listOf( + "https://github.githubassets.com/favicon.ico", + "https://github.com/fluidicon.png", + "https://github.githubassets.com/images/modules/open_graph/github-logo.png", + "https://github.githubassets.com/images/modules/open_graph/github-mark.png", + "https://github.githubassets.com/images/modules/open_graph/github-octocat.png", + ), + urls, + ) + } + + @Test + fun `compare theverge-com icons`() { + val resources = listOf( + IconRequest.Resource( + url = "https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395367/favicon-16x16.0.png", + type = IconRequest.Resource.Type.FAVICON, + sizes = listOf(Size(16, 16)), + ), + IconRequest.Resource( + url = "https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395363/favicon-32x32.0.png", + type = IconRequest.Resource.Type.FAVICON, + sizes = listOf(Size(32, 32)), + ), + IconRequest.Resource( + url = "https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395365/favicon-96x96.0.png", + type = IconRequest.Resource.Type.FAVICON, + sizes = listOf(Size(96, 96)), + ), + IconRequest.Resource( + url = "https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395351/android-chrome-192x192.0.png", + type = IconRequest.Resource.Type.FAVICON, + sizes = listOf(Size(192, 192)), + ), + IconRequest.Resource( + url = "https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395361/favicon-64x64.0.ico", + type = IconRequest.Resource.Type.FAVICON, + ), + IconRequest.Resource( + url = "https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395359/ios-icon.0.png", + type = IconRequest.Resource.Type.APPLE_TOUCH_ICON, + sizes = listOf(Size(180, 180)), + ), + IconRequest.Resource( + url = "https://cdn.vox-cdn.com/uploads/chorus_asset/file/9672633/VergeOG.0_1200x627.0.png", + type = IconRequest.Resource.Type.OPENGRAPH, + ), + IconRequest.Resource( + url = "https://cdn.vox-cdn.com/community_logos/52803/VER_Logomark_175x92..png", + type = IconRequest.Resource.Type.TWITTER, + ), + IconRequest.Resource( + url = "https://cdn.vox-cdn.com/uploads/chorus_asset/file/7396113/221a67c8-a10f-11e6-8fae-983107008690.0.png", + type = IconRequest.Resource.Type.MICROSOFT_TILE, + ), + ) + + val urls = resources.sortedWith(IconResourceComparator).map { it.url } + + assertEquals( + listOf( + "https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395359/ios-icon.0.png", + "https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395351/android-chrome-192x192.0.png", + "https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395365/favicon-96x96.0.png", + "https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395363/favicon-32x32.0.png", + "https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395367/favicon-16x16.0.png", + "https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395361/favicon-64x64.0.ico", + "https://cdn.vox-cdn.com/uploads/chorus_asset/file/9672633/VergeOG.0_1200x627.0.png", + "https://cdn.vox-cdn.com/community_logos/52803/VER_Logomark_175x92..png", + "https://cdn.vox-cdn.com/uploads/chorus_asset/file/7396113/221a67c8-a10f-11e6-8fae-983107008690.0.png", + ), + urls, + ) + } + + @Test + fun `compare proxx-app icons`() { + val resources = listOf( + IconRequest.Resource( + url = "https://proxx.app/assets/icon-05a70868.png", + type = IconRequest.Resource.Type.MANIFEST_ICON, + sizes = listOf(Size(1024, 1024)), + mimeType = "image/png", + ), + IconRequest.Resource( + url = "https://proxx.app/assets/icon-maskable-7a2eb399.png", + type = IconRequest.Resource.Type.MANIFEST_ICON, + sizes = listOf(Size(1024, 1024)), + mimeType = "image/png", + maskable = true, + ), + ) + + val urls = resources.sortedWith(IconResourceComparator).map { it.url } + + assertEquals( + listOf( + "https://proxx.app/assets/icon-maskable-7a2eb399.png", + "https://proxx.app/assets/icon-05a70868.png", + ), + urls, + ) + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/preparer/DiskIconPreparerTest.kt b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/preparer/DiskIconPreparerTest.kt new file mode 100644 index 0000000000..9cc92cfbf7 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/preparer/DiskIconPreparerTest.kt @@ -0,0 +1,69 @@ +/* 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 mozilla.components.browser.icons.preparer + +import mozilla.components.browser.icons.IconRequest +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mockito.Mockito + +class DiskIconPreparerTest { + @Test + fun `Preparer will add resources from cache`() { + val resources = listOf( + IconRequest.Resource("https://www.mozilla.org", type = IconRequest.Resource.Type.FAVICON), + IconRequest.Resource("https://www.firefox.com", type = IconRequest.Resource.Type.APPLE_TOUCH_ICON), + ) + + val cache: DiskIconPreparer.PreparerDiskCache = mock() + Mockito.doReturn(resources).`when`(cache).getResources(any(), any()) + + val preparer = DiskIconPreparer(cache) + + val initialRequest = IconRequest(url = "example.org") + + val request = preparer.prepare(mock(), initialRequest) + + assertEquals(2, request.resources.size) + assertEquals( + listOf( + "https://www.mozilla.org", + "https://www.firefox.com", + ), + request.resources.map { it.url }, + ) + } + + @Test + fun `Preparer will not add resources if request already has resources`() { + val resources = listOf( + IconRequest.Resource("https://www.mozilla.org", type = IconRequest.Resource.Type.FAVICON), + IconRequest.Resource("https://www.firefox.com", type = IconRequest.Resource.Type.APPLE_TOUCH_ICON), + ) + + val cache: DiskIconPreparer.PreparerDiskCache = mock() + Mockito.doReturn(resources).`when`(cache).getResources(any(), any()) + + val preparer = DiskIconPreparer(cache) + + val initialRequest = IconRequest( + url = "https://www.example.org", + resources = listOf( + IconRequest.Resource("https://getpocket.com", type = IconRequest.Resource.Type.FAVICON), + ), + ) + + val request = preparer.prepare(mock(), initialRequest) + + assertEquals( + listOf( + "https://getpocket.com", + ), + request.resources.map { it.url }, + ) + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/preparer/MemoryIconPreparerTest.kt b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/preparer/MemoryIconPreparerTest.kt new file mode 100644 index 0000000000..d563d8d968 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/preparer/MemoryIconPreparerTest.kt @@ -0,0 +1,69 @@ +/* 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 mozilla.components.browser.icons.preparer + +import mozilla.components.browser.icons.IconRequest +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mockito.Mockito.doReturn + +class MemoryIconPreparerTest { + @Test + fun `Preparer will add resources from cache`() { + val resources = listOf( + IconRequest.Resource("https://www.mozilla.org", type = IconRequest.Resource.Type.FAVICON), + IconRequest.Resource("https://www.firefox.com", type = IconRequest.Resource.Type.APPLE_TOUCH_ICON), + ) + + val cache: MemoryIconPreparer.PreparerMemoryCache = mock() + doReturn(resources).`when`(cache).getResources(any()) + + val preparer = MemoryIconPreparer(cache) + + val initialRequest = IconRequest(url = "example.org") + + val request = preparer.prepare(mock(), initialRequest) + + assertEquals(2, request.resources.size) + assertEquals( + listOf( + "https://www.mozilla.org", + "https://www.firefox.com", + ), + request.resources.map { it.url }, + ) + } + + @Test + fun `Preparer will not add resources if request already has resources`() { + val resources = listOf( + IconRequest.Resource("https://www.mozilla.org", type = IconRequest.Resource.Type.FAVICON), + IconRequest.Resource("https://www.firefox.com", type = IconRequest.Resource.Type.APPLE_TOUCH_ICON), + ) + + val cache: MemoryIconPreparer.PreparerMemoryCache = mock() + doReturn(resources).`when`(cache).getResources(any()) + + val preparer = MemoryIconPreparer(cache) + + val initialRequest = IconRequest( + url = "https://www.example.org", + resources = listOf( + IconRequest.Resource("https://getpocket.com", type = IconRequest.Resource.Type.FAVICON), + ), + ) + + val request = preparer.prepare(mock(), initialRequest) + + assertEquals( + listOf( + "https://getpocket.com", + ), + request.resources.map { it.url }, + ) + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/preparer/TippyTopIconPreparerTest.kt b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/preparer/TippyTopIconPreparerTest.kt new file mode 100644 index 0000000000..b1f02ff560 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/preparer/TippyTopIconPreparerTest.kt @@ -0,0 +1,110 @@ +/* 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 mozilla.components.browser.icons.preparer + +import android.content.res.AssetManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.browser.icons.IconRequest +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn + +@RunWith(AndroidJUnit4::class) +class TippyTopIconPreparerTest { + @Test + fun `WHEN url is not in list THEN no resource is added`() { + val preparer = TippyTopIconPreparer(testContext.assets) + + val request = IconRequest("https://thispageisnotpartofthetippytopylist.org") + assertEquals(0, request.resources.size) + + val preparedRequest = preparer.prepare(testContext, request) + assertEquals(0, preparedRequest.resources.size) + } + + @Test + fun `WHEN url is not http(s) THEN no resource is added`() { + val preparer = TippyTopIconPreparer(testContext.assets) + + val request = IconRequest("about://www.github.com") + assertEquals(0, request.resources.size) + + val preparedRequest = preparer.prepare(testContext, request) + assertEquals(0, preparedRequest.resources.size) + } + + @Test + fun `WHEN list could not be read THEN no resource is added`() { + val assetManager: AssetManager = mock() + doReturn("{".toByteArray().inputStream()).`when`(assetManager).open(any()) + + val preparer = TippyTopIconPreparer(assetManager) + + val request = IconRequest("https://www.github.com") + assertEquals(0, request.resources.size) + + val preparedRequest = preparer.prepare(testContext, request) + assertEquals(0, preparedRequest.resources.size) + } + + @Test + fun `WHEN url is Wikipedia THEN prefix is ignored`() { + val preparer = TippyTopIconPreparer(testContext.assets) + + var request = IconRequest("https://www.wikipedia.org") + assertEquals(0, request.resources.size) + + var preparedRequest = preparer.prepare(testContext, request) + assertEquals(1, preparedRequest.resources.size) + + var resource = preparedRequest.resources[0] + + assertEquals("https://www.wikipedia.org/static/apple-touch/wikipedia.png", resource.url) + assertEquals(IconRequest.Resource.Type.TIPPY_TOP, resource.type) + + request = IconRequest("https://en.wikipedia.org") + assertEquals(0, request.resources.size) + + preparedRequest = preparer.prepare(testContext, request) + assertEquals(1, preparedRequest.resources.size) + + resource = preparedRequest.resources[0] + + assertEquals("https://www.wikipedia.org/static/apple-touch/wikipedia.png", resource.url) + assertEquals(IconRequest.Resource.Type.TIPPY_TOP, resource.type) + + request = IconRequest("https://de.wikipedia.org") + assertEquals(0, request.resources.size) + + preparedRequest = preparer.prepare(testContext, request) + assertEquals(1, preparedRequest.resources.size) + + resource = preparedRequest.resources[0] + + assertEquals("https://www.wikipedia.org/static/apple-touch/wikipedia.png", resource.url) + assertEquals(IconRequest.Resource.Type.TIPPY_TOP, resource.type) + + request = IconRequest("https://de.m.wikipedia.org") + assertEquals(0, request.resources.size) + + preparedRequest = preparer.prepare(testContext, request) + assertEquals(1, preparedRequest.resources.size) + + resource = preparedRequest.resources[0] + + assertEquals("https://www.wikipedia.org/static/apple-touch/wikipedia.png", resource.url) + assertEquals(IconRequest.Resource.Type.TIPPY_TOP, resource.type) + + request = IconRequest("https://abc.wikipedia.org.com") + assertEquals(0, request.resources.size) + + preparedRequest = preparer.prepare(testContext, request) + assertEquals(0, preparedRequest.resources.size) + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/processor/AdaptiveIconProcessorTest.kt b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/processor/AdaptiveIconProcessorTest.kt new file mode 100644 index 0000000000..d113b6e2a1 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/processor/AdaptiveIconProcessorTest.kt @@ -0,0 +1,89 @@ +/* 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 mozilla.components.browser.icons.processor + +import android.os.Build +import androidx.core.graphics.createBitmap +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.browser.icons.Icon +import mozilla.components.browser.icons.IconRequest +import mozilla.components.browser.icons.IconRequest.Resource.Type.MANIFEST_ICON +import mozilla.components.support.test.mock +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify +import org.robolectric.util.ReflectionHelpers.setStaticField +import kotlin.reflect.jvm.javaField + +@RunWith(AndroidJUnit4::class) +class AdaptiveIconProcessorTest { + + @Before + fun setup() { + setSdkInt(0) + } + + @After + fun teardown() = setSdkInt(0) + + @Test + fun `process returns non-maskable icons on legacy devices`() { + val icon = Icon(mock(), source = Icon.Source.GENERATOR) + + assertEquals( + icon, + AdaptiveIconProcessor().process(mock(), mock(), mock(), icon, mock()), + ) + } + + @Test + fun `process adds padding to legacy icons`() { + setSdkInt(Build.VERSION_CODES.O) + val bitmap = spy(createBitmap(128, 128)) + + val icon = AdaptiveIconProcessor().process( + mock(), + mock(), + IconRequest.Resource("", MANIFEST_ICON, maskable = false), + Icon(bitmap, source = Icon.Source.DISK), + mock(), + ) + + assertEquals(228, icon.bitmap.width) + assertEquals(228, icon.bitmap.height) + + assertEquals(Icon.Source.DISK, icon.source) + assertTrue(icon.maskable) + verify(bitmap).recycle() + } + + @Test + fun `process adjusts the size of maskable icons`() { + val bitmap = createBitmap(256, 256) + + val icon = AdaptiveIconProcessor().process( + mock(), + mock(), + IconRequest.Resource("", MANIFEST_ICON, maskable = true), + Icon(bitmap, source = Icon.Source.INLINE), + mock(), + ) + + assertEquals(334, icon.bitmap.width) + assertEquals(334, icon.bitmap.height) + + assertEquals(Icon.Source.INLINE, icon.source) + assertTrue(icon.maskable) + } + + private fun setSdkInt(sdkVersion: Int) { + setStaticField(Build.VERSION::SDK_INT.javaField, sdkVersion) + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/processor/ColorProcessorTest.kt b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/processor/ColorProcessorTest.kt new file mode 100644 index 0000000000..62c2d35398 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/processor/ColorProcessorTest.kt @@ -0,0 +1,53 @@ +/* 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 mozilla.components.browser.icons.processor + +import android.graphics.Bitmap +import android.graphics.Color +import mozilla.components.browser.icons.Icon +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.mockito.Mockito.anyInt +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.doReturn + +class ColorProcessorTest { + @Test + fun `test extracting color`() { + val icon = Icon(mockRedBitmap(1), source = Icon.Source.DISK) + val processed = ColorProcessor().process(mock(), mock(), mock(), icon, mock()) + + assertEquals(icon.bitmap, processed.bitmap) + assertNotNull(processed.color) + } + + @Test + fun `test extracting color from larger bitmap`() { + val icon = Icon(mockRedBitmap(3), source = Icon.Source.DISK) + val processed = ColorProcessor().process(mock(), mock(), mock(), icon, mock()) + + assertEquals(icon.bitmap, processed.bitmap) + assertNotNull(processed.color) + } + + private fun mockRedBitmap(size: Int): Bitmap { + val bitmap: Bitmap = mock() + doReturn(size).`when`(bitmap).height + doReturn(size).`when`(bitmap).width + + doAnswer { + val pixels: IntArray = it.getArgument(0) + for (i in 0 until pixels.size) { + pixels[i] = Color.RED + } + null + }.`when`(bitmap).getPixels(any(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt()) + + return bitmap + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/processor/DiskIconProcessorTest.kt b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/processor/DiskIconProcessorTest.kt new file mode 100644 index 0000000000..c90b522261 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/processor/DiskIconProcessorTest.kt @@ -0,0 +1,117 @@ +/* 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 mozilla.components.browser.icons.processor + +import mozilla.components.browser.icons.Icon +import mozilla.components.browser.icons.IconRequest +import mozilla.components.support.test.any +import mozilla.components.support.test.eq +import mozilla.components.support.test.mock +import org.junit.Test +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` + +class DiskIconProcessorTest { + @Test + fun `Generated icons are not saved in cache`() { + val icon = Icon(mock(), source = Icon.Source.GENERATOR) + val cache: DiskIconProcessor.ProcessorDiskCache = mock() + + val processor = DiskIconProcessor(cache) + processor.process(mock(), mock(), mock(), icon, mock()) + + verify(cache, never()).putIcon(any(), any(), eq(icon)) + } + + @Test + fun `Icon loaded from memory cache are not saved in cache`() { + val icon = Icon(mock(), source = Icon.Source.MEMORY) + val cache: DiskIconProcessor.ProcessorDiskCache = mock() + + val processor = DiskIconProcessor(cache) + processor.process(mock(), mock(), mock(), icon, mock()) + + verify(cache, never()).putIcon(any(), any(), eq(icon)) + } + + @Test + fun `Icon loaded from disk cache are not saved in cache`() { + val icon = Icon(mock(), source = Icon.Source.DISK) + val cache: DiskIconProcessor.ProcessorDiskCache = mock() + + val processor = DiskIconProcessor(cache) + processor.process(mock(), mock(), mock(), icon, mock()) + + verify(cache, never()).putIcon(any(), any(), eq(icon)) + } + + @Test + fun `Downloaded icon is saved in cache`() { + val icon = Icon(mock(), source = Icon.Source.DOWNLOAD) + val cache: DiskIconProcessor.ProcessorDiskCache = mock() + + val processor = DiskIconProcessor(cache) + val request: IconRequest = mock() + val resource: IconRequest.Resource = mock() + processor.process(mock(), request, resource, icon, mock()) + + verify(cache).putIcon(any(), eq(resource), eq(icon)) + } + + @Test + fun `Inlined icon is saved in cache`() { + val icon = Icon(mock(), source = Icon.Source.INLINE) + val cache: DiskIconProcessor.ProcessorDiskCache = mock() + + val processor = DiskIconProcessor(cache) + val request: IconRequest = mock() + val resource: IconRequest.Resource = mock() + processor.process(mock(), request, resource, icon, mock()) + + verify(cache).putIcon(any(), eq(resource), eq(icon)) + } + + @Test + fun `Icon without resource is not saved in cache`() { + val icon = Icon(mock(), source = Icon.Source.MEMORY) + val cache: DiskIconProcessor.ProcessorDiskCache = mock() + + val processor = DiskIconProcessor(cache) + processor.process(context = mock(), request = mock(), resource = null, icon = icon, desiredSize = mock()) + + verify(cache, never()).putIcon(any(), any(), eq(icon)) + } + + @Test + fun `Icon loaded in private mode is not saved in cache`() { + /* Can be Source.INLINE as well. To ensure that the icon is eligible for caching on the disk. */ + val icon = Icon(mock(), source = Icon.Source.DOWNLOAD) + val cache: DiskIconProcessor.ProcessorDiskCache = mock() + + val processor = DiskIconProcessor(cache) + val request: IconRequest = mock() + `when`(request.isPrivate).thenReturn(true) + val resource: IconRequest.Resource = mock() + processor.process(context = mock(), request = request, resource = resource, icon = icon, desiredSize = mock()) + + verify(cache, never()).putIcon(any(), any(), eq(icon)) + } + + @Test + fun `Icon loaded in non-private mode is saved in cache`() { + /* Can be Source.INLINE as well. To ensure that the icon is eligible for caching on the disk. */ + val icon = Icon(mock(), source = Icon.Source.DOWNLOAD) + val cache: DiskIconProcessor.ProcessorDiskCache = mock() + + val processor = DiskIconProcessor(cache) + val request: IconRequest = mock() + `when`(request.isPrivate).thenReturn(false) + val resource: IconRequest.Resource = mock() + processor.process(context = mock(), request = request, resource = resource, icon = icon, desiredSize = mock()) + + verify(cache).putIcon(any(), eq(resource), eq(icon)) + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/processor/MemoryIconProcessorTest.kt b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/processor/MemoryIconProcessorTest.kt new file mode 100644 index 0000000000..93b79db442 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/processor/MemoryIconProcessorTest.kt @@ -0,0 +1,85 @@ +/* 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 mozilla.components.browser.icons.processor + +import mozilla.components.browser.icons.Icon +import mozilla.components.browser.icons.IconRequest +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import org.junit.Test +import org.mockito.Mockito.never +import org.mockito.Mockito.verify + +class MemoryIconProcessorTest { + @Test + fun `Generated icons are not saved in cache`() { + val icon = Icon(mock(), source = Icon.Source.GENERATOR) + val cache: MemoryIconProcessor.ProcessorMemoryCache = mock() + + val processor = MemoryIconProcessor(cache) + processor.process(mock(), mock(), mock(), icon, mock()) + + verify(cache, never()).put(any(), any(), any()) + } + + @Test + fun `Icon loaded from memory cache are not saved in cache`() { + val icon = Icon(mock(), source = Icon.Source.MEMORY) + val cache: MemoryIconProcessor.ProcessorMemoryCache = mock() + + val processor = MemoryIconProcessor(cache) + processor.process(mock(), mock(), mock(), icon, mock()) + + verify(cache, never()).put(any(), any(), any()) + } + + @Test + fun `Icon loaded from disk cache are saved in cache`() { + val icon = Icon(mock(), source = Icon.Source.DISK) + val cache: MemoryIconProcessor.ProcessorMemoryCache = mock() + + val processor = MemoryIconProcessor(cache) + processor.process(mock(), mock(), mock(), icon, mock()) + + verify(cache).put(any(), any(), any()) + } + + @Test + fun `Downloaded icon is saved in cache`() { + val icon = Icon(mock(), source = Icon.Source.DOWNLOAD) + val cache: MemoryIconProcessor.ProcessorMemoryCache = mock() + + val processor = MemoryIconProcessor(cache) + val request: IconRequest = mock() + val resource: IconRequest.Resource = mock() + processor.process(mock(), request, resource, icon, mock()) + + verify(cache).put(request, resource, icon) + } + + @Test + fun `Inlined icon is saved in cache`() { + val icon = Icon(mock(), source = Icon.Source.INLINE) + val cache: MemoryIconProcessor.ProcessorMemoryCache = mock() + + val processor = MemoryIconProcessor(cache) + val request: IconRequest = mock() + val resource: IconRequest.Resource = mock() + processor.process(mock(), request, resource, icon, mock()) + + verify(cache).put(request, resource, icon) + } + + @Test + fun `Icon without resource is not saved in cache`() { + val icon = Icon(mock(), source = Icon.Source.MEMORY) + val cache: MemoryIconProcessor.ProcessorMemoryCache = mock() + + val processor = MemoryIconProcessor(cache) + processor.process(context = mock(), request = mock(), resource = null, icon = icon, desiredSize = mock()) + + verify(cache, never()).put(any(), any(), any()) + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/processor/ResizingProcessorTest.kt b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/processor/ResizingProcessorTest.kt new file mode 100644 index 0000000000..67c1135e25 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/processor/ResizingProcessorTest.kt @@ -0,0 +1,124 @@ +/* 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 mozilla.components.browser.icons.processor + +import android.content.Context +import android.content.res.Resources +import android.graphics.Bitmap +import android.util.DisplayMetrics +import mozilla.components.browser.icons.Icon +import mozilla.components.browser.icons.IconRequest +import mozilla.components.support.images.DesiredSize +import mozilla.components.support.test.any +import mozilla.components.support.test.eq +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.never +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify + +class ResizingProcessorTest { + private lateinit var processor: ResizingProcessor + + @Before + fun setup() { + processor = spy(ResizingProcessor()) + } + + @Test + fun `icons of the correct size are not resized`() { + val icon = Icon(mockBitmap(64), source = Icon.Source.DISK) + val resized = process(icon = icon, desiredSize = DesiredSize(64, 64, 100, 3f)) + + assertEquals(icon.bitmap, resized?.bitmap) + + verify(processor, never()).resize(any(), anyInt()) + } + + @Test + fun `larger icons are resized`() { + val icon = Icon(mockBitmap(120), source = Icon.Source.DISK) + val smallerIcon = mockBitmap(64) + doReturn(smallerIcon).`when`(processor).resize(eq(icon.bitmap), anyInt()) + val resized = process(icon = icon, desiredSize = DesiredSize(64, 64, 64, 3f)) + + assertNotEquals(icon.bitmap, resized?.bitmap) + assertEquals(smallerIcon, resized?.bitmap) + + verify(processor).resize(icon.bitmap, 64) + } + + @Test + fun `smaller icons are resized`() { + val icon = Icon(mockBitmap(34), source = Icon.Source.DISK) + val largerIcon = mockBitmap(64) + doReturn(largerIcon).`when`(processor).resize(eq(icon.bitmap), anyInt()) + val resized = process(icon = icon, desiredSize = DesiredSize(64, 64, 70, 3f)) + + assertNotEquals(icon.bitmap, resized?.bitmap) + assertEquals(largerIcon, resized?.bitmap) + + verify(processor).resize(icon.bitmap, 64) + } + + @Test + fun `smaller icons are not resized past the max scale factor`() { + val icon = Icon(mockBitmap(10), source = Icon.Source.DISK) + val largerIcon = mockBitmap(64) + doReturn(largerIcon).`when`(processor).resize(eq(icon.bitmap), anyInt()) + val resized = process(icon = icon) + + assertNotEquals(icon.bitmap, resized?.bitmap) + assertNull(resized) + } + + @Test + fun `smaller icons are resized to the max scale factor if permitted`() { + val processor = spy(ResizingProcessor(discardSmallIcons = false)) + + val icon = Icon(mockBitmap(10), source = Icon.Source.DISK) + val largerIcon = mockBitmap(64) + doReturn(largerIcon).`when`(processor).resize(eq(icon.bitmap), anyInt()) + val resized = process(processor, icon = icon) + + assertNotEquals(icon.bitmap, resized?.bitmap) + assertNotNull(resized) + + verify(processor).resize(icon.bitmap, 30) + } + + private fun process( + p: ResizingProcessor = processor, + context: Context = mockContext(2f), + request: IconRequest = IconRequest("https://mozilla.org"), + resource: IconRequest.Resource = mock(), + icon: Icon = Icon(mockBitmap(64), source = Icon.Source.DISK), + desiredSize: DesiredSize = DesiredSize(96, 96, 1000, 3f), + ) = p.process(context, request, resource, icon, desiredSize) + + private fun mockContext(density: Float): Context { + val context: Context = mock() + val resources: Resources = mock() + val displayMetrics = DisplayMetrics() + doReturn(resources).`when`(context).resources + doReturn(displayMetrics).`when`(resources).displayMetrics + displayMetrics.density = density + return context + } + + private fun mockBitmap(size: Int): Bitmap { + val bitmap: Bitmap = mock() + doReturn(size).`when`(bitmap).height + doReturn(size).`when`(bitmap).width + return bitmap + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/utils/IconDiskCacheTest.kt b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/utils/IconDiskCacheTest.kt new file mode 100644 index 0000000000..605a4bd9e2 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/utils/IconDiskCacheTest.kt @@ -0,0 +1,106 @@ +/* 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 mozilla.components.browser.icons.utils + +import android.graphics.Bitmap +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.jakewharton.disklrucache.DiskLruCache +import mozilla.components.browser.icons.IconRequest +import mozilla.components.concept.engine.manifest.Size +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.Mockito.`when` +import java.io.IOException +import java.io.OutputStream + +@RunWith(AndroidJUnit4::class) +class IconDiskCacheTest { + + @Test + fun `Writing and reading resources`() { + val cache = IconDiskCache() + + val resources = listOf( + IconRequest.Resource( + url = "https://www.mozilla.org/icon64.png", + sizes = listOf(Size(64, 64)), + mimeType = "image/png", + type = IconRequest.Resource.Type.FAVICON, + ), + IconRequest.Resource( + url = "https://www.mozilla.org/icon128.png", + sizes = listOf(Size(128, 128)), + mimeType = "image/png", + type = IconRequest.Resource.Type.FAVICON, + ), + IconRequest.Resource( + url = "https://www.mozilla.org/icon128.png", + sizes = listOf(Size(180, 180)), + type = IconRequest.Resource.Type.APPLE_TOUCH_ICON, + ), + ) + + val request = IconRequest("https://www.mozilla.org", resources = resources) + cache.putResources(testContext, request) + + val restoredResources = cache.getResources(testContext, request) + assertEquals(3, restoredResources.size) + assertEquals(resources, restoredResources) + } + + @Test + fun `Writing and reading bitmap bytes`() { + val cache = IconDiskCache() + + val resource = IconRequest.Resource( + url = "https://www.mozilla.org/icon64.png", + sizes = listOf(Size(64, 64)), + mimeType = "image/png", + type = IconRequest.Resource.Type.FAVICON, + ) + + val bitmap: Bitmap = mock() + `when`(bitmap.compress(any(), anyInt(), any())).thenAnswer { + @Suppress("DEPRECATION") + // Deprecation will be handled in https://github.com/mozilla-mobile/android-components/issues/9555 + assertEquals(Bitmap.CompressFormat.WEBP, it.arguments[0] as Bitmap.CompressFormat) + assertEquals(90, it.arguments[1] as Int) // Quality + + val stream = it.arguments[2] as OutputStream + stream.write("Hello World".toByteArray()) + true + } + + cache.putIconBitmap(testContext, resource, bitmap) + + val data = cache.getIconData(testContext, resource) + assertNotNull(data!!) + assertEquals("Hello World", String(data)) + } + + @Test + fun `Clearing cache directories catches IOException`() { + val cache = IconDiskCache() + val dataCache: DiskLruCache = mock() + val resCache: DiskLruCache = mock() + cache.iconDataCache = dataCache + cache.iconResourcesCache = resCache + + `when`(dataCache.delete()).thenThrow(IOException("test")) + `when`(resCache.delete()).thenThrow(IOException("test")) + + cache.clear(testContext) + + assertNull(cache.iconDataCache) + assertNull(cache.iconResourcesCache) + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/utils/IconMemoryCacheTest.kt b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/utils/IconMemoryCacheTest.kt new file mode 100644 index 0000000000..843fa1134c --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/utils/IconMemoryCacheTest.kt @@ -0,0 +1,62 @@ +/* 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 mozilla.components.browser.icons.utils + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.browser.icons.Icon +import mozilla.components.browser.icons.IconRequest +import mozilla.components.browser.icons.loader.IconLoader +import mozilla.components.browser.icons.loader.MemoryIconLoader +import mozilla.components.browser.icons.preparer.MemoryIconPreparer +import mozilla.components.browser.icons.processor.MemoryIconProcessor +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class IconMemoryCacheTest { + + @Test + fun `Verify memory components interaction`() { + val cache = IconMemoryCache() + + val preparer = MemoryIconPreparer(cache) + val loader = MemoryIconLoader(cache) + val processor = MemoryIconProcessor(cache) + + val icon = Icon(bitmap = mock(), source = Icon.Source.DOWNLOAD) + val resource = IconRequest.Resource( + url = "https://www.mozilla.org/favicon64.ico", + type = IconRequest.Resource.Type.FAVICON, + ) + val request = IconRequest("https://www.mozilla.org", resources = listOf(resource)) + + assertEquals(IconLoader.Result.NoResult, loader.load(mock(), request, resource)) + + // First, save something in the memory cache using the processor + processor.process(mock(), request, resource, icon, mock()) + + // Then load the same icon from the loader + val result = loader.load(mock(), request, resource) + assertTrue(result is IconLoader.Result.BitmapResult) + assertSame(icon.bitmap, (result as IconLoader.Result.BitmapResult).bitmap) + assertEquals(Icon.Source.MEMORY, result.source) + + // Prepare a new request with the same URL + val newRequest = IconRequest("https://www.mozilla.org") + assertTrue(newRequest.resources.isEmpty()) + val preparedRequest = preparer.prepare(mock(), newRequest) + assertEquals(1, preparedRequest.resources.size) + + // Load prepared request + val preparedResult = loader.load(mock(), preparedRequest, preparedRequest.resources[0]) + assertTrue(preparedResult is IconLoader.Result.BitmapResult) + assertSame(icon.bitmap, (preparedResult as IconLoader.Result.BitmapResult).bitmap) + assertEquals(Icon.Source.MEMORY, preparedResult.source) + } +} diff --git a/mobile/android/android-components/components/browser/icons/src/test/resources/bmp/test.bmp b/mobile/android/android-components/components/browser/icons/src/test/resources/bmp/test.bmp Binary files differnew file mode 100644 index 0000000000..340f116fc8 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/resources/bmp/test.bmp diff --git a/mobile/android/android-components/components/browser/icons/src/test/resources/gif/cat.gif b/mobile/android/android-components/components/browser/icons/src/test/resources/gif/cat.gif Binary files differnew file mode 100644 index 0000000000..935cef723c --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/resources/gif/cat.gif diff --git a/mobile/android/android-components/components/browser/icons/src/test/resources/ico/golem_favicon.ico b/mobile/android/android-components/components/browser/icons/src/test/resources/ico/golem_favicon.ico Binary files differnew file mode 100644 index 0000000000..e5f6fd86f4 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/resources/ico/golem_favicon.ico diff --git a/mobile/android/android-components/components/browser/icons/src/test/resources/ico/microsoft_favicon.ico b/mobile/android/android-components/components/browser/icons/src/test/resources/ico/microsoft_favicon.ico Binary files differnew file mode 100644 index 0000000000..bfe873eb22 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/resources/ico/microsoft_favicon.ico diff --git a/mobile/android/android-components/components/browser/icons/src/test/resources/ico/nvidia_favicon.ico b/mobile/android/android-components/components/browser/icons/src/test/resources/ico/nvidia_favicon.ico Binary files differnew file mode 100644 index 0000000000..424df87200 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/resources/ico/nvidia_favicon.ico diff --git a/mobile/android/android-components/components/browser/icons/src/test/resources/jpg/tonys.jpg b/mobile/android/android-components/components/browser/icons/src/test/resources/jpg/tonys.jpg Binary files differnew file mode 100644 index 0000000000..d1d34e6b4a --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/resources/jpg/tonys.jpg diff --git a/mobile/android/android-components/components/browser/icons/src/test/resources/misc/test.txt b/mobile/android/android-components/components/browser/icons/src/test/resources/misc/test.txt new file mode 100644 index 0000000000..c57eff55eb --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/resources/misc/test.txt @@ -0,0 +1 @@ +Hello World!
\ No newline at end of file diff --git a/mobile/android/android-components/components/browser/icons/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/browser/icons/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000000..49324d83c5 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1,3 @@ +mock-maker-inline +// This allows mocking final classes (classes are final by default in Kotlin) + diff --git a/mobile/android/android-components/components/browser/icons/src/test/resources/png/mozac.png b/mobile/android/android-components/components/browser/icons/src/test/resources/png/mozac.png Binary files differnew file mode 100644 index 0000000000..2a03203476 --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/resources/png/mozac.png diff --git a/mobile/android/android-components/components/browser/icons/src/test/resources/robolectric.properties b/mobile/android/android-components/components/browser/icons/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..932b01b9eb --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 diff --git a/mobile/android/android-components/components/browser/icons/src/test/resources/webp/test.webp b/mobile/android/android-components/components/browser/icons/src/test/resources/webp/test.webp Binary files differnew file mode 100644 index 0000000000..f0e226f0ae --- /dev/null +++ b/mobile/android/android-components/components/browser/icons/src/test/resources/webp/test.webp |