summaryrefslogtreecommitdiffstats
path: root/mobile/android/android-components/components/browser/icons
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:35:49 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:35:49 +0000
commitd8bbc7858622b6d9c278469aab701ca0b609cddf (patch)
treeeff41dc61d9f714852212739e6b3738b82a2af87 /mobile/android/android-components/components/browser/icons
parentReleasing progress-linux version 125.0.3-1~progress7.99u1. (diff)
downloadfirefox-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')
-rw-r--r--mobile/android/android-components/components/browser/icons/.gitignore1
-rw-r--r--mobile/android/android-components/components/browser/icons/README.md19
-rw-r--r--mobile/android/android-components/components/browser/icons/build.gradle92
-rw-r--r--mobile/android/android-components/components/browser/icons/proguard-rules.pro21
-rw-r--r--mobile/android/android-components/components/browser/icons/src/androidTest/java/mozilla/components/browser/icons/OnDeviceBrowserIconsTest.kt70
-rw-r--r--mobile/android/android-components/components/browser/icons/src/androidTest/java/mozilla/components/browser/icons/decoder/ICOIconDecoderTest.kt129
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/AndroidManifest.xml4
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/assets/extensions/browser-icons/icons.js81
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/assets/extensions/browser-icons/manifest.template.json22
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/assets/mozac.browser.icons/icons-top200.json1056
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/BrowserIcons.kt476
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/Icon.kt52
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconRequest.kt140
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/compose/IconLoaderScope.kt59
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/compose/IconLoaderState.kt35
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/compose/Loader.kt50
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/decoder/ICOIconDecoder.kt42
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/decoder/SvgIconDecoder.kt108
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/decoder/ico/IconDirectoryEntry.kt315
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/extension/IconMessage.kt118
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/extension/IconMessageHandler.kt53
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/extension/WebAppManifest.kt49
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/generator/DefaultIconGenerator.kt106
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/generator/IconGenerator.kt18
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/DataUriIconLoader.kt36
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/DiskIconLoader.kt26
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/HttpIconLoader.kt116
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/IconLoader.kt34
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/MemoryIconLoader.kt27
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/NonBlockingHttpIconLoader.kt43
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/pipeline/IconResourceComparator.kt75
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparer/DiskIconPreparer.kt32
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparer/IconPreprarer.kt16
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparer/MemoryIconPreparer.kt29
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparer/TippyTopIconPreparer.kt89
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/processor/AdaptiveIconProcessor.kt76
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/processor/ColorProcessor.kt37
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/processor/DiskIconProcessor.kt52
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/processor/IconProcessor.kt24
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/processor/MemoryIconProcessor.kt42
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/processor/ResizingProcessor.kt67
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/utils/IconDiskCache.kt185
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/utils/IconMemoryCache.kt62
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/utils/Utils.kt64
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/res/values/colors.xml18
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/res/values/dimens.xml13
-rw-r--r--mobile/android/android-components/components/browser/icons/src/main/res/values/tags.xml7
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/BrowserIconsTest.kt339
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/decoder/ICOIconDecoderTest.kt238
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/decoder/SvgIconDecoderTest.kt156
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/extension/IconMessageHandlerTest.kt223
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/extension/IconMessageKtTest.kt62
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/generator/DefaultIconGeneratorTest.kt66
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/DataUriIconLoaderTest.kt93
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/DiskIconLoaderTest.kt60
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/FailureCacheTest.kt51
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/HttpIconLoaderTest.kt273
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/MemoryIconLoaderTest.kt62
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/NonBlockingHttpIconLoaderTest.kt318
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/pipeline/IconResourceComparatorTest.kt354
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/preparer/DiskIconPreparerTest.kt69
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/preparer/MemoryIconPreparerTest.kt69
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/preparer/TippyTopIconPreparerTest.kt110
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/processor/AdaptiveIconProcessorTest.kt89
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/processor/ColorProcessorTest.kt53
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/processor/DiskIconProcessorTest.kt117
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/processor/MemoryIconProcessorTest.kt85
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/processor/ResizingProcessorTest.kt124
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/utils/IconDiskCacheTest.kt106
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/utils/IconMemoryCacheTest.kt62
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/resources/bmp/test.bmpbin0 -> 30122 bytes
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/resources/gif/cat.gifbin0 -> 844849 bytes
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/resources/ico/golem_favicon.icobin0 -> 40648 bytes
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/resources/ico/microsoft_favicon.icobin0 -> 17174 bytes
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/resources/ico/nvidia_favicon.icobin0 -> 25214 bytes
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/resources/jpg/tonys.jpgbin0 -> 83782 bytes
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/resources/misc/test.txt1
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker3
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/resources/png/mozac.pngbin0 -> 406 bytes
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/resources/robolectric.properties1
-rw-r--r--mobile/android/android-components/components/browser/icons/src/test/resources/webp/test.webpbin0 -> 2010 bytes
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
new file mode 100644
index 0000000000..340f116fc8
--- /dev/null
+++ b/mobile/android/android-components/components/browser/icons/src/test/resources/bmp/test.bmp
Binary files differ
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
new file mode 100644
index 0000000000..935cef723c
--- /dev/null
+++ b/mobile/android/android-components/components/browser/icons/src/test/resources/gif/cat.gif
Binary files differ
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
new file mode 100644
index 0000000000..e5f6fd86f4
--- /dev/null
+++ b/mobile/android/android-components/components/browser/icons/src/test/resources/ico/golem_favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..bfe873eb22
--- /dev/null
+++ b/mobile/android/android-components/components/browser/icons/src/test/resources/ico/microsoft_favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..424df87200
--- /dev/null
+++ b/mobile/android/android-components/components/browser/icons/src/test/resources/ico/nvidia_favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..d1d34e6b4a
--- /dev/null
+++ b/mobile/android/android-components/components/browser/icons/src/test/resources/jpg/tonys.jpg
Binary files differ
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
new file mode 100644
index 0000000000..2a03203476
--- /dev/null
+++ b/mobile/android/android-components/components/browser/icons/src/test/resources/png/mozac.png
Binary files differ
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
new file mode 100644
index 0000000000..f0e226f0ae
--- /dev/null
+++ b/mobile/android/android-components/components/browser/icons/src/test/resources/webp/test.webp
Binary files differ