diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index 40b08699d3c..d1936c792b0 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -46,6 +46,7 @@ object Deps { const val support_fragment = "com.android.support:support-fragment:${Versions.support_libraries}" const val support_constraintlayout = "com.android.support.constraint:constraint-layout:${Versions.constraint_layout}" const val support_compat = "com.android.support:support-compat:${Versions.support_libraries}" + const val support_palette = "com.android.support:palette-v7:${Versions.support_libraries}" const val arch_workmanager = "android.arch.work:work-runtime:${Versions.workmanager}" diff --git a/components/browser/icons/README.md b/components/browser/icons/README.md new file mode 100644 index 00000000000..9437e8ae53d --- /dev/null +++ b/components/browser/icons/README.md @@ -0,0 +1,23 @@ +# [Android Components](../../../README.md) > Browser > Icons + +Favicons support for Android browser apps. + +## Usage + +### Setting up the dependency + +Use gradle to download the library from JCenter: + +```Groovy +implementation "org.mozilla.components:browser-icons:{latest-version}" +``` +### Quick Start + +(TBD) + +## 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/components/browser/icons/build.gradle b/components/browser/icons/build.gradle new file mode 100644 index 00000000000..ea6c08dcf56 --- /dev/null +++ b/components/browser/icons/build.gradle @@ -0,0 +1,61 @@ +/* + * 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 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 { + compileSdkVersion Config.compileSdkVersion + + defaultConfig { + minSdkVersion Config.minSdkVersion + targetSdkVersion Config.targetSdkVersion + + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + } + + lintOptions { + // Lint reports (falsely) a bunch of unused resources for this project. Those resources + // are references from Kotlin code and it looks like Android lint can't see those references + // yet. Let's disable this check and retry once a newer SDK is available. + disable 'UnusedResources' + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + +} + +dependencies { + implementation project(':support-ktx') + implementation project(':support-base') + implementation project(':support-utils') + + implementation Deps.support_annotations + implementation Deps.kotlin_stdlib + + testImplementation project(':support-test') + + testImplementation Deps.testing_junit + testImplementation Deps.testing_robolectric + testImplementation Deps.testing_mockito +} + +archivesBaseName = "icons" + +apply from: '../../../publish.gradle' +ext.configurePublish( + 'org.mozilla.components', + 'icons', + 'Favicons support for Android browser apps.') diff --git a/components/browser/icons/proguard-rules.pro b/components/browser/icons/proguard-rules.pro new file mode 100644 index 00000000000..f1b424510da --- /dev/null +++ b/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/components/browser/icons/src/main/AndroidManifest.xml b/components/browser/icons/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..0721810803e --- /dev/null +++ b/components/browser/icons/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconCallback.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconCallback.java new file mode 100644 index 00000000000..5b7af71340f --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconCallback.java @@ -0,0 +1,25 @@ +/* + * 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 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/. + */ + +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * 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; + +/** + * Interface for a callback that will be executed once an icon has been loaded successfully. + */ +public interface IconCallback { + void onIconResponse(IconResponse response); +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconDescriptor.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconDescriptor.java new file mode 100644 index 00000000000..6c4997cdd17 --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconDescriptor.java @@ -0,0 +1,125 @@ +/* + * 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 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/. + */ + +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * 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.support.annotation.IntDef; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; + +/** + * A class describing the location and properties of an icon that can be loaded. + */ +public class IconDescriptor { + @IntDef({ TYPE_GENERIC, TYPE_FAVICON, TYPE_TOUCHICON, TYPE_LOOKUP, TYPE_BUNDLED_TILE }) + @interface IconType {} + + // The type values are used for ranking icons (higher values = try to load first). + @VisibleForTesting + static final int TYPE_GENERIC = 0; + @VisibleForTesting + static final int TYPE_LOOKUP = 1; + @VisibleForTesting + static final int TYPE_FAVICON = 5; + @VisibleForTesting + static final int TYPE_TOUCHICON = 10; + @VisibleForTesting + static final int TYPE_BUNDLED_TILE = 15; + + private final String url; + private final int size; + private final String mimeType; + private final int type; + + /** + * Create a generic icon located at the given URL. No MIME type or size is known. + */ + public static IconDescriptor createGenericIcon(@NonNull String url) { + return new IconDescriptor(TYPE_GENERIC, url, 0, null); + } + + /** + * Create a favicon located at the given URL and with a known size and MIME type. + */ + public static IconDescriptor createFavicon(@NonNull String url, int size, String mimeType) { + return new IconDescriptor(TYPE_FAVICON, url, size, mimeType); + } + + /** + * Create a touch icon located at the given URL and with a known MIME type and size. + */ + public static IconDescriptor createTouchicon(@NonNull String url, int size, String mimeType) { + return new IconDescriptor(TYPE_TOUCHICON, url, size, mimeType); + } + + /** + * Create an icon located at an URL that has been returned from a disk or memory storage. This + * is an icon with an URL we loaded an icon from previously. Therefore we give it a little higher + * ranking than a generic icon - even though we do not know the MIME type or size of the icon. + */ + public static IconDescriptor createLookupIcon(@NonNull String url) { + return new IconDescriptor(TYPE_LOOKUP, url, 0, null); + } + + /** + * Create a bundled tile icon at the given URL. MIME type or size is not known until we load + * the icons, but we know these icons are high fidelity. (Although the icons are png's at time + * of writing, they could be changed to webp or VectorDrawable in future.) + */ + public static IconDescriptor createBundledTileIcon(@NonNull String url) { + return new IconDescriptor(TYPE_BUNDLED_TILE, url, 0, null); + } + + + private IconDescriptor(@IconType int type, @NonNull String url, int size, String mimeType) { + this.type = type; + this.url = url; + this.size = size; + this.mimeType = mimeType; + } + + /** + * Get the URL of the icon. + */ + public String getUrl() { + return url; + } + + /** + * Get the (assumed) size of the icon. Returns 0 if no size is known. + */ + public int getSize() { + return size; + } + + /** + * Get the type of the icon (favicon, touch icon, generic, lookup). + */ + @IconType + public int getType() { + return type; + } + + /** + * Get the (assumed) MIME type of the icon. Returns null if no MIME type is known. + */ + @Nullable + public String getMimeType() { + return mimeType; + } +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconDescriptorComparator.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconDescriptorComparator.java new file mode 100644 index 00000000000..582d0437f71 --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconDescriptorComparator.java @@ -0,0 +1,79 @@ +/* + * 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 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/. + */ + +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * 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 java.util.Comparator; + +/** + * This comparator implementation compares IconDescriptor objects in order to determine which icon + * to load first. + * + * In general this comparator will try touch icons before favicons (they usually have a higher resolution) + * and prefers larger icons over smaller ones. + */ +/* package-private */ class IconDescriptorComparator implements Comparator { + @Override + public int compare(final IconDescriptor lhs, final IconDescriptor rhs) { + if (lhs.getUrl().equals(rhs.getUrl())) { + // Two descriptors pointing to the same URL are always referencing the same icon. So treat + // them as equal. + return 0; + } + + // First compare the types. We prefer touch icons because they tend to have a higher resolution + // than ordinary favicons. + if (lhs.getType() != rhs.getType()) { + return compareType(lhs, rhs); + } + + // If one of them is larger than pick the larger icon. + if (lhs.getSize() != rhs.getSize()) { + return compareSizes(lhs, rhs); + } + + // If there's no other way to choose, we prefer container types. They *might* contain + // an image larger than the size given in the tag. + final boolean lhsContainer = IconsHelper.isContainerType(lhs.getMimeType()); + final boolean rhsContainer = IconsHelper.isContainerType(rhs.getMimeType()); + + if (lhsContainer != rhsContainer) { + return lhsContainer ? -1 : 1; + } + + // There's no way to know which icon might be better. However we need to pick a consistent + // one to avoid breaking the TreeSet implementation (See Bug 1331808). Therefore we are + // picking one by just comparing the URLs. + return lhs.getUrl().compareTo(rhs.getUrl()); + } + + private int compareType(IconDescriptor lhs, IconDescriptor rhs) { + if (lhs.getType() > rhs.getType()) { + return -1; + } else { + return 1; + } + } + + private int compareSizes(IconDescriptor lhs, IconDescriptor rhs) { + if (lhs.getSize() > rhs.getSize()) { + return -1; + } else { + return 1; + } + } +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconRequest.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconRequest.java new file mode 100644 index 00000000000..f77a8f7a1e7 --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconRequest.java @@ -0,0 +1,227 @@ +/* + * 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 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/. + */ + +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * 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 android.support.annotation.VisibleForTesting; + +import java.util.Iterator; +import java.util.TreeSet; +import java.util.concurrent.Future; + +/** + * A class describing a request to load an icon for a website. + */ +public class IconRequest { + private Context context; + + // Those values are written by the IconRequestBuilder class. + /* package-private */ String pageUrl; + /* package-private */ boolean privileged; + /* package-private */ boolean isPrivate; + /* package-private */ TreeSet icons; + /* package-private */ boolean skipNetwork; + /* package-private */ boolean backgroundThread; + /* package-private */ boolean skipDisk; + /* package-private */ boolean skipMemory; + /* package-private */ int targetSize; + /* package-private */ int minimumSizePxAfterScaling; + /* package-private */ boolean prepareOnly; + /* package-private */ float textSize; + private IconCallback callback; + + /* package-private */ IconRequest(Context context) { + this.context = context.getApplicationContext(); + this.icons = new TreeSet<>(new IconDescriptorComparator()); + + // Setting some sensible defaults. + this.privileged = false; + this.isPrivate = false; + this.skipMemory = false; + this.skipDisk = false; + this.skipNetwork = false; + this.targetSize = context.getResources().getDimensionPixelSize(R.dimen.favicon_bg); + this.minimumSizePxAfterScaling = 0; + this.prepareOnly = false; + + // textSize is only used in IconGenerator.java for creating a icon with specific text size. + this.textSize = 0; + } + + /** + * Execute this request and try to load an icon. Once an icon has been loaded successfully the + * callback will be executed. + * + * The returned Future can be used to cancel the job. + */ + public Future execute(IconCallback callback) { + setCallback(callback); + + return IconRequestExecutor.submit(this); + } + + @VisibleForTesting + void setCallback(IconCallback callback) { + this.callback = callback; + } + + /** + * Get the (application) context associated with this request. + */ + public Context getContext() { + return context; + } + + /** + * Get the descriptor for the potentially best icon. This is the icon that should be loaded if + * possible. + */ + public IconDescriptor getBestIcon() { + return icons.first(); + } + + /** + * Get the URL of the page for which an icon should be loaded. + */ + public String getPageUrl() { + return pageUrl; + } + + /** + * Is this request allowed to load icons from internal data sources like the omni.ja? + */ + public boolean isPrivileged() { + return privileged; + } + + /** + * Is this request initiated from a tab in private browsing mode? + */ + public boolean isPrivateMode() { + return isPrivate; + } + + /** + * Get the number of icon descriptors associated with this request. + */ + public int getIconCount() { + return icons.size(); + } + + /** + * Get the required target size of the icon. + */ + public int getTargetSize() { + return targetSize; + } + + /** + * Gets the minimum size the icon can be before we substitute a generated icon. + * + * N.B. the minimum size is compared to the icon *after* scaling: consider using + * {@link mozilla.components.browser.icons.processing.ResizingProcessor#MAX_SCALE_FACTOR} + * when setting this value. + */ + public int getMinimumSizePxAfterScaling() { + return minimumSizePxAfterScaling; + } + + /** + * Should a loader access the network to load this icon? + */ + public boolean shouldSkipNetwork() { + return skipNetwork; + } + + /** + * Should a loader access the disk to load this icon? + */ + public boolean shouldSkipDisk() { + return skipDisk; + } + + /** + * Should a loader access the memory cache to load this icon? + */ + public boolean shouldSkipMemory() { + return skipMemory; + } + + /** + * Get an iterator to iterate over all icon descriptors associated with this request. + */ + public Iterator getIconIterator() { + return icons.iterator(); + } + + /** + * Get the required text size of the icon created by + * {@link mozilla.components.browser.icons.loader.IconGenerator}. + */ + public float getTextSize() { + return textSize; + } + + /** + * Create a builder to modify this request. + * + * Calling methods on the builder will modify this object and not create a copy. + */ + public IconRequestBuilder modify() { + return new IconRequestBuilder(this); + } + + /** + * Should the callback be executed on a background thread? By default a callback is always + * executed on the UI thread because an icon is usually loaded in order to display it somewhere + * in the UI. + */ + /* package-private */ boolean shouldRunOnBackgroundThread() { + return backgroundThread; + } + + /* package-private */ IconCallback getCallback() { + return callback; + } + + /* package-private */ boolean hasIconDescriptors() { + return !icons.isEmpty(); + } + + /** + * Move to the next icon. This method is called after all loaders for the current best icon + * have failed. After calling this method getBestIcon() will return the next icon to try. + * hasIconDescriptors() should be called before requesting the next icon. + */ + /* + package-private */ void moveToNextIcon() { + if (!icons.remove(getBestIcon())) { + // Calling this method when there's no next icon is an error (use hasIconDescriptors()). + // Theoretically this method can fail even if there's a next icon (like it did in bug 1331808). + // In this case crashing to see and fix the issue is desired. + throw new IllegalStateException("Moving to next icon failed. Could not remove first icon from set."); + } + } + + /** + * Should this request be prepared but not actually load an icon? + */ + /* package-private */ boolean shouldPrepareOnly() { + return prepareOnly; + } +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconRequestBuilder.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconRequestBuilder.java new file mode 100644 index 00000000000..659d9cde85a --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconRequestBuilder.java @@ -0,0 +1,225 @@ +/* + * 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 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/. + */ + +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * 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.app.ActivityManager; +import android.content.Context; +import android.support.annotation.CheckResult; +import android.text.TextUtils; +import mozilla.components.browser.icons.processing.ResizingProcessor; + +import java.util.TreeSet; + +/** + * Builder for creating a request to load an icon. + */ +public class IconRequestBuilder { + private final IconRequest internal; + + /* package-private */ IconRequestBuilder(Context context) { + internal = new IconRequest(context); + } + + /* package-private */ IconRequestBuilder(IconRequest request) { + internal = request; + } + + /** + * Set the URL of the page for which the icon should be loaded. + */ + @CheckResult + public IconRequestBuilder pageUrl(String pageUrl) { + internal.pageUrl = pageUrl; + return this; + } + + /** + * Set whether this request is allowed to load icons from non http(s) URLs (e.g. the omni.ja). + * + * For example web content referencing internal URLs should not lead to us loading icons from + * internal data structures like the omni.ja. + */ + @CheckResult + public IconRequestBuilder privileged(boolean privileged) { + internal.privileged = privileged; + return this; + } + + /** + * Add an icon descriptor describing the location and properties of an icon. All descriptors + * will be ranked and tried in order of their rank. Executing the request will modify the list + * of icons (filter or add additional descriptors). + */ + @CheckResult + public IconRequestBuilder icon(IconDescriptor descriptor) { + internal.icons.add(descriptor); + return this; + } + + /** + * Set the private mode to avoid saving the result to the disk. + */ + @CheckResult + public IconRequestBuilder setPrivateMode(boolean isPrivate) { + internal.isPrivate = isPrivate; + return this; + } + + /** + * Skip the network and do not load an icon from a network connection. + */ + @CheckResult + public IconRequestBuilder skipNetwork() { + internal.skipNetwork = true; + return this; + } + + /** + * If shouldSkipNetwork is true then do not load icon from a network connection. + */ + @CheckResult + public IconRequestBuilder skipNetworkIf(boolean shouldSkipNetwork) { + internal.skipNetwork = shouldSkipNetwork; + return this; + } + + /** + * Skip the disk cache and do not load an icon from disk. + */ + @CheckResult + public IconRequestBuilder skipDisk() { + internal.skipDisk = true; + return this; + } + + /** + * Skip the memory cache and do not return a previously loaded icon. + */ + @CheckResult + public IconRequestBuilder skipMemory() { + internal.skipMemory = true; + return this; + } + + /** + * If shouldSkipMemory is true then skip the memory cache and do not return + * a previously loaded icon. + */ + @CheckResult + public IconRequestBuilder skipMemoryIf(boolean shouldSkipMemory) { + internal.skipMemory = shouldSkipMemory; + return this; + } + + /** + * The icon will be used as (Android) launcher icon. The loaded icon will be scaled to the + * preferred Android launcher icon size. + */ + public IconRequestBuilder forLauncherIcon() { + ActivityManager am = (ActivityManager) internal.getContext().getSystemService(Context.ACTIVITY_SERVICE); + internal.targetSize = am.getLauncherLargeIconSize(); + return this; + } + + /** + * The icon will be scaled to the given size. + */ + public IconRequestBuilder targetSize(final int targetSize) { + internal.targetSize = targetSize; + return this; + } + + /** + * The icon will be used in Activity Stream: a minimum size for the icon will be set. + */ + public IconRequestBuilder forActivityStream() { + // This value was set anecdotally: 16px icons scaled up both look blurry and + // don't fill the space well. 32px icons look good enough. + internal.minimumSizePxAfterScaling = 32 * ResizingProcessor.MAX_SCALE_FACTOR; + return this; + } + + /** + * Execute the callback on the background thread. By default the callback is always executed on + * the UI thread in order to add the loaded icon to a view easily. + */ + @CheckResult + public IconRequestBuilder executeCallbackOnBackgroundThread() { + internal.backgroundThread = true; + return this; + } + + /** + * When executing the request then only prepare executing it but do not actually load an icon. + * This mode is only used for some legacy code that uses the icon URL and therefore needs to + * perform a lookup of the URL but doesn't want to load the icon yet. + */ + public IconRequestBuilder prepareOnly() { + internal.prepareOnly = true; + return this; + } + + /** + * The text size will be resized to the given size, and this field is only used by + * {@link mozilla.components.browser.icons.loader.IconGenerator} for creating a new icon. + */ + public IconRequestBuilder textSize(final float textSize) { + internal.textSize = textSize; + return this; + } + + /** + * Return the request built with this builder. + */ + @CheckResult + public IconRequest build() { + if (TextUtils.isEmpty(internal.pageUrl)) { + throw new IllegalStateException("Page URL is required"); + } + + IconRequest request = new IconRequest(internal.getContext()); + request.pageUrl = internal.pageUrl; + request.privileged = internal.privileged; + request.isPrivate = internal.isPrivate; + request.icons = new TreeSet<>(internal.icons); + request.skipNetwork = internal.skipNetwork; + request.backgroundThread = internal.backgroundThread; + request.skipDisk = internal.skipDisk; + request.skipMemory = internal.skipMemory; + request.targetSize = internal.targetSize; + request.minimumSizePxAfterScaling = internal.minimumSizePxAfterScaling; + request.prepareOnly = internal.prepareOnly; + request.textSize = internal.textSize; + return request; + } + + /** + * This is a no-op method. + * + * All builder methods are annotated with @CheckResult to denote that the + * methods return the builder object and that it is typically an error to not call another method + * on it until eventually calling build(). + * + * However in some situations code can keep a reference + * to the builder object and call methods only when a specific event occurs. To make this explicit + * and avoid lint errors this method can be called. + */ + public void deferBuild() { + // No op + } +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconRequestExecutor.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconRequestExecutor.java new file mode 100644 index 00000000000..5e80642bd72 --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconRequestExecutor.java @@ -0,0 +1,152 @@ +/* + * 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 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/. + */ + +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * 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.support.annotation.NonNull; +import mozilla.components.browser.icons.loader.*; +import mozilla.components.browser.icons.preparation.*; +import mozilla.components.browser.icons.processing.*; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.*; + +/** + * Executor for icon requests. + */ +/* package-private */ class IconRequestExecutor { + /** + * Loader implementation that generates an icon if none could be loaded. + */ + private static final IconLoader GENERATOR = new IconGenerator(); + + /** + * Ordered list of prepares that run before any icon is loaded. + */ + private static final List PREPARERS = Arrays.asList( + // First we look into our memory and disk caches if there are some known icon URLs for + // the page URL of the request. + new LookupIconUrl(), + + // For all icons with MIME type we filter entries with unknown MIME type that we probably + // cannot decode anyways. + new FilterMimeTypes(), + + // If this is not a request that is allowed to load icons from privileged locations (omni.jar) + // then filter such icon URLs. + new FilterPrivilegedUrls(), + + // This preparer adds an icon URL for about pages. It's added after the filter for privileged + // URLs. We always want to be able to load those specific icons. + //new AboutPagesPreparer(), + + // Suggested sites have icons bundled in the app - we should use them until the user has + // visited a specified page (after which the standard icon lookup will generally provide + // an update icon. + //new SuggestedSitePreparer(), + + // Add the default favicon URL (*/favicon.ico) to the list of icon URLs; with a low priority, + // this icon URL should be tried last. + new AddDefaultIconUrl(), + + // Finally we filter all URLs that failed to load recently (4xx / 5xx errors). + new FilterKnownFailureUrls() + ); + + /** + * Ordered list of loaders. If a loader returns a response object then subsequent loaders are not run. + */ + private static final List LOADERS = Arrays.asList( + // First we try to load an icon that is already in the memory. That's cheap. + new MemoryLoader(), + + // Try to decode the icon if it is a data: URI. + new DataUriLoader(), + + // Try to load the icon from the omni.ha if it's a jar:jar URI. + //new JarLoader(), + + // Try to load the icon from a content provider (if applicable). + new ContentProviderLoader(), + + // Try to load the icon from the disk cache. + //new DiskLoader(), + + // Try to load from the suggested site tile builder + //new SuggestedSiteLoader(), + + // If the icon is not in any of our cashes and can't be decoded then look into the + // database (legacy). Maybe this icon was loaded before the new code was deployed. + //new LegacyLoader(), + + // Download the icon from the web. + new IconDownloader() + ); + + /** + * Ordered list of processors that run after an icon has been loaded. + */ + private static final List PROCESSORS = Arrays.asList( + // Store the icon (and mapping) in the disk cache if needed + //new DiskProcessor(), + + // Resize the icon to match the target size (if possible) + new ResizingProcessor(), + + // Extract the dominant color from the icon + new ColorProcessor(), + + // Store the icon in the memory cache + new MemoryProcessor(), + + // Substitute a generated icon if the final result is not large enough. + // Expected to be called after ResizingProcessor. + new MinimumSizeProcessor() + ); + + private static final ExecutorService EXECUTOR; + static { + final ThreadFactory factory = new ThreadFactory() { + @Override + public Thread newThread(@NonNull Runnable runnable) { + Thread thread = new Thread(runnable, "GeckoIconTask"); + thread.setDaemon(false); + thread.setPriority(Thread.NORM_PRIORITY); + return thread; + } + }; + + // Single thread executor + EXECUTOR = new ThreadPoolExecutor( + 1, /* corePoolSize */ + 1, /* maximumPoolSize */ + 0L, /* keepAliveTime */ + TimeUnit.MILLISECONDS, + new LinkedBlockingQueue(), + factory); + } + + /** + * Submit the request for execution. + */ + /* package-private */ static Future submit(IconRequest request) { + return EXECUTOR.submit( + new IconTask(request, PREPARERS, LOADERS, PROCESSORS, GENERATOR) + ); + } +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconResponse.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconResponse.java new file mode 100644 index 00000000000..e8097277c98 --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconResponse.java @@ -0,0 +1,179 @@ +/* + * 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 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/. + */ + +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * 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.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.text.TextUtils; + +/** + * Response object containing a successful loaded icon and meta data. + */ +public class IconResponse { + /** + * Create a response for a plain bitmap. + */ + public static IconResponse create(@NonNull Bitmap bitmap) { + return new IconResponse(bitmap); + } + + /** + * Create a response for a bitmap that has been loaded from the network by requesting a specific URL. + */ + public static IconResponse createFromNetwork(@NonNull Bitmap bitmap, @NonNull String url) { + final IconResponse response = new IconResponse(bitmap); + response.url = url; + response.fromNetwork = true; + return response; + } + + /** + * Create a response for a generated bitmap with a dominant color. + */ + public static IconResponse createGenerated(@NonNull Bitmap bitmap, int color) { + final IconResponse response = new IconResponse(bitmap); + response.color = color; + response.generated = true; + return response; + } + + /** + * Create a response for a bitmap that has been loaded from the memory cache. + */ + public static IconResponse createFromMemory(@NonNull Bitmap bitmap, @NonNull String url, int color) { + final IconResponse response = new IconResponse(bitmap); + response.url = url; + response.color = color; + response.fromMemory = true; + return response; + } + + /** + * Create a response for a bitmap that has been loaded from the disk cache. + */ + public static IconResponse createFromDisk(@NonNull Bitmap bitmap, @NonNull String url) { + final IconResponse response = new IconResponse(bitmap); + response.url = url; + response.fromDisk = true; + return response; + } + + private Bitmap bitmap; + private int color; + private boolean fromNetwork; + private boolean fromMemory; + private boolean fromDisk; + private boolean generated; + private String url; + + private IconResponse(Bitmap bitmap) { + if (bitmap == null) { + throw new NullPointerException("Bitmap is null"); + } + + this.bitmap = bitmap; + this.color = 0; + this.url = null; + this.fromNetwork = false; + this.fromMemory = false; + this.fromDisk = false; + this.generated = false; + } + + /** + * Get the icon bitmap. This method will always return a bitmap. + */ + @NonNull + public Bitmap getBitmap() { + return bitmap; + } + + /** + * Get the dominant color of the icon. Will return 0 if no color could be extracted. + */ + public int getColor() { + return color; + } + + /** + * Does this response contain a dominant color? + */ + public boolean hasColor() { + return color != 0; + } + + /** + * Has this icon been loaded from the network? + */ + public boolean isFromNetwork() { + return fromNetwork; + } + + /** + * Has this icon been generated? + */ + public boolean isGenerated() { + return generated; + } + + /** + * Has this icon been loaded from memory (cache)? + */ + public boolean isFromMemory() { + return fromMemory; + } + + /** + * Has this icon been loaded from disk (cache)? + */ + public boolean isFromDisk() { + return fromDisk; + } + + /** + * Get the URL this icon has been loaded from. + */ + @Nullable + public String getUrl() { + return url; + } + + /** + * Does this response contain an URL from which the icon has been loaded? + */ + public boolean hasUrl() { + return !TextUtils.isEmpty(url); + } + + /** + * Update the color of this response. This method is called by processors updating meta data + * after the icon has been loaded. + */ + public void updateColor(int color) { + this.color = color; + } + + /** + * Update the bitmap of this response. This method is called by processors that modify the + * loaded icon. + */ + public void updateBitmap(Bitmap bitmap) { + this.bitmap = bitmap; + } +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconTask.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconTask.java new file mode 100644 index 00000000000..5c3bc1b8c38 --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconTask.java @@ -0,0 +1,236 @@ +/* + * 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 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/. + */ + +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- +/* 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.support.annotation.NonNull; +import mozilla.components.browser.icons.loader.IconLoader; +import mozilla.components.browser.icons.preparation.Preparer; +import mozilla.components.browser.icons.processing.Processor; +import mozilla.components.support.base.log.logger.Logger; +import mozilla.components.support.utils.ThreadUtils; + +import java.util.List; +import java.util.concurrent.Callable; + +/** + * Task that will be run by the IconRequestExecutor for every icon request. + */ +/* package-private */ class IconTask implements Callable { + private static final String LOGTAG = "Gecko/IconTask"; + private static final boolean DEBUG = false; + + private final List preparers; + private final List loaders; + private final List processors; + private final IconLoader generator; + private final IconRequest request; + + /* package-private */ IconTask( + @NonNull IconRequest request, + @NonNull List preparers, + @NonNull List loaders, + @NonNull List processors, + @NonNull IconLoader generator) { + this.request = request; + this.preparers = preparers; + this.loaders = loaders; + this.processors = processors; + this.generator = generator; + } + + @Override + public IconResponse call() { + try { + logRequest(request); + + prepareRequest(request); + + if (request.shouldPrepareOnly()) { + // This request should only be prepared but not load an actual icon. + return null; + } + + final IconResponse response = loadIcon(request); + + if (response != null) { + processIcon(request, response); + executeCallback(request, response); + + logResponse(response); + + return response; + } + } catch (InterruptedException e) { + Logger.Companion.debug("IconTask was interrupted", e); + + // Clear interrupt thread. + Thread.interrupted(); + } catch (Throwable e) { + handleException(e); + } + + return null; + } + + /** + * Check if this thread was interrupted (e.g. this task was cancelled). Throws an InterruptedException + * to stop executing the task in this case. + */ + private void ensureNotInterrupted() throws InterruptedException { + if (Thread.currentThread().isInterrupted()) { + throw new InterruptedException("Task has been cancelled"); + } + } + + private void executeCallback(IconRequest request, final IconResponse response) { + final IconCallback callback = request.getCallback(); + + if (callback != null) { + if (request.shouldRunOnBackgroundThread()) { + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + callback.onIconResponse(response); + } + }); + } else { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + callback.onIconResponse(response); + } + }); + } + } + } + + private void prepareRequest(IconRequest request) throws InterruptedException { + for (Preparer preparer : preparers) { + ensureNotInterrupted(); + + preparer.prepare(request); + + logPreparer(request, preparer); + } + } + + private IconResponse loadIcon(IconRequest request) throws InterruptedException { + while (request.hasIconDescriptors()) { + for (IconLoader loader : loaders) { + ensureNotInterrupted(); + + IconResponse response = loader.load(request); + + logLoader(request, loader, response); + + if (response != null) { + return response; + } + } + + request.moveToNextIcon(); + } + + return generator.load(request); + } + + private void processIcon(IconRequest request, IconResponse response) throws InterruptedException { + for (Processor processor : processors) { + ensureNotInterrupted(); + + processor.process(request, response); + + logProcessor(processor); + } + } + + private void handleException(final Throwable t) { + if (DEBUG) { + // TODO: Confirm that this new logic should be. + //if (AppConstants.NIGHTLY_BUILD) { + // We want to be aware of problems: Let's re-throw the exception on the main thread to + // force an app crash. However we only do this in Nightly builds. Every other build + // (especially release builds) should just carry on and log the error. + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + throw new RuntimeException("Icon task thread crashed", t); + } + }); + } else { + Logger.Companion.error("Icon task crashed", t); + } + } + + private boolean shouldLog() { + // Do not log anything if debugging is disabled and never log anything in a non-nightly build. + // TODO: Confirm that this new logic should be. + //return DEBUG && AppConstants.NIGHTLY_BUILD; + return DEBUG; + } + + private void logPreparer(IconRequest request, Preparer preparer) { + if (!shouldLog()) { + return; + } + + Logger.Companion.debug(String.format(" PREPARE %s" + " (%s)", + preparer.getClass().getSimpleName(), + request.getIconCount()), null); + } + + private void logLoader(IconRequest request, IconLoader loader, IconResponse response) { + if (!shouldLog()) { + return; + } + + Logger.Companion.debug(String.format(" LOAD [%s] %s : %s", + response != null ? "X" : " ", + loader.getClass().getSimpleName(), + request.getBestIcon().getUrl()), null); + } + + private void logProcessor(Processor processor) { + if (!shouldLog()) { + return; + } + + Logger.Companion.debug(" PROCESS " + processor.getClass().getSimpleName(), null); + } + + private void logResponse(IconResponse response) { + if (!shouldLog()) { + return; + } + + final Bitmap bitmap = response.getBitmap(); + + Logger.Companion.debug(String.format("=> ICON: %sx%s", bitmap.getWidth(), bitmap.getHeight()), null); + } + + private void logRequest(IconRequest request) { + if (!shouldLog()) { + return; + } + + Logger.Companion.debug(String.format("REQUEST (%s) %s", + request.getIconCount(), + request.getPageUrl()), null); + } +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/Icons.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/Icons.java new file mode 100644 index 00000000000..8507c956ae2 --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/Icons.java @@ -0,0 +1,47 @@ +/* + * 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 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/. + */ + +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * 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 android.support.annotation.CheckResult; + +/** + * Entry point for loading icons for websites (just high quality icons, can be favicons or + * touch icons). + * + * The API is loosely inspired by Picasso's builder. + * + * Example: + * + * Icons.with(context) + * .pageUrl(pageURL) + * .skipNetwork() + * .privileged(true) + * .icon(IconDescriptor.createGenericIcon(url)) + * .build() + * .execute(callback); + */ +public abstract class Icons { + /** + * Create a new request for loading a website icon. + */ + @CheckResult + public static IconRequestBuilder with(Context context) { + return new IconRequestBuilder(context); + } +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconsHelper.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconsHelper.java new file mode 100644 index 00000000000..72852354e7d --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/IconsHelper.java @@ -0,0 +1,180 @@ +/* + * 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 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/. + */ + +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * 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.net.Uri; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.text.TextUtils; +import android.util.Base64; +import android.util.Log; +import mozilla.components.support.ktx.kotlin.StringKt; + +import java.io.ByteArrayOutputStream; +import java.util.HashSet; + +/** + * Helper methods for icon related tasks. + */ +public class IconsHelper { + private static final String LOGTAG = "Gecko/IconsHelper"; + + // Mime types of things we are capable of decoding. + private static final HashSet sDecodableMimeTypes = new HashSet<>(); + + // Mime types of things we are both capable of decoding and are container formats (May contain + // multiple different sizes of image) + private static final HashSet sContainerMimeTypes = new HashSet<>(); + + static { + // MIME types extracted from http://filext.com - ostensibly all in-use mime types for the + // corresponding formats. + // ICO + sContainerMimeTypes.add("image/vnd.microsoft.icon"); + sContainerMimeTypes.add("image/ico"); + sContainerMimeTypes.add("image/icon"); + sContainerMimeTypes.add("image/x-icon"); + sContainerMimeTypes.add("text/ico"); + sContainerMimeTypes.add("application/ico"); + + // Add supported container types to the set of supported types. + sDecodableMimeTypes.addAll(sContainerMimeTypes); + + // PNG + sDecodableMimeTypes.add("image/png"); + sDecodableMimeTypes.add("application/png"); + sDecodableMimeTypes.add("application/x-png"); + + // GIF + sDecodableMimeTypes.add("image/gif"); + + // JPEG + sDecodableMimeTypes.add("image/jpeg"); + sDecodableMimeTypes.add("image/jpg"); + sDecodableMimeTypes.add("image/pipeg"); + sDecodableMimeTypes.add("image/vnd.swiftview-jpeg"); + sDecodableMimeTypes.add("application/jpg"); + sDecodableMimeTypes.add("application/x-jpg"); + + // BMP + sDecodableMimeTypes.add("application/bmp"); + sDecodableMimeTypes.add("application/x-bmp"); + sDecodableMimeTypes.add("application/x-win-bitmap"); + sDecodableMimeTypes.add("image/bmp"); + sDecodableMimeTypes.add("image/x-bmp"); + sDecodableMimeTypes.add("image/x-bitmap"); + sDecodableMimeTypes.add("image/x-xbitmap"); + sDecodableMimeTypes.add("image/x-win-bitmap"); + sDecodableMimeTypes.add("image/x-windows-bitmap"); + sDecodableMimeTypes.add("image/x-ms-bitmap"); + sDecodableMimeTypes.add("image/x-ms-bmp"); + sDecodableMimeTypes.add("image/ms-bmp"); + } + + /** + * Helper method to getIcon the default Favicon URL for a given pageURL. Generally: somewhere.com/favicon.ico + * + * @param pageURL Page URL for which a default Favicon URL is requested + * @return The default Favicon URL or null if no default URL could be guessed. + */ + @Nullable + public static String guessDefaultFaviconURL(String pageURL) { + if (TextUtils.isEmpty(pageURL)) { + return null; + } + + // Special-casing for about: pages. The favicon for about:pages which don't provide a link tag + // is bundled in the database, keyed only by page URL, hence the need to return the page URL + // here. If the database ever migrates to stop being silly in this way, this can plausibly + // be removed. + if (StringKt.isAboutPage(pageURL)) { + return pageURL; + } + + if (!StringKt.isHttpOrHttps(pageURL)) { + // Guessing a default URL only makes sense for http(s) URLs. + return null; + } + + try { + // Fall back to trying "someScheme:someDomain.someExtension/favicon.ico". + Uri uri = Uri.parse(pageURL); + if (uri.getAuthority().isEmpty()) { + return null; + } + + return uri.buildUpon() + .path("favicon.ico") + .clearQuery() + .fragment("") + .build() + .toString(); + } catch (Exception e) { + Log.d(LOGTAG, "Exception getting default favicon URL"); + return null; + } + } + + /** + * Helper function to determine if the provided mime type is that of a format that can contain + * multiple image types. At time of writing, the only such type is ICO. + * @param mimeType Mime type to check. + * @return true if the given mime type is a container type, false otherwise. + */ + public static boolean isContainerType(@NonNull String mimeType) { + return sContainerMimeTypes.contains(mimeType); + } + + /** + * Helper function to determine if we can decode a particular mime type. + * + * @param imgType Mime type to check for decodability. + * @return false if the given mime type is certainly not decodable, true if it might be. + */ + public static boolean canDecodeType(@NonNull String imgType) { + return sDecodableMimeTypes.contains(imgType); + } + + /** + * Create an icon callback that encodes the icon as base64 image URI and returns it via the + * EventCallback to JavaScript. + */ + /*public static IconCallback createBase64EventCallback(final EventCallback callback) { + return new IconCallback() { + @Override + public void onIconResponse(IconResponse response) { + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + + try { + response.getBitmap().compress( + Bitmap.CompressFormat.PNG, + 100, // PNG which is lossless will ignore the quality setting + stream); + + // TODO: Find generic alternative to this? + callback.sendSuccess( + "data:image/x-icon;base64," + + Base64.encodeToString(stream.toByteArray(), Base64.NO_WRAP)); + } finally { + IOUtils.safeStreamClose(stream); + } + } + }; + }*/ +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/decoders/FaviconDecoder.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/decoders/FaviconDecoder.java new file mode 100644 index 00000000000..abd40165525 --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/decoders/FaviconDecoder.java @@ -0,0 +1,204 @@ +/* + * 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 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.decoders; + +import android.content.Context; +import android.graphics.Bitmap; +import android.util.Base64; +import android.util.Log; +import mozilla.components.support.base.log.logger.Logger; +import mozilla.components.support.utils.BitmapUtils; + +import java.util.Iterator; +import java.util.NoSuchElementException; + +/** + * Class providing static utility methods for decoding favicons. + */ +public class FaviconDecoder { + private static final String LOG_TAG = "GeckoFaviconDecoder"; + + enum ImageMagicNumbers { + // It is irritating that Java bytes are signed... + PNG(new byte[]{(byte) (0x89 & 0xFF), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}), + GIF(new byte[]{0x47, 0x49, 0x46, 0x38}), + JPEG(new byte[]{-0x1, -0x28, -0x1, -0x20}), + BMP(new byte[]{0x42, 0x4d}), + WEB(new byte[]{0x57, 0x45, 0x42, 0x50, 0x0a}); + + public byte[] value; + + private ImageMagicNumbers(byte[] value) { + this.value = value; + } + } + + /** + * Check for image format magic numbers of formats supported by Android. + * + * @param buffer Byte buffer to check for magic numbers + * @param offset Offset at which to look for magic numbers. + * @return true if the buffer contains a bitmap decodable by Android (Or at least, a sequence + * starting with the magic numbers thereof). false otherwise. + */ + private static boolean isDecodableByAndroid(byte[] buffer, int offset) { + for (ImageMagicNumbers m : ImageMagicNumbers.values()) { + if (bufferStartsWith(buffer, m.value, offset)) { + return true; + } + } + + return false; + } + + /** + * Utility function to check for the existence of a test byte sequence at a given offset in a + * buffer. + * + * @param buffer Byte buffer to search. + * @param test Byte sequence to search for. + * @param bufferOffset Index in input buffer to expect test sequence. + * @return true if buffer contains the byte sequence given in test at offset bufferOffset, false + * otherwise. + */ + static boolean bufferStartsWith(byte[] buffer, byte[] test, int bufferOffset) { + if (buffer.length < test.length) { + return false; + } + + for (int i = 0; i < test.length; ++i) { + if (buffer[bufferOffset + i] != test[i]) { + return false; + } + } + return true; + } + + /** + * Decode the favicon present in the region of the provided byte[] starting at offset and + * proceeding for length bytes, if any. Returns either the resulting LoadFaviconResult or null if the + * given range does not contain a bitmap we know how to decode. + * + * @param buffer Byte array containing the favicon to decode. + * @param offset The index of the first byte in the array of the region of interest. + * @param length The length of the region in the array to decode. + * @return The decoded version of the bitmap in the described region, or null if none can be + * decoded. + */ + public static LoadFaviconResult decodeFavicon(Context context, byte[] buffer, int offset, int length) { + LoadFaviconResult result; + if (isDecodableByAndroid(buffer, offset)) { + result = new LoadFaviconResult(); + result.offset = offset; + result.length = length; + result.isICO = false; + + Bitmap decodedImage = BitmapUtils.decodeByteArray(buffer, offset, length, null); + if (decodedImage == null) { + // What we got wasn't decodable after all. Probably corrupted image, or we got a muffled OOM. + return null; + } + + // We assume here that decodeByteArray doesn't hold on to the entire supplied + // buffer -- worst case, each of our buffers will be twice the necessary size. + result.bitmapsDecoded = new SingleBitmapIterator(decodedImage); + result.faviconBytes = buffer; + + return result; + } + + // If it's not decodable by Android, it might be an ICO. Let's try. + ICODecoder decoder = new ICODecoder(context, buffer, offset, length); + + result = decoder.decode(); + + if (result == null) { + return null; + } + + return result; + } + + public static LoadFaviconResult decodeDataURI(Context context, String uri) { + if (uri == null) { + Logger.Companion.warn("Can't decode null data: URI.", null); + return null; + } + + if (!uri.startsWith("data:image/")) { + // Can't decode non-image data: URI. + return null; + } + + // Otherwise, let's attack this blindly. Strictly we should be parsing. + int offset = uri.indexOf(',') + 1; + if (offset == 0) { + Logger.Companion.warn("No ',' in data: URI; malformed?", null); + return null; + } + + try { + String base64 = uri.substring(offset); + byte[] raw = Base64.decode(base64, Base64.DEFAULT); + return decodeFavicon(context, raw); + } catch (Exception e) { + Logger.Companion.warn("Couldn't decode data: URI.", e); + return null; + } + } + + public static LoadFaviconResult decodeFavicon(Context context, byte[] buffer) { + return decodeFavicon(context, buffer, 0, buffer.length); + } + + /** + * Iterator to hold a single bitmap. + */ + static class SingleBitmapIterator implements Iterator { + private Bitmap bitmap; + + public SingleBitmapIterator(Bitmap b) { + bitmap = b; + } + + /** + * Slightly cheating here - this iterator supports peeking (Handy in a couple of obscure + * places where the runtime type of the Iterator under consideration is known and + * destruction of it is discouraged. + * + * @return The bitmap carried by this SingleBitmapIterator. + */ + public Bitmap peek() { + return bitmap; + } + + @Override + public boolean hasNext() { + return bitmap != null; + } + + @Override + public Bitmap next() { + if (bitmap == null) { + throw new NoSuchElementException("Element already returned from SingleBitmapIterator."); + } + + Bitmap ret = bitmap; + bitmap = null; + return ret; + } + + @Override + public void remove() { + throw new UnsupportedOperationException("remove() not supported on SingleBitmapIterator."); + } + } +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/decoders/ICODecoder.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/decoders/ICODecoder.java new file mode 100644 index 00000000000..8a0fe3ed328 --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/decoders/ICODecoder.java @@ -0,0 +1,397 @@ +/* + * 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 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.decoders; + +import android.content.Context; +import android.graphics.Bitmap; +import android.support.annotation.VisibleForTesting; +import android.util.SparseArray; +import mozilla.components.browser.icons.R; +import mozilla.components.support.utils.BitmapUtils; + +import java.util.Iterator; +import java.util.NoSuchElementException; + +/** + * Utility class for determining the region of a provided array which contains the largest bitmap, + * assuming the provided array is a valid ICO and the bitmap desired is square, and for pruning + * unwanted entries from ICO files, if desired. + * + * 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. + * + * 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. + * + * This class is not thread safe. + */ +public class ICODecoder implements Iterable { + // The number of bytes that compacting will save for us to bother doing it. + public static final int COMPACT_THRESHOLD = 4000; + + // Some geometry of an ICO file. + public static final int ICO_HEADER_LENGTH_BYTES = 6; + public static final int ICO_ICONDIRENTRY_LENGTH_BYTES = 16; + + // The buffer containing bytes to attempt to decode. + private byte[] decodand; + + // The region of the decodand to decode. + private int offset; + private int len; + + IconDirectoryEntry[] iconDirectory; + private boolean isValid; + private boolean hasDecoded; + private int largestFaviconSize; + + public ICODecoder(Context context, byte[] decodand, int offset, int len) { + this.decodand = decodand; + this.offset = offset; + this.len = len; + this.largestFaviconSize = context.getResources() + .getDimensionPixelSize(R.dimen.favicon_largest_interesting_size); + } + + /** + * Decode the Icon Directory for this ICO and store the result in iconDirectory. + * + * @return true if ICO decoding was considered to probably be a success, false if it certainly + * was a failure. + */ + private boolean decodeIconDirectoryAndPossiblyPrune() { + hasDecoded = true; + + // Fail if the end of the described range is out of bounds. + if (offset + len > decodand.length) { + return false; + } + + // Fail if we don't have enough space for the header. + if (len < ICO_HEADER_LENGTH_BYTES) { + return false; + } + + // 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 (decodand[offset] != 0 || + decodand[offset + 1] != 0 || + decodand[offset + 2] != 1 || + decodand[offset + 3] != 0) { + return false; + } + + // 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. + int numEncodedImages = (decodand[offset + 4] & 0xFF) | + (decodand[offset + 5] & 0xFF) << 8; + + + // Fail if there are no images or the field is corrupt. + if (numEncodedImages <= 0) { + return false; + } + + final int headerAndDirectorySize = ICO_HEADER_LENGTH_BYTES + (numEncodedImages * ICO_ICONDIRENTRY_LENGTH_BYTES); + + // Fail if there is not enough space in the buffer for the stated number of icondir entries, + // let alone the data. + if (len < headerAndDirectorySize) { + return false; + } + + // Put the pointer on the first byte of the first Icon Directory Entry. + int bufferIndex = offset + ICO_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. + int minimumMaximum = Integer.MAX_VALUE; + + // Used to track the best entry for each size. The entries we want to keep. + SparseArray preferenceArray = new SparseArray(); + + for (int i = 0; i < numEncodedImages; i++, bufferIndex += ICO_ICONDIRENTRY_LENGTH_BYTES) { + // Decode the Icon Directory Entry at this offset. + IconDirectoryEntry newEntry = IconDirectoryEntry.createFromBuffer(decodand, offset, len, bufferIndex); + newEntry.index = i; + + if (newEntry.isErroneous) { + continue; + } + + if (newEntry.width > largestFaviconSize) { + // 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) { + continue; + } + + // Remove the previous minimum-maximum. + preferenceArray.delete(minimumMaximum); + + minimumMaximum = newEntry.width; + } + + IconDirectoryEntry oldEntry = preferenceArray.get(newEntry.width); + if (oldEntry == null) { + preferenceArray.put(newEntry.width, newEntry); + continue; + } + + if (oldEntry.compareTo(newEntry) < 0) { + preferenceArray.put(newEntry.width, newEntry); + } + } + + final int count = preferenceArray.size(); + + // Abort if no entries are desired (Perhaps all are corrupt?) + if (count == 0) { + return false; + } + + // Allocate space for the icon directory entries in the decoded directory. + iconDirectory = new IconDirectoryEntry[count]; + + // The size of the data in the buffer that we find useful. + int retainedSpace = ICO_HEADER_LENGTH_BYTES; + + for (int i = 0; i < count; i++) { + IconDirectoryEntry e = preferenceArray.valueAt(i); + retainedSpace += ICO_ICONDIRENTRY_LENGTH_BYTES + e.payloadSize; + iconDirectory[i] = e; + } + + isValid = true; + + // Set the number of images field in the buffer to reflect the number of retained entries. + decodand[offset + 4] = (byte) iconDirectory.length; + decodand[offset + 5] = (byte) (iconDirectory.length >>> 8); + + if ((len - retainedSpace) > COMPACT_THRESHOLD) { + compactingCopy(retainedSpace); + } + + return true; + } + + /** + * Copy the buffer into a new array of exactly the required size, omitting any unwanted data. + */ + private void compactingCopy(int spaceRetained) { + byte[] buf = new byte[spaceRetained]; + + // Copy the header. + System.arraycopy(decodand, offset, buf, 0, ICO_HEADER_LENGTH_BYTES); + + int headerPtr = ICO_HEADER_LENGTH_BYTES; + + int payloadPtr = ICO_HEADER_LENGTH_BYTES + (iconDirectory.length * ICO_ICONDIRENTRY_LENGTH_BYTES); + + int ind = 0; + for (IconDirectoryEntry entry : iconDirectory) { + // Copy this entry. + System.arraycopy(decodand, offset + entry.getOffset(), buf, headerPtr, ICO_ICONDIRENTRY_LENGTH_BYTES); + + // Copy its payload. + System.arraycopy(decodand, offset + entry.payloadOffset, buf, payloadPtr, entry.payloadSize); + + // Update the offset field. + buf[headerPtr + 12] = (byte) payloadPtr; + buf[headerPtr + 13] = (byte) (payloadPtr >>> 8); + buf[headerPtr + 14] = (byte) (payloadPtr >>> 16); + buf[headerPtr + 15] = (byte) (payloadPtr >>> 24); + + entry.payloadOffset = payloadPtr; + entry.index = ind; + + payloadPtr += entry.payloadSize; + headerPtr += ICO_ICONDIRENTRY_LENGTH_BYTES; + ind++; + } + + decodand = buf; + offset = 0; + len = spaceRetained; + } + + /** + * Decode and return the bitmap represented by the given index in the Icon Directory, if valid. + * + * @param index The index into the Icon Directory of the image of interest. + * @return The decoded Bitmap object for this image, or null if the entry is invalid or decoding + * fails. + */ + public Bitmap decodeBitmapAtIndex(int index) { + final IconDirectoryEntry iconDirEntry = iconDirectory[index]; + + if (iconDirEntry.payloadIsPNG) { + // PNG payload. Simply extract it and decode it. + return BitmapUtils.decodeByteArray(decodand, offset + iconDirEntry.payloadOffset, iconDirEntry.payloadSize, null); + } + + // 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. + byte[] decodeTarget = new byte[ICO_HEADER_LENGTH_BYTES + ICO_ICONDIRENTRY_LENGTH_BYTES + iconDirEntry.payloadSize]; + + // Set the type field in the ICO header. + decodeTarget[2] = 1; + + // Set the num-images field in the header to 1. + decodeTarget[4] = 1; + + // Copy the ICONDIRENTRY we need into the new buffer. + System.arraycopy(decodand, offset + iconDirEntry.getOffset(), decodeTarget, ICO_HEADER_LENGTH_BYTES, ICO_ICONDIRENTRY_LENGTH_BYTES); + + // Copy the payload into the new buffer. + final int singlePayloadOffset = ICO_HEADER_LENGTH_BYTES + ICO_ICONDIRENTRY_LENGTH_BYTES; + System.arraycopy(decodand, offset + iconDirEntry.payloadOffset, decodeTarget, singlePayloadOffset, iconDirEntry.payloadSize); + + // Update the offset field of the ICONDIRENTRY to make the new ICO valid. + decodeTarget[ICO_HEADER_LENGTH_BYTES + 12] = singlePayloadOffset; + decodeTarget[ICO_HEADER_LENGTH_BYTES + 13] = (singlePayloadOffset >>> 8); + decodeTarget[ICO_HEADER_LENGTH_BYTES + 14] = (singlePayloadOffset >>> 16); + decodeTarget[ICO_HEADER_LENGTH_BYTES + 15] = (singlePayloadOffset >>> 24); + + // Decode the newly-constructed singleton-ICO. + return BitmapUtils.decodeByteArray(decodeTarget, 0, decodeTarget.length, null); + } + + /** + * Fetch an iterator over the images in this ICO, or null if this ICO seems to be invalid. + * + * @return An iterator over the Bitmaps stored in this ICO, or null if decoding fails. + */ + @Override + public ICOIterator iterator() { + // If a previous call to decode concluded this ICO is invalid, abort. + if (hasDecoded && !isValid) { + return null; + } + + // If we've not been decoded before, but now fail to make any sense of the ICO, abort. + if (!hasDecoded) { + if (!decodeIconDirectoryAndPossiblyPrune()) { + return null; + } + } + + // If decoding was a success, return an iterator over the images in this ICO. + return new ICOIterator(); + } + + /** + * Decode this ICO and return the result as a LoadFaviconResult. + * @return A LoadFaviconResult representing the decoded ICO. + */ + public LoadFaviconResult decode() { + // The call to iterator returns null if decoding fails. + Iterator bitmaps = iterator(); + if (bitmaps == null) { + return null; + } + + LoadFaviconResult result = new LoadFaviconResult(); + + result.bitmapsDecoded = bitmaps; + result.faviconBytes = decodand; + result.offset = offset; + result.length = len; + result.isICO = true; + + return result; + } + + @VisibleForTesting + public IconDirectoryEntry[] getIconDirectory() { + return iconDirectory; + } + + @VisibleForTesting + public int getLargestFaviconSize() { + return largestFaviconSize; + } + + /** + * Inner class to iterate over the elements in the ICO represented by the enclosing instance. + */ + private class ICOIterator implements Iterator { + private int mIndex; + + @Override + public boolean hasNext() { + return mIndex < iconDirectory.length; + } + + @Override + public Bitmap next() { + if (mIndex > iconDirectory.length) { + throw new NoSuchElementException("No more elements in this ICO."); + } + return decodeBitmapAtIndex(mIndex++); + } + + @Override + public void remove() { + if (iconDirectory[mIndex] == null) { + throw new IllegalStateException("Remove already called for element " + mIndex); + } + iconDirectory[mIndex] = null; + } + } +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/decoders/IconDirectoryEntry.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/decoders/IconDirectoryEntry.java new file mode 100644 index 00000000000..ac7ee1182dd --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/decoders/IconDirectoryEntry.java @@ -0,0 +1,214 @@ +/* + * 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 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.decoders; + +import android.support.annotation.VisibleForTesting; + +/** + * Representation of an ICO file ICONDIRENTRY structure. + */ +public class IconDirectoryEntry implements Comparable { + + public static int maxBPP; + + int width; + int height; + int paletteSize; + int bitsPerPixel; + int payloadSize; + int payloadOffset; + boolean payloadIsPNG; + + // Tracks the index in the Icon Directory of this entry. Useful only for pruning. + int index; + boolean isErroneous; + + public IconDirectoryEntry(int width, int height, int paletteSize, int bitsPerPixel, int payloadSize, int payloadOffset, boolean payloadIsPNG) { + this.width = width; + this.height = height; + this.paletteSize = paletteSize; + this.bitsPerPixel = bitsPerPixel; + this.payloadSize = payloadSize; + this.payloadOffset = payloadOffset; + this.payloadIsPNG = payloadIsPNG; + } + + /** + * Method to get a dummy Icon Directory Entry with the Erroneous bit set. + * + * @return An erroneous placeholder Icon Directory Entry. + */ + public static IconDirectoryEntry getErroneousEntry() { + IconDirectoryEntry ret = new IconDirectoryEntry(-1, -1, -1, -1, -1, -1, false); + ret.isErroneous = true; + + return ret; + } + + /** + * Create an IconDirectoryEntry object from a byte[]. Interprets the buffer starting at the given + * offset as an IconDirectoryEntry and returns the result. + * + * @param buffer Byte array containing the icon directory entry to decode. + * @param regionOffset Offset into the byte array of the valid region of the buffer. + * @param regionLength Length of the valid region in the buffer. + * @param entryOffset Offset of the icon directory entry to decode within the buffer. + * @return An IconDirectoryEntry object representing the entry specified, or null if the entry + * is obviously invalid. + */ + public static IconDirectoryEntry createFromBuffer(byte[] buffer, int regionOffset, int regionLength, int entryOffset) { + // Verify that the reserved field is really zero. + if (buffer[entryOffset + 3] != 0) { + return getErroneousEntry(); + } + + // Verify that the entry points to a region that actually exists in the buffer, else bin it. + int fieldPtr = entryOffset + 8; + int entryLength = (buffer[fieldPtr] & 0xFF) | + (buffer[fieldPtr + 1] & 0xFF) << 8 | + (buffer[fieldPtr + 2] & 0xFF) << 16 | + (buffer[fieldPtr + 3] & 0xFF) << 24; + + // Advance to the offset field. + fieldPtr += 4; + + int payloadOffset = (buffer[fieldPtr] & 0xFF) | + (buffer[fieldPtr + 1] & 0xFF) << 8 | + (buffer[fieldPtr + 2] & 0xFF) << 16 | + (buffer[fieldPtr + 3] & 0xFF) << 24; + + // Fail if the entry describes a region outside the buffer. + if (payloadOffset < 0 || entryLength < 0 || payloadOffset + entryLength > regionOffset + regionLength) { + return getErroneousEntry(); + } + + // Extract the image dimensions. + int imageWidth = buffer[entryOffset] & 0xFF; + int imageHeight = buffer[entryOffset + 1] & 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. + int paletteSize = buffer[entryOffset + 2] & 0xFF; + + // The plane count - usually 0 or 1. When > 1, taken as multiplier on bitsPerPixel. + int colorPlanes = buffer[entryOffset + 4] & 0xFF; + + int bitsPerPixel = (buffer[entryOffset + 6] & 0xFF) | + (buffer[entryOffset + 7] & 0xFF) << 8; + + if (colorPlanes > 1) { + bitsPerPixel *= colorPlanes; + } + + // Look for PNG magic numbers at the start of the payload. + boolean payloadIsPNG = FaviconDecoder.bufferStartsWith(buffer, FaviconDecoder.ImageMagicNumbers.PNG.value, regionOffset + payloadOffset); + + return new IconDirectoryEntry(imageWidth, imageHeight, paletteSize, bitsPerPixel, entryLength, payloadOffset, payloadIsPNG); + } + + /** + * Get the number of bytes from the start of the ICO file to the beginning of this entry. + */ + public int getOffset() { + return ICODecoder.ICO_HEADER_LENGTH_BYTES + (index * ICODecoder.ICO_ICONDIRENTRY_LENGTH_BYTES); + } + + @Override + public int compareTo(IconDirectoryEntry another) { + if (width > another.width) { + return 1; + } + + if (width < another.width) { + return -1; + } + + // Where both images exceed the max BPP, take the smaller of the two BPP values. + if (bitsPerPixel >= maxBPP && another.bitsPerPixel >= maxBPP) { + if (bitsPerPixel < another.bitsPerPixel) { + return 1; + } + + if (bitsPerPixel > another.bitsPerPixel) { + return -1; + } + } + + // Otherwise, take the larger of the BPP values. + if (bitsPerPixel > another.bitsPerPixel) { + return 1; + } + + if (bitsPerPixel < another.bitsPerPixel) { + return -1; + } + + // Prefer large palettes. + if (paletteSize > another.paletteSize) { + return 1; + } + + if (paletteSize < another.paletteSize) { + return -1; + } + + // Prefer smaller payloads. + if (payloadSize < another.payloadSize) { + return 1; + } + + if (payloadSize > another.payloadSize) { + return -1; + } + + // If all else fails, prefer PNGs over BMPs. They tend to be smaller. + if (payloadIsPNG && !another.payloadIsPNG) { + return 1; + } + + if (!payloadIsPNG && another.payloadIsPNG) { + return -1; + } + + return 0; + } + + public static void setMaxBPP(int maxBPP) { + IconDirectoryEntry.maxBPP = maxBPP; + } + + @VisibleForTesting + public int getWidth() { + return width; + } + + @Override + public String toString() { + return "IconDirectoryEntry{" + + "\nwidth=" + width + + ", \nheight=" + height + + ", \npaletteSize=" + paletteSize + + ", \nbitsPerPixel=" + bitsPerPixel + + ", \npayloadSize=" + payloadSize + + ", \npayloadOffset=" + payloadOffset + + ", \npayloadIsPNG=" + payloadIsPNG + + ", \nindex=" + index + + '}'; + } +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/decoders/LoadFaviconResult.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/decoders/LoadFaviconResult.java new file mode 100644 index 00000000000..6ed75761c94 --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/decoders/LoadFaviconResult.java @@ -0,0 +1,139 @@ +/* + * 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 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.decoders; + +import android.graphics.Bitmap; +import android.support.annotation.Nullable; +import android.util.Log; +import android.util.SparseArray; + +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; + +/** + * Class representing the result of loading a favicon. + * This operation will produce either a collection of favicons, a single favicon, or no favicon. + * It is necessary to model single favicons differently to a collection of one favicon (An entity + * that may not exist with this scheme) since the in-database representation of these things differ. + * (In particular, collections of favicons are stored in encoded ICO format, whereas single icons are + * stored as decoded bitmap blobs.) + */ +public class LoadFaviconResult { + private static final String LOGTAG = "LoadFaviconResult"; + + byte[] faviconBytes; + int offset; + int length; + + boolean isICO; + Iterator bitmapsDecoded; + + public Iterator getBitmaps() { + return bitmapsDecoded; + } + + /** + * Return a representation of this result suitable for storing in the database. + * + * @return A byte array containing the bytes from which this result was decoded, + * or null if re-encoding failed. + */ + public byte[] getBytesForDatabaseStorage() { + // Begin by normalising the buffer. + if (offset != 0 || length != faviconBytes.length) { + final byte[] normalised = new byte[length]; + System.arraycopy(faviconBytes, offset, normalised, 0, length); + offset = 0; + faviconBytes = normalised; + } + + // For results containing multiple images, we store the result verbatim. (But cutting the + // buffer to size first). + // We may instead want to consider re-encoding the entire ICO as a collection of efficiently + // encoded PNGs. This may not be worth the CPU time (Indeed, the encoding of single-image + // favicons may also not be worth the time/space tradeoff.). + if (isICO) { + return faviconBytes; + } + + // For results containing a single image, we re-encode the + // result as a PNG in an effort to save space. + final Bitmap favicon = ((FaviconDecoder.SingleBitmapIterator) bitmapsDecoded).peek(); + final ByteArrayOutputStream stream = new ByteArrayOutputStream(); + + try { + if (favicon.compress(Bitmap.CompressFormat.PNG, 100, stream)) { + return stream.toByteArray(); + } + } catch (OutOfMemoryError e) { + Log.w(LOGTAG, "Out of memory re-compressing favicon."); + } + + Log.w(LOGTAG, "Favicon re-compression failed."); + return null; + } + + @Nullable + public Bitmap getBestBitmap(int targetWidthAndHeight) { + final SparseArray iconMap = new SparseArray<>(); + final List sizes = new ArrayList<>(); + + while (bitmapsDecoded.hasNext()) { + final Bitmap b = bitmapsDecoded.next(); + + // It's possible to receive null, most likely due to OOM or a zero-sized image, + // from BitmapUtils.decodeByteArray(byte[], int, int, BitmapFactory.Options) + if (b != null) { + iconMap.put(b.getWidth(), b); + sizes.add(b.getWidth()); + } + } + + int bestSize = selectBestSizeFromList(sizes, targetWidthAndHeight); + + if (bestSize == -1) { + // No icons found: this could occur if we weren't able to process any of the + // supplied icons. + return null; + } + + return iconMap.get(bestSize); + } + + /** + * Select the closest icon size from a list of icon sizes. + * We just find the first icon that is larger than the preferred size if available, or otherwise select the + * largest icon (if all icons are smaller than the preferred size). + * + * @return The closest icon size, or -1 if no sizes are supplied. + */ + public static int selectBestSizeFromList(final List sizes, final int preferredSize) { + if (sizes.isEmpty()) { + // This isn't ideal, however current code assumes this as an error value for now. + return -1; + } + + Collections.sort(sizes); + + for (int size : sizes) { + if (size >= preferredSize) { + return size; + } + } + + // If all icons are smaller than the preferred size then we don't have an icon + // selected yet, therefore just take the largest (last) icon. + return sizes.get(sizes.size() - 1); + } +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/ContentProviderLoader.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/ContentProviderLoader.java new file mode 100644 index 00000000000..87676c8976f --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/ContentProviderLoader.java @@ -0,0 +1,103 @@ +/* + * 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/. + */ + +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * 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.database.Cursor; +import android.graphics.Bitmap; +import android.net.Uri; +import android.text.TextUtils; +import mozilla.components.browser.icons.IconRequest; +import mozilla.components.browser.icons.IconResponse; +import mozilla.components.browser.icons.decoders.FaviconDecoder; +import mozilla.components.browser.icons.decoders.LoadFaviconResult; + +/** + * Loader for loading icons from a content provider. This loader was primarily written to load icons + * from the partner bookmarks provider. However it can load icons from arbitrary content providers + * as long as they return a cursor with a "favicon" or "touchicon" column (blob). + */ +public class ContentProviderLoader implements IconLoader { + private static final String TOUCHICON = "touchicon"; + private static final String FAVICON = "favicon"; + + @Override + public IconResponse load(IconRequest request) { + if (request.shouldSkipDisk()) { + // If we should not load data from disk then we do not load from content providers either. + return null; + } + + final String iconUrl = request.getBestIcon().getUrl(); + final Context context = request.getContext(); + final int targetSize = request.getTargetSize(); + + if (TextUtils.isEmpty(iconUrl) || !iconUrl.startsWith("content://")) { + return null; + } + + Cursor cursor = context.getContentResolver().query( + Uri.parse(iconUrl), + new String[] { + TOUCHICON, + FAVICON, + }, + null, + null, + null + ); + + if (cursor == null) { + return null; + } + + try { + if (!cursor.moveToFirst()) { + return null; + } + + // Try the touch icon first. It has a higher resolution usually. + Bitmap icon = decodeFromCursor(request.getContext(), cursor, TOUCHICON, targetSize); + if (icon != null) { + return IconResponse.create(icon); + } + + icon = decodeFromCursor(request.getContext(), cursor, FAVICON, targetSize); + if (icon != null) { + return IconResponse.create(icon); + } + } finally { + cursor.close(); + } + + return null; + } + + private Bitmap decodeFromCursor(Context context, Cursor cursor, String column, int targetWidthAndHeight) { + final int index = cursor.getColumnIndex(column); + if (index == -1) { + return null; + } + + if (cursor.isNull(index)) { + return null; + } + + final byte[] data = cursor.getBlob(index); + LoadFaviconResult result = FaviconDecoder.decodeFavicon(context, data, 0, data.length); + if (result == null) { + return null; + } + + return result.getBestBitmap(targetWidthAndHeight); + } +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/DataUriLoader.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/DataUriLoader.java new file mode 100644 index 00000000000..3b233fe9c90 --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/DataUriLoader.java @@ -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/. + */ + +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * 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 mozilla.components.browser.icons.IconRequest; +import mozilla.components.browser.icons.IconResponse; +import mozilla.components.browser.icons.decoders.FaviconDecoder; +import mozilla.components.browser.icons.decoders.LoadFaviconResult; + +/** + * Loader for loading icons from a data URI. This loader will try to decode any data with an + * "image/*" MIME type. + * + * https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs + */ +public class DataUriLoader implements IconLoader { + @Override + public IconResponse load(IconRequest request) { + final String iconUrl = request.getBestIcon().getUrl(); + + if (!iconUrl.startsWith("data:image/")) { + return null; + } + + LoadFaviconResult loadFaviconResult = FaviconDecoder.decodeDataURI(request.getContext(), iconUrl); + if (loadFaviconResult == null) { + return null; + } + + return IconResponse.create( + loadFaviconResult.getBestBitmap(request.getTargetSize())); + } +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/IconDownloader.kt b/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/IconDownloader.kt new file mode 100644 index 00000000000..34ac648b924 --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/IconDownloader.kt @@ -0,0 +1,234 @@ +/* + * 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/. + */ + +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * 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.support.annotation.VisibleForTesting +import android.util.Log +import mozilla.components.browser.icons.IconRequest +import mozilla.components.browser.icons.IconResponse +import mozilla.components.browser.icons.decoders.FaviconDecoder +import mozilla.components.browser.icons.decoders.LoadFaviconResult +import mozilla.components.browser.icons.storage.FailureCache +import mozilla.components.browser.icons.util.ProxySelector +import mozilla.components.support.ktx.kotlin.* + +import java.io.IOException +import java.io.InputStream +import java.net.HttpURLConnection +import java.net.URI +import java.net.URISyntaxException +import java.util.HashSet + +/** + * This loader implementation downloads icons from http(s) URLs. + */ +class IconDownloader : IconLoader { + + override fun load(request: IconRequest): IconResponse? { + if (request.shouldSkipNetwork()) { + return null + } + + val iconUrl = request.bestIcon.url + + if (!iconUrl.isHttpOrHttps()) { + return null + } + + try { + val result = downloadAndDecodeImage(request.context, iconUrl) ?: return null + + val bitmap = result.getBestBitmap(request.targetSize) ?: return null + + return IconResponse.createFromNetwork(bitmap, iconUrl) + } catch (e: Exception) { + Log.e(LOGTAG, "Error reading favicon", e) + } catch (e: OutOfMemoryError) { + Log.e(LOGTAG, "Insufficient memory to process favicon") + } + + return null + } + + /** + * Download the Favicon from the given URL and pass it to the decoder function. + * + * @param targetFaviconURL URL of the favicon to download. + * @return A LoadFaviconResult containing the bitmap(s) extracted from the downloaded file, or + * null if no or corrupt data was received. + */ + fun downloadAndDecodeImage(context: Context, targetFaviconURL: String): LoadFaviconResult? { + // Try the URL we were given. + val connection = tryDownload(targetFaviconURL) ?: return null + + // Decode the image from the fetched response. + return connection.inputStream.use { + decodeImageFromResponse(context, it, connection.getHeaderFieldInt("Content-Length", -1)) + }.also { + connection.disconnect() + } + +// // Decode the image from the fetched response. +// try { +// stream = connection.inputStream +// return decodeImageFromResponse(context, stream, connection.getHeaderFieldInt("Content-Length", -1)) +// } catch (e: IOException) { +// Log.d(LOGTAG, "IOException while reading and decoding ixon", e) +// return null +// } finally { +// // Close the stream and free related resources. +// // TODO: Use try-with-resources in Kotlin to close this stream. +// //IOUtils.safeStreamClose(stream); +// connection.disconnect() +// } + } + + /** + * Helper method for trying the download request to grab a Favicon. + * + * @param faviconURI URL of Favicon to try and download + * @return The HttpResponse containing the downloaded Favicon if successful, null otherwise. + */ + private fun tryDownload(faviconURI: String): HttpURLConnection? { + val visitedLinkSet = HashSet() + visitedLinkSet.add(faviconURI) + return tryDownloadRecurse(faviconURI, visitedLinkSet) + } + + /** + * Try to download from the favicon URL and recursively follow redirects. + */ + private fun tryDownloadRecurse(faviconURI: String, visited: HashSet): HttpURLConnection? { + if (visited.size == MAX_REDIRECTS_TO_FOLLOW) { + return null + } + + var connection: HttpURLConnection? = null + + try { + connection = connectTo(faviconURI) + + // Was the response a failure? + val status = connection.responseCode + + // Handle HTTP status codes requesting a redirect. + if (status in 300..399) { + val newURI = connection.getHeaderField("Location") + + // Handle mad web servers. + try { + if (newURI == null || newURI == faviconURI) { + return null + } + + if (visited.contains(newURI)) { + // Already been redirected here - abort. + return null + } + + visited.add(newURI) + } finally { + connection.disconnect() + } + + return tryDownloadRecurse(newURI, visited) + } + + if (status >= 400) { + // Client or Server error. Let's not retry loading from this URL again for some time. + FailureCache.get().rememberFailure(faviconURI) + + connection.disconnect() + return null + } + } catch (e: IOException) { + connection?.disconnect() + return null + } catch (e: URISyntaxException) { + connection?.disconnect() + return null + } + + return connection + } + + @Throws(URISyntaxException::class, IOException::class) + fun connectTo(uri: String): HttpURLConnection { + val connection = ProxySelector.openConnectionWithProxy( + URI(uri) + ) as HttpURLConnection + + // TODO: Find a way to pass the User-Agent to the IconDownloader + //connection.setRequestProperty("User-Agent", GeckoApplication.getDefaultUAString()) + connection.setRequestProperty("User-Agent", "Mozilla/5.0 (Android 5.0; Mobile; rv: 63.0) Gecko/63.0 Firefox/63.0") + + // We implemented or own way of following redirects back when this code was using HttpClient. + // Nowadays we should let HttpUrlConnection do the work - assuming that it doesn't follow + // redirects in loops forever. + connection.instanceFollowRedirects = false + + return connection + } + + /** + * Copies the favicon stream to a buffer and decodes downloaded content into bitmaps using the + * FaviconDecoder. + * + * @param stream to decode + * @param contentLength as reported by the server (or -1) + * @return A LoadFaviconResult containing the bitmap(s) extracted from the downloaded file, or + * null if no or corrupt data were received. + * @throws IOException If attempts to fully read the stream result in such an exception, such as + * in the event of a transient connection failure. + */ + @Throws(IOException::class) + private fun decodeImageFromResponse( + context: Context, + stream: InputStream?, + contentLength: Int + ): LoadFaviconResult? { + // This may not be provided, but if it is, it's useful. + val bufferSize = if (contentLength > 0) { + // The size was reported and sane, so let's use that. + // Integer overflow should not be a problem for Favicon sizes... + contentLength + 1 + } else { + // No declared size, so guess and reallocate later if it turns out to be too small. + DEFAULT_FAVICON_BUFFER_SIZE_BYTES + } + + stream?.use { + // Read the InputStream into a byte[]. + val data = it.readBytes(bufferSize) + + // Having downloaded the image, decode it. + return FaviconDecoder.decodeFavicon(context, data, 0, data.size) + } + + return null + } + + companion object { + private const val LOGTAG = "Gecko/Downloader" + + /** + * The maximum number of http redirects (3xx) until we give up. + */ + private const val MAX_REDIRECTS_TO_FOLLOW = 5 + + /** + * The default size of the buffer to use for downloading Favicons in the event no size is given + * by the server. */ + private const val DEFAULT_FAVICON_BUFFER_SIZE_BYTES = 25000 + } +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/IconGenerator.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/IconGenerator.java new file mode 100644 index 00000000000..c83801b0edd --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/IconGenerator.java @@ -0,0 +1,169 @@ +/* + * 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/. + */ + +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * 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.content.res.Resources; +import android.graphics.*; +import android.net.Uri; +import android.support.annotation.NonNull; +import android.support.annotation.VisibleForTesting; +import android.text.TextUtils; +import android.util.TypedValue; +import mozilla.components.browser.icons.IconRequest; +import mozilla.components.browser.icons.IconResponse; +import mozilla.components.browser.icons.R; +import mozilla.components.support.ktx.kotlin.StringKt; + +/** + * This loader will generate an icon in case no icon could be loaded. In order to do so this needs + * to be the last loader that will be tried. + */ +public class IconGenerator implements IconLoader { + // Mozilla's Visual Design Colour Palette + // http://firefoxux.github.io/StyleGuide/#/visualDesign/colours + private static final int[] COLORS = { + 0xFF9A4C00, + 0xFFAB008D, + 0xFF4C009C, + 0xFF002E9C, + 0xFF009EC2, + 0xFF009D02, + 0xFF51AB00, + 0xFF36385A, + }; + + @Override + public IconResponse load(IconRequest request) { + if (request.getIconCount() > 1) { + // There are still other icons to try. We will only generate an icon if there's only one + // icon left and all previous loaders have failed (assuming this is the last one). + return null; + } + + return generate(request.getContext(), request.getPageUrl(), request.getTargetSize(), request.getTextSize()); + } + + public static IconResponse generate(Context context, String pageURL) { + return generate(context, pageURL, 0, 0); + } + + /** + * Generate default favicon for the given page URL. + */ + public static IconResponse generate(final Context context, final String pageURL, + int widthAndHeight, float textSize) { + final Resources resources = context.getResources(); + if (widthAndHeight == 0) { + widthAndHeight = resources.getDimensionPixelSize(R.dimen.favicon_bg); + } + final int roundedCorners = resources.getDimensionPixelOffset(R.dimen.favicon_corner_radius); + + final Bitmap favicon = Bitmap.createBitmap(widthAndHeight, widthAndHeight, Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(favicon); + + final int color = pickColor(pageURL); + + final Paint paint = new Paint(); + paint.setColor(color); + + canvas.drawRoundRect(new RectF(0, 0, widthAndHeight, widthAndHeight), roundedCorners, roundedCorners, paint); + + paint.setColor(Color.WHITE); + + final String character = getRepresentativeCharacter(pageURL); + + if (textSize == 0) { + // 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). + textSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, + widthAndHeight / 8, + resources.getDisplayMetrics()); + } + + paint.setTextAlign(Paint.Align.CENTER); + paint.setTextSize(textSize); + paint.setAntiAlias(true); + + canvas.drawText(character, + canvas.getWidth() / 2, + (int) ((canvas.getHeight() / 2) - ((paint.descent() + paint.ascent()) / 2)), + paint); + + return IconResponse.createGenerated(favicon, color); + } + + /** + * Get a representative character for the given URL. + * + * For example this method will return "f" for "http://m.facebook.com/foobar". + */ + @VisibleForTesting + static String getRepresentativeCharacter(String url) { + if (TextUtils.isEmpty(url)) { + return "?"; + } + + final String snippet = getRepresentativeSnippet(url); + for (int i = 0; i < snippet.length(); i++) { + char c = snippet.charAt(i); + + if (Character.isLetterOrDigit(c)) { + return String.valueOf(Character.toUpperCase(c)); + } + } + + // Nothing found.. + return "?"; + } + + /** + * Return a color for this URL. Colors will be based on the host. URLs with the same host will + * return the same color. + */ + @VisibleForTesting + static int pickColor(String url) { + if (TextUtils.isEmpty(url)) { + return COLORS[0]; + } + + final String snippet = getRepresentativeSnippet(url); + final int color = Math.abs(snippet.hashCode() % COLORS.length); + + return COLORS[color]; + } + + /** + * Get the representative part of the URL. Usually this is the host (without common prefixes). + */ + private static String getRepresentativeSnippet(@NonNull String url) { + Uri uri = Uri.parse(url); + + // Use the host if available + String snippet = uri.getHost(); + + if (TextUtils.isEmpty(snippet)) { + // If the uri does not have a host (e.g. file:// uris) then use the path + snippet = uri.getPath(); + } + + if (TextUtils.isEmpty(snippet)) { + // If we still have no snippet then just return the question mark + return "?"; + } + + // Strip common prefixes that we do not want to use to determine the representative characterS + snippet = StringKt.stripCommonSubdomains(snippet); + + return snippet; + } +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/IconLoader.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/IconLoader.java new file mode 100644 index 00000000000..d78a1defbc6 --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/IconLoader.java @@ -0,0 +1,28 @@ +/* + * 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/. + */ + +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * 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.support.annotation.Nullable; +import mozilla.components.browser.icons.IconRequest; +import mozilla.components.browser.icons.IconResponse; + +/** + * Generic interface for classes that can load icons. + */ +public interface IconLoader { + /** + * Loads the icon for this request or returns null if this loader can't load an icon for this + * request or just failed this time. + */ + @Nullable + IconResponse load(IconRequest request); +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/MemoryLoader.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/MemoryLoader.java new file mode 100644 index 00000000000..00c171bfa3e --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/MemoryLoader.java @@ -0,0 +1,38 @@ +/* + * 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/. + */ + +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * 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 mozilla.components.browser.icons.IconRequest; +import mozilla.components.browser.icons.IconResponse; +import mozilla.components.browser.icons.storage.MemoryStorage; + +/** + * Loader implementation for loading icons from an in-memory cached (Implemented by MemoryStorage). + */ +public class MemoryLoader implements IconLoader { + private final MemoryStorage storage; + + public MemoryLoader() { + storage = MemoryStorage.get(); + } + + @Override + public IconResponse load(IconRequest request) { + if (request.shouldSkipMemory()) { + return null; + } + + final String iconUrl = request.getBestIcon().getUrl(); + return storage.getIcon(iconUrl); + } +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparation/AddDefaultIconUrl.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparation/AddDefaultIconUrl.java new file mode 100644 index 00000000000..77ce4e47eb9 --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparation/AddDefaultIconUrl.java @@ -0,0 +1,44 @@ +/* + * 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/. + */ + +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * 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.preparation; + +import android.text.TextUtils; +import mozilla.components.browser.icons.IconDescriptor; +import mozilla.components.browser.icons.IconRequest; +import mozilla.components.browser.icons.IconsHelper; +import mozilla.components.support.ktx.kotlin.StringKt; + +/** + * Preparer to add the "default/guessed" favicon URL (domain/favicon.ico) to the list of URLs to + * try loading the favicon from. + * + * The default URL will be added with a very low priority so that we will only try to load from this + * URL if all other options failed. + */ +public class AddDefaultIconUrl implements Preparer { + @Override + public void prepare(IconRequest request) { + if (!StringKt.isHttpOrHttps(request.getPageUrl())) { + return; + } + + final String defaultFaviconUrl = IconsHelper.guessDefaultFaviconURL(request.getPageUrl()); + if (TextUtils.isEmpty(defaultFaviconUrl)) { + // We couldn't generate a default favicon URL for this URL. Nothing to do here. + return; + } + + request.modify() + .icon(IconDescriptor.createGenericIcon(defaultFaviconUrl)) + .deferBuild(); + } +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparation/FilterKnownFailureUrls.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparation/FilterKnownFailureUrls.java new file mode 100644 index 00000000000..9a19909c39b --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparation/FilterKnownFailureUrls.java @@ -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/. + */ + +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * 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.preparation; + + +import mozilla.components.browser.icons.IconDescriptor; +import mozilla.components.browser.icons.IconRequest; +import mozilla.components.browser.icons.storage.FailureCache; + +import java.util.Iterator; + +public class FilterKnownFailureUrls implements Preparer { + @Override + public void prepare(IconRequest request) { + final FailureCache failureCache = FailureCache.get(); + final Iterator iterator = request.getIconIterator(); + + while (iterator.hasNext()) { + final IconDescriptor descriptor = iterator.next(); + + if (failureCache.isKnownFailure(descriptor.getUrl())) { + // Loading from this URL has failed in the past. Do not try again. + iterator.remove(); + } + } + } +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparation/FilterMimeTypes.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparation/FilterMimeTypes.java new file mode 100644 index 00000000000..746c37b2813 --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparation/FilterMimeTypes.java @@ -0,0 +1,44 @@ +/* + * 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/. + */ + +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * 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.preparation; + +import android.text.TextUtils; +import mozilla.components.browser.icons.IconDescriptor; +import mozilla.components.browser.icons.IconRequest; +import mozilla.components.browser.icons.IconsHelper; + +import java.util.Iterator; + +/** + * Preparer implementation to filter unknown MIME types to avoid loading images that we cannot decode. + */ +public class FilterMimeTypes implements Preparer { + @Override + public void prepare(IconRequest request) { + final Iterator iterator = request.getIconIterator(); + + while (iterator.hasNext()) { + final IconDescriptor descriptor = iterator.next(); + final String mimeType = descriptor.getMimeType(); + + if (TextUtils.isEmpty(mimeType)) { + // We do not have a MIME type for this icon, so we cannot know in advance if we are able + // to decode it. Let's just continue. + continue; + } + + if (!IconsHelper.canDecodeType(mimeType)) { + iterator.remove(); + } + } + } +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparation/FilterPrivilegedUrls.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparation/FilterPrivilegedUrls.java new file mode 100644 index 00000000000..8c99b19deed --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparation/FilterPrivilegedUrls.java @@ -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.preparation; + +import mozilla.components.browser.icons.IconDescriptor; +import mozilla.components.browser.icons.IconRequest; +import mozilla.components.support.ktx.kotlin.StringKt; + +import java.util.Iterator; + +/** + * Filter non http/https URLs if the request is not from privileged code. + */ +public class FilterPrivilegedUrls implements Preparer { + @Override + public void prepare(IconRequest request) { + if (request.isPrivileged()) { + // This request is privileged. No need to filter anything. + return; + } + + final Iterator iterator = request.getIconIterator(); + + while (iterator.hasNext()) { + IconDescriptor descriptor = iterator.next(); + + if (!StringKt.isHttpOrHttps(descriptor.getUrl())) { + iterator.remove(); + } + } + } +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparation/LookupIconUrl.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparation/LookupIconUrl.java new file mode 100644 index 00000000000..7836aa25642 --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparation/LookupIconUrl.java @@ -0,0 +1,44 @@ +/* + * 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/. + */ + +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * 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.preparation; + +import mozilla.components.browser.icons.IconDescriptor; +import mozilla.components.browser.icons.IconRequest; +import mozilla.components.browser.icons.storage.MemoryStorage; + +/** + * Preparer implementation to lookup the icon URL for the page URL in the request. This class tries + * to locate the icon URL by looking through previously stored mappings on disk and in memory. + */ +public class LookupIconUrl implements Preparer { + @Override + public void prepare(IconRequest request) { + if (lookupFromMemory(request)) { + return; + } + } + + private boolean lookupFromMemory(IconRequest request) { + final String iconUrl = MemoryStorage.get() + .getMapping(request.getPageUrl()); + + if (iconUrl != null) { + request.modify() + .icon(IconDescriptor.createLookupIcon(iconUrl)) + .deferBuild(); + + return true; + } + + return false; + } +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparation/Preparer.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparation/Preparer.java new file mode 100644 index 00000000000..bb2704f3b50 --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/preparation/Preparer.java @@ -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/. + */ + +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * 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.preparation; + + +import mozilla.components.browser.icons.IconRequest; + +/** + * Generic interface for a class "preparing" a request before we try to load icons. A class + * implementing this interface can modify the request (e.g. filter or add icon URLs). + */ +public interface Preparer { + /** + * Inspects or modifies the request before any icon is loaded. + */ + void prepare(IconRequest request); +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/processing/ColorProcessor.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/processing/ColorProcessor.java new file mode 100644 index 00000000000..931e73e1301 --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/processing/ColorProcessor.java @@ -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/. + */ + +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * 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.processing; + +import android.graphics.Bitmap; +import android.support.annotation.ColorInt; +import mozilla.components.browser.icons.IconRequest; +import mozilla.components.browser.icons.IconResponse; +import mozilla.components.support.utils.BitmapUtils; + +/** + * Processor implementation to extract the dominant color from the icon and attach it to the icon + * response object. + */ +public class ColorProcessor implements Processor { + private static final int DEFAULT_COLOR = 0xFFB1B1B3; // 0 == No color, here we use photon color + + @Override + public void process(IconRequest request, IconResponse response) { + if (response.hasColor()) { + return; + } + + final Bitmap bitmap = response.getBitmap(); + + final @ColorInt Integer edgeColor = getEdgeColor(bitmap); + if (edgeColor != null) { + response.updateColor(edgeColor); + return; + } + + final @ColorInt int dominantColor = BitmapUtils.getDominantColor(response.getBitmap(), DEFAULT_COLOR); + response.updateColor(dominantColor & 0x7FFFFFFF); + } + + /** + * If a bitmap has a consistent edge colour (i.e. if all the border pixels have the same colour), + * return that colour. + * @param bitmap The Bitmap in question. + * @return The edge colour. null if there is no consistent edge color. + */ + @ColorInt + private Integer getEdgeColor(final Bitmap bitmap) { + final int width = bitmap.getWidth(); + final int height = bitmap.getHeight(); + + // Only allocate an array once, with the max width we need once, to minimise the number + // of allocations. + @ColorInt int[] edge = new int[Math.max(width, height)]; + + // Top: + bitmap.getPixels(edge, 0, width, 0, 0, width, 1); + final @ColorInt Integer edgeColor = getEdgeColorFromSingleDimension(edge, width); + if (edgeColor == null) { + return null; + } + + // Bottom: + bitmap.getPixels(edge, 0, width, 0, height - 1, width, 1); + if (!edgeColor.equals(getEdgeColorFromSingleDimension(edge, width))) { + return null; + } + + // Left: + bitmap.getPixels(edge, 0, 1, 0, 0, 1, height); + if (!edgeColor.equals(getEdgeColorFromSingleDimension(edge, height))) { + return null; + } + + // Right: + bitmap.getPixels(edge, 0, 1, width - 1, 0, 1, height); + if (!edgeColor.equals(getEdgeColorFromSingleDimension(edge, height))) { + return null; + } + + return edgeColor; + } + + /** + * Obtain the colour for a given edge if all colors are the same. + * + * @param edge An array containing the color values of the pixels constituting the edge of a bitmap. + * @param length The length of the array to be traversed. Must be smaller than, or equal to + * the total length of the array. + * @return The colour contained within the array, or null if colours vary. + */ + @ColorInt + private Integer getEdgeColorFromSingleDimension(@ColorInt int[] edge, int length) { + @ColorInt int color = edge[0]; + + for (int i = 1; i < length; ++i) { + if (edge[i] != color) { + return null; + } + } + + return color; + } +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/processing/MemoryProcessor.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/processing/MemoryProcessor.java new file mode 100644 index 00000000000..1db409a85df --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/processing/MemoryProcessor.java @@ -0,0 +1,44 @@ +/* + * 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/. + */ + +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * 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.processing; + +import mozilla.components.browser.icons.IconRequest; +import mozilla.components.browser.icons.IconResponse; +import mozilla.components.browser.icons.storage.MemoryStorage; + +public class MemoryProcessor implements Processor { + private final MemoryStorage storage; + + public MemoryProcessor() { + storage = MemoryStorage.get(); + } + + @Override + public void process(IconRequest request, IconResponse response) { + if (request.shouldSkipMemory() || request.getIconCount() == 0 || response.isGenerated()) { + // Do not cache this icon in memory if we should skip the memory cache or if this icon + // has been generated. We can re-generate it if needed. + return; + } + + final String iconUrl = request.getBestIcon().getUrl(); + + if (iconUrl.startsWith("data:image/")) { + // The image data is encoded in the URL. It doesn't make sense to store the URL and the + // bitmap in cache. + return; + } + + storage.putMapping(request, iconUrl); + storage.putIcon(iconUrl, response); + } +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/processing/MinimumSizeProcessor.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/processing/MinimumSizeProcessor.java new file mode 100644 index 00000000000..9c2fb8103e5 --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/processing/MinimumSizeProcessor.java @@ -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/. + */ + +/* 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.processing; + +import mozilla.components.browser.icons.IconRequest; +import mozilla.components.browser.icons.IconResponse; +import mozilla.components.browser.icons.loader.IconGenerator; + +/** + * Substitutes a generated image for the given icon if it doesn't not meet the minimum size requirement. + * + * Ideally, we would discard images while we're loading them: if we found an icon that was too small, we + * would have the opportunity to search other places before generating an icon and we wouldn't duplicate + * the generate icon call. However, this turned out to be non-trivial: a single icon will appear as two + * different sizes when given to the loader, the original size the first time it's loaded and the size after + * {@link ResizingProcessor} the second time, when it's loaded from the cache. It turned out to be much simpler + * to enforce the requirement that... + * + * This processor is expected to be called after {@link ResizingProcessor}. + */ +public class MinimumSizeProcessor implements Processor { + + @Override + public void process(final IconRequest request, final IconResponse response) { + // We expect that this bitmap has already been scaled by ResizingProcessor. + if (response.getBitmap().getWidth() >= request.getMinimumSizePxAfterScaling()) { + return; + } + + // This is fragile: ideally, we can return the generated response but instead we're mutating the argument. + final IconResponse generatedResponse = IconGenerator.generate(request.getContext(), request.getPageUrl()); + response.updateBitmap(generatedResponse.getBitmap()); + response.updateColor(generatedResponse.getColor()); + } +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/processing/Processor.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/processing/Processor.java new file mode 100644 index 00000000000..47ca1dbee64 --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/processing/Processor.java @@ -0,0 +1,28 @@ +/* + * 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/. + */ + +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * 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.processing; + + +import mozilla.components.browser.icons.IconRequest; +import mozilla.components.browser.icons.IconResponse; + +/** + * Generic interface for a class that processes a response object after an icon has been loaded and + * decoded. A class implementing this interface can attach additional data to the response or modify + * the bitmap (e.g. resizing). + */ +public interface Processor { + /** + * Process a response object containing an icon loaded for this request. + */ + void process(IconRequest request, IconResponse response); +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/processing/ResizingProcessor.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/processing/ResizingProcessor.java new file mode 100644 index 00000000000..34a14a1a01d --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/processing/ResizingProcessor.java @@ -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/. + */ + +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * 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.processing; + +import android.graphics.Bitmap; +import android.support.annotation.VisibleForTesting; +import mozilla.components.browser.icons.IconRequest; +import mozilla.components.browser.icons.IconResponse; + +/** + * Processor implementation for resizing the loaded icon based on the target size. + */ +public class ResizingProcessor implements Processor { + // This is the largest factor we'll scale up an image by: the goal is an image + // that both fills the top site space well but does not have extreme resizing + // artifacts. This number was chosen anecdotally by comparing variously-sized + // favicons across devices to see which factor(s) looked the best. bug 1398970 + // is filed to take a more comprehensive approach to favicons. + public static final int MAX_SCALE_FACTOR = 3; + + @Override + public void process(IconRequest request, IconResponse response) { + if (response.isFromMemory()) { + // This bitmap has been loaded from memory, so it has already gone through the resizing + // process. We do not want to resize the image every time we hit the memory cache. + return; + } + + final Bitmap originalBitmap = response.getBitmap(); + final int size = originalBitmap.getWidth(); + + final int targetSize = request.getTargetSize(); + + if (size == targetSize) { + // The bitmap has exactly the size we are looking for. + return; + } + + final Bitmap resizedBitmap; + + if (size > targetSize) { + resizedBitmap = 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. + final int largestSize = size * MAX_SCALE_FACTOR; + + if (largestSize > targetSize) { + // Perfect! We can upscale by less than 2x and reach the needed size. Do it. + resizedBitmap = resize(originalBitmap, targetSize); + } else { + // We don't have enough information to make the target size look non terrible. Best effort: + resizedBitmap = resize(originalBitmap, largestSize); + } + } + + response.updateBitmap(resizedBitmap); + + originalBitmap.recycle(); + } + + @VisibleForTesting + Bitmap resize(Bitmap bitmap, int targetSize) { + try { + return Bitmap.createScaledBitmap(bitmap, targetSize, targetSize, true); + } catch (OutOfMemoryError error) { + // There's not enough memory to create a resized copy of the bitmap in memory. Let's just + // use what we have. + return bitmap; + } + } +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/storage/FailureCache.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/storage/FailureCache.java new file mode 100644 index 00000000000..54c7c808276 --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/storage/FailureCache.java @@ -0,0 +1,77 @@ +/* + * 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/. + */ + +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * 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.storage; + +import android.os.SystemClock; +import android.support.annotation.NonNull; +import android.support.annotation.VisibleForTesting; +import android.util.LruCache; + +/** + * In-memory cache to remember URLs from which loading icons has failed recently. + */ +public class FailureCache { + /** + * Retry loading failed icons after 4 hours. + */ + private static final long FAILURE_RETRY_MILLISECONDS = 1000 * 60 * 60 * 4; + + private static final int MAX_ENTRIES = 25; + + private static FailureCache instance; + + public static synchronized FailureCache get() { + if (instance == null) { + instance = new FailureCache(); + } + + return instance; + } + + private final LruCache cache; + + private FailureCache() { + cache = new LruCache<>(MAX_ENTRIES); + } + + /** + * Remember this icon URL after loading from it (over the network) has failed. + */ + public void rememberFailure(@NonNull String iconUrl) { + cache.put(iconUrl, SystemClock.elapsedRealtime()); + } + + /** + * Has loading from this URL failed previously and recently? + */ + public boolean isKnownFailure(@NonNull String iconUrl) { + synchronized (cache) { + final Long failedAt = cache.get(iconUrl); + if (failedAt == null) { + return false; + } + + if (failedAt + FAILURE_RETRY_MILLISECONDS < SystemClock.elapsedRealtime()) { + // The wait time has passed and we can retry loading from this URL. + cache.remove(iconUrl); + return false; + } + } + + return true; + } + + @VisibleForTesting + public void evictAll() { + cache.evictAll(); + } +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/storage/MemoryStorage.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/storage/MemoryStorage.java new file mode 100644 index 00000000000..172057beccc --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/storage/MemoryStorage.java @@ -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/. + */ + +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * 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.storage; + +import android.graphics.Bitmap; +import android.support.annotation.Nullable; +import android.util.LruCache; +import mozilla.components.browser.icons.IconRequest; +import mozilla.components.browser.icons.IconResponse; + +/** + * Least Recently Used (LRU) memory cache for icons and the mappings from page URLs to icon URLs. + */ +public class MemoryStorage { + /** + * Maximum number of items in the cache for mapping page URLs to icon URLs. + */ + private static final int MAPPING_CACHE_SIZE = 500; + + private static MemoryStorage instance; + + public static synchronized MemoryStorage get() { + if (instance == null) { + instance = new MemoryStorage(); + } + + return instance; + } + + /** + * Class representing an cached icon. We store the original bitmap and the color in cache only. + */ + private static class CacheEntry { + private final Bitmap bitmap; + private final int color; + + private CacheEntry(Bitmap bitmap, int color) { + this.bitmap = bitmap; + this.color = color; + } + } + + private final LruCache iconCache; // Guarded by 'this' + private final LruCache mappingCache; // Guarded by 'this' + + private MemoryStorage() { + iconCache = new LruCache(calculateCacheSize()) { + @Override + protected int sizeOf(String key, CacheEntry value) { + return value.bitmap.getByteCount() / 1024; + } + }; + + mappingCache = new LruCache<>(MAPPING_CACHE_SIZE); + } + + private int calculateCacheSize() { + // Use a maximum of 1/8 of the available memory for storing cached icons. + int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024); + return maxMemory / 8; + } + + /** + * Store a mapping from page URL to icon URL in the cache. + */ + public synchronized void putMapping(IconRequest request, String iconUrl) { + mappingCache.put(request.getPageUrl(), iconUrl); + } + + /** + * Get the icon URL for this page URL. Returns null if no mapping is in the cache. + */ + @Nullable + public synchronized String getMapping(String pageUrl) { + return mappingCache.get(pageUrl); + } + + /** + * Store an icon in the cache (uses the icon URL as key). + */ + public synchronized void putIcon(String url, IconResponse response) { + final CacheEntry entry = new CacheEntry(response.getBitmap(), response.getColor()); + + iconCache.put(url, entry); + } + + /** + * Get an icon for the icon URL from the cache. Returns null if no icon is cached for this URL. + */ + @Nullable + public synchronized IconResponse getIcon(String iconUrl) { + final CacheEntry entry = iconCache.get(iconUrl); + if (entry == null) { + return null; + } + + return IconResponse.createFromMemory(entry.bitmap, iconUrl, entry.color); + } + + /** + * Remove all entries from this cache. + */ + public synchronized void evictAll() { + iconCache.evictAll(); + mappingCache.evictAll(); + } +} diff --git a/components/browser/icons/src/main/java/mozilla/components/browser/icons/util/ProxySelector.java b/components/browser/icons/src/main/java/mozilla/components/browser/icons/util/ProxySelector.java new file mode 100644 index 00000000000..bf46cf311d9 --- /dev/null +++ b/components/browser/icons/src/main/java/mozilla/components/browser/icons/util/ProxySelector.java @@ -0,0 +1,145 @@ +/* + * 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 code is based on AOSP /libcore/luni/src/main/java/java/net/ProxySelectorImpl.java + +package mozilla.components.browser.icons.util; + +import android.support.annotation.Nullable; +import android.text.TextUtils; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.URI; +import java.net.URLConnection; +import java.util.List; + +public class ProxySelector { + public static URLConnection openConnectionWithProxy(URI uri) throws IOException { + java.net.ProxySelector ps = java.net.ProxySelector.getDefault(); + Proxy proxy = Proxy.NO_PROXY; + if (ps != null) { + List proxies = ps.select(uri); + if (proxies != null && !proxies.isEmpty()) { + proxy = proxies.get(0); + } + } + + return uri.toURL().openConnection(proxy); + } + + public ProxySelector() { + } + + public Proxy select(String scheme, String host) { + int port = -1; + Proxy proxy = null; + String nonProxyHostsKey = null; + boolean httpProxyOkay = true; + if ("http".equalsIgnoreCase(scheme)) { + port = 80; + nonProxyHostsKey = "http.nonProxyHosts"; + proxy = lookupProxy("http.proxyHost", "http.proxyPort", Proxy.Type.HTTP, port); + } else if ("https".equalsIgnoreCase(scheme)) { + port = 443; + nonProxyHostsKey = "https.nonProxyHosts"; // RI doesn't support this + proxy = lookupProxy("https.proxyHost", "https.proxyPort", Proxy.Type.HTTP, port); + } else if ("ftp".equalsIgnoreCase(scheme)) { + port = 80; // not 21 as you might guess + nonProxyHostsKey = "ftp.nonProxyHosts"; + proxy = lookupProxy("ftp.proxyHost", "ftp.proxyPort", Proxy.Type.HTTP, port); + } else if ("socket".equalsIgnoreCase(scheme)) { + httpProxyOkay = false; + } else { + return Proxy.NO_PROXY; + } + + if (nonProxyHostsKey != null + && isNonProxyHost(host, System.getProperty(nonProxyHostsKey))) { + return Proxy.NO_PROXY; + } + + if (proxy != null) { + return proxy; + } + + if (httpProxyOkay) { + proxy = lookupProxy("proxyHost", "proxyPort", Proxy.Type.HTTP, port); + if (proxy != null) { + return proxy; + } + } + + proxy = lookupProxy("socksProxyHost", "socksProxyPort", Proxy.Type.SOCKS, 1080); + if (proxy != null) { + return proxy; + } + + return Proxy.NO_PROXY; + } + + /** + * Returns the proxy identified by the {@code hostKey} system property, or + * null. + */ + @Nullable + private Proxy lookupProxy(String hostKey, String portKey, Proxy.Type type, int defaultPort) { + final String host = System.getProperty(hostKey); + if (TextUtils.isEmpty(host)) { + return null; + } + + final int port = getSystemPropertyInt(portKey, defaultPort); + if (port == -1) { + // Port can be -1. See bug 1270529. + return null; + } + + return new Proxy(type, InetSocketAddress.createUnresolved(host, port)); + } + + private int getSystemPropertyInt(String key, int defaultValue) { + String string = System.getProperty(key); + if (string != null) { + try { + return Integer.parseInt(string); + } catch (NumberFormatException ignored) { + } + } + return defaultValue; + } + + /** + * Returns true if the {@code nonProxyHosts} system property pattern exists + * and matches {@code host}. + */ + private boolean isNonProxyHost(String host, String nonProxyHosts) { + if (host == null || nonProxyHosts == null) { + return false; + } + + // construct pattern + StringBuilder patternBuilder = new StringBuilder(); + for (int i = 0; i < nonProxyHosts.length(); i++) { + char c = nonProxyHosts.charAt(i); + switch (c) { + case '.': + patternBuilder.append("\\."); + break; + case '*': + patternBuilder.append(".*"); + break; + default: + patternBuilder.append(c); + } + } + // check whether the host is the nonProxyHosts. + String pattern = patternBuilder.toString(); + return host.matches(pattern); + } +} + diff --git a/components/browser/icons/src/main/res/values/dimens.xml b/components/browser/icons/src/main/res/values/dimens.xml new file mode 100644 index 00000000000..d42c5f2fd5e --- /dev/null +++ b/components/browser/icons/src/main/res/values/dimens.xml @@ -0,0 +1,22 @@ + + + + + + 112dp + 2dp + + 112dp + + 24dp + + \ No newline at end of file diff --git a/components/browser/icons/src/test/java/mozilla/components/browser/icons/TestIconDescriptor.java b/components/browser/icons/src/test/java/mozilla/components/browser/icons/TestIconDescriptor.java new file mode 100644 index 00000000000..d64ec3bf335 --- /dev/null +++ b/components/browser/icons/src/test/java/mozilla/components/browser/icons/TestIconDescriptor.java @@ -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; + +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class TestIconDescriptor { + private static final String ICON_URL = "https://www.mozilla.org/favicon.ico"; + private static final String MIME_TYPE = "image/png"; + private static final int ICON_SIZE = 64; + + @Test + public void testGenericIconDescriptor() { + final IconDescriptor descriptor = IconDescriptor.createGenericIcon(ICON_URL); + + Assert.assertEquals(ICON_URL, descriptor.getUrl()); + Assert.assertNull(descriptor.getMimeType()); + Assert.assertEquals(0, descriptor.getSize()); + Assert.assertEquals(IconDescriptor.TYPE_GENERIC, descriptor.getType()); + } + + @Test + public void testFaviconIconDescriptor() { + final IconDescriptor descriptor = IconDescriptor.createFavicon(ICON_URL, ICON_SIZE, MIME_TYPE); + + Assert.assertEquals(ICON_URL, descriptor.getUrl()); + Assert.assertEquals(MIME_TYPE, descriptor.getMimeType()); + Assert.assertEquals(ICON_SIZE, descriptor.getSize()); + Assert.assertEquals(IconDescriptor.TYPE_FAVICON, descriptor.getType()); + } + + @Test + public void testTouchIconDescriptor() { + final IconDescriptor descriptor = IconDescriptor.createTouchicon(ICON_URL, ICON_SIZE, MIME_TYPE); + + Assert.assertEquals(ICON_URL, descriptor.getUrl()); + Assert.assertEquals(MIME_TYPE, descriptor.getMimeType()); + Assert.assertEquals(ICON_SIZE, descriptor.getSize()); + Assert.assertEquals(IconDescriptor.TYPE_TOUCHICON, descriptor.getType()); + } + + @Test + public void testLookupIconDescriptor() { + final IconDescriptor descriptor = IconDescriptor.createLookupIcon(ICON_URL); + + Assert.assertEquals(ICON_URL, descriptor.getUrl()); + Assert.assertNull(descriptor.getMimeType()); + Assert.assertEquals(0, descriptor.getSize()); + Assert.assertEquals(IconDescriptor.TYPE_LOOKUP, descriptor.getType()); + } + + @Test + public void testBundledTileIconDescriptor() { + final IconDescriptor descriptor = IconDescriptor.createBundledTileIcon(ICON_URL); + + Assert.assertEquals(ICON_URL, descriptor.getUrl()); + Assert.assertNull(descriptor.getMimeType()); + Assert.assertEquals(0, descriptor.getSize()); + Assert.assertEquals(IconDescriptor.TYPE_BUNDLED_TILE, descriptor.getType()); + } +} diff --git a/components/browser/icons/src/test/java/mozilla/components/browser/icons/TestIconDescriptorComparator.java b/components/browser/icons/src/test/java/mozilla/components/browser/icons/TestIconDescriptorComparator.java new file mode 100644 index 00000000000..0b0ff7f4cd3 --- /dev/null +++ b/components/browser/icons/src/test/java/mozilla/components/browser/icons/TestIconDescriptorComparator.java @@ -0,0 +1,154 @@ +/* + * 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 org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.util.TreeSet; + +@RunWith(RobolectricTestRunner.class) +public class TestIconDescriptorComparator { + private static final String TEST_ICON_URL_1 = "http://www.mozilla.org/favicon.ico"; + private static final String TEST_ICON_URL_2 = "http://www.example.org/favicon.ico"; + private static final String TEST_ICON_URL_3 = "http://www.example.com/favicon.ico"; + + private static final String TEST_MIME_TYPE = "image/png"; + private static final int TEST_SIZE = 32; + + @Test + public void testIconsWithTheSameUrlAreTreatedAsEqual() { + final IconDescriptor descriptor1 = IconDescriptor.createGenericIcon(TEST_ICON_URL_1); + final IconDescriptor descriptor2 = IconDescriptor.createGenericIcon(TEST_ICON_URL_1); + + final IconDescriptorComparator comparator = new IconDescriptorComparator(); + + Assert.assertEquals(0, comparator.compare(descriptor1, descriptor2)); + Assert.assertEquals(0, comparator.compare(descriptor2, descriptor1)); + } + + @Test + public void testTouchIconsAreRankedHigherThanFavicons() { + final IconDescriptor faviconDescriptor = IconDescriptor.createFavicon(TEST_ICON_URL_1, TEST_SIZE, TEST_MIME_TYPE); + final IconDescriptor touchIconDescriptor = IconDescriptor.createTouchicon(TEST_ICON_URL_2, TEST_SIZE, TEST_MIME_TYPE); + + final IconDescriptorComparator comparator = new IconDescriptorComparator(); + + Assert.assertEquals(1, comparator.compare(faviconDescriptor, touchIconDescriptor)); + Assert.assertEquals(-1, comparator.compare(touchIconDescriptor, faviconDescriptor)); + } + + @Test + public void testFaviconsAndTouchIconsAreRankedHigherThanGenericIcons() { + final IconDescriptor genericDescriptor = IconDescriptor.createGenericIcon(TEST_ICON_URL_1); + + final IconDescriptor faviconDescriptor = IconDescriptor.createFavicon(TEST_ICON_URL_2, TEST_SIZE, TEST_MIME_TYPE); + final IconDescriptor touchIconDescriptor = IconDescriptor.createTouchicon(TEST_ICON_URL_3, TEST_SIZE, TEST_MIME_TYPE); + + final IconDescriptorComparator comparator = new IconDescriptorComparator(); + + Assert.assertEquals(1, comparator.compare(genericDescriptor, faviconDescriptor)); + Assert.assertEquals(-1, comparator.compare(faviconDescriptor, genericDescriptor)); + + Assert.assertEquals(1, comparator.compare(genericDescriptor, touchIconDescriptor)); + Assert.assertEquals(-1, comparator.compare(touchIconDescriptor, genericDescriptor)); + } + + @Test + public void testLookupIconsAreRankedHigherThanGenericIcons() { + final IconDescriptor genericDescriptor = IconDescriptor.createGenericIcon(TEST_ICON_URL_1); + final IconDescriptor lookupDescriptor = IconDescriptor.createLookupIcon(TEST_ICON_URL_2); + + final IconDescriptorComparator comparator = new IconDescriptorComparator(); + + Assert.assertEquals(1, comparator.compare(genericDescriptor, lookupDescriptor)); + Assert.assertEquals(-1, comparator.compare(lookupDescriptor, genericDescriptor)); + } + + @Test + public void testFaviconsAndTouchIconsAreRankedHigherThanLookupIcons() { + final IconDescriptor lookupDescriptor = IconDescriptor.createLookupIcon(TEST_ICON_URL_1); + + final IconDescriptor faviconDescriptor = IconDescriptor.createFavicon(TEST_ICON_URL_2, TEST_SIZE, TEST_MIME_TYPE); + final IconDescriptor touchIconDescriptor = IconDescriptor.createTouchicon(TEST_ICON_URL_3, TEST_SIZE, TEST_MIME_TYPE); + + final IconDescriptorComparator comparator = new IconDescriptorComparator(); + + Assert.assertEquals(1, comparator.compare(lookupDescriptor, faviconDescriptor)); + Assert.assertEquals(-1, comparator.compare(faviconDescriptor, lookupDescriptor)); + + Assert.assertEquals(1, comparator.compare(lookupDescriptor, touchIconDescriptor)); + Assert.assertEquals(-1, comparator.compare(touchIconDescriptor, lookupDescriptor)); + } + + @Test + public void testLargestIconOfSameTypeIsSelected() { + final IconDescriptor smallDescriptor = IconDescriptor.createFavicon(TEST_ICON_URL_1, 16, TEST_MIME_TYPE); + final IconDescriptor largeDescriptor = IconDescriptor.createFavicon(TEST_ICON_URL_2, 128, TEST_MIME_TYPE); + + final IconDescriptorComparator comparator = new IconDescriptorComparator(); + + Assert.assertEquals(1, comparator.compare(smallDescriptor, largeDescriptor)); + Assert.assertEquals(-1, comparator.compare(largeDescriptor, smallDescriptor)); + } + + @Test + public void testContainerTypesArePreferred() { + final IconDescriptor containerDescriptor = IconDescriptor.createFavicon(TEST_ICON_URL_1, TEST_SIZE, "image/x-icon"); + final IconDescriptor faviconDescriptor = IconDescriptor.createFavicon(TEST_ICON_URL_2, TEST_SIZE, "image/png"); + + final IconDescriptorComparator comparator = new IconDescriptorComparator(); + + Assert.assertEquals(1, comparator.compare(faviconDescriptor, containerDescriptor)); + Assert.assertEquals(-1, comparator.compare(containerDescriptor, faviconDescriptor)); + } + + @Test + public void testWithNoDifferences() { + final IconDescriptor descriptor1 = IconDescriptor.createFavicon(TEST_ICON_URL_1, TEST_SIZE, TEST_MIME_TYPE); + final IconDescriptor descriptor2 = IconDescriptor.createFavicon(TEST_ICON_URL_2, TEST_SIZE, TEST_MIME_TYPE); + + final IconDescriptorComparator comparator = new IconDescriptorComparator(); + + Assert.assertNotEquals(0, comparator.compare(descriptor1, descriptor2)); + Assert.assertNotEquals(0, comparator.compare(descriptor2, descriptor1)); + } + + @Test + public void testWithSameObject() { + final IconDescriptor descriptor = IconDescriptor.createTouchicon(TEST_ICON_URL_1, TEST_SIZE, TEST_MIME_TYPE); + + final IconDescriptorComparator comparator = new IconDescriptorComparator(); + Assert.assertEquals(0, comparator.compare(descriptor, descriptor)); + } + + /** + * This test reconstructs the scenario from bug 1331808. A comparator implementation that does + * not return a consistent order can break the implementation of remove() of the TreeSet class. + */ + @Test + public void testBug1331808() { + TreeSet set = new TreeSet<>(new IconDescriptorComparator()); + + set.add(IconDescriptor.createFavicon("http://example.org/new-logo32.jpg", 0, "")); + set.add(IconDescriptor.createTouchicon("http://example.org/new-logo57.jpg", 0, "")); + set.add(IconDescriptor.createTouchicon("http://example.org/new-logo76.jpg", 76, "")); + set.add(IconDescriptor.createTouchicon("http://example.org/new-logo120.jpg", 120, "")); + set.add(IconDescriptor.createTouchicon("http://example.org/new-logo152.jpg", 114, "")); + set.add(IconDescriptor.createFavicon("http://example.org/02.png", 32, "")); + set.add(IconDescriptor.createFavicon("http://example.org/01.png", 192, "")); + set.add(IconDescriptor.createTouchicon("http://example.org/03.png", 0, "")); + + for (int i = 8; i > 0; i--) { + Assert.assertEquals("items in set before deleting: " + i, i, set.size()); + Assert.assertTrue("item removed successfully: " + i, set.remove(set.first())); + Assert.assertEquals("items in set after deleting: " + i, i - 1, set.size()); + } + } +} diff --git a/components/browser/icons/src/test/java/mozilla/components/browser/icons/TestIconRequest.java b/components/browser/icons/src/test/java/mozilla/components/browser/icons/TestIconRequest.java new file mode 100644 index 00000000000..677809a1e18 --- /dev/null +++ b/components/browser/icons/src/test/java/mozilla/components/browser/icons/TestIconRequest.java @@ -0,0 +1,84 @@ +/* + * 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 junit.framework.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +import java.util.TreeSet; + +import static org.mockito.Matchers.anyObject; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +@RunWith(RobolectricTestRunner.class) +public class TestIconRequest { + private static final String TEST_PAGE_URL = "http://www.mozilla.org"; + private static final String TEST_ICON_URL_1 = "http://www.mozilla.org/favicon.ico"; + private static final String TEST_ICON_URL_2 = "http://www.example.org/favicon.ico"; + + @Test + public void testIconHandling() { + final IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl(TEST_PAGE_URL) + .build(); + + Assert.assertEquals(0, request.getIconCount()); + Assert.assertFalse(request.hasIconDescriptors()); + + request.modify() + .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL_1)) + .deferBuild(); + + Assert.assertEquals(1, request.getIconCount()); + Assert.assertTrue(request.hasIconDescriptors()); + + request.modify() + .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL_2)) + .deferBuild(); + + Assert.assertEquals(2, request.getIconCount()); + Assert.assertTrue(request.hasIconDescriptors()); + + Assert.assertEquals(TEST_ICON_URL_2, request.getBestIcon().getUrl()); + + request.moveToNextIcon(); + + Assert.assertEquals(1, request.getIconCount()); + Assert.assertTrue(request.hasIconDescriptors()); + + Assert.assertEquals(TEST_ICON_URL_1, request.getBestIcon().getUrl()); + + request.moveToNextIcon(); + + Assert.assertEquals(0, request.getIconCount()); + Assert.assertFalse(request.hasIconDescriptors()); + } + + /** + * If removing an icon from the internal set failed then we want to throw an exception. + */ + @Test(expected = IllegalStateException.class) + public void testMoveToNextIconThrowsException() { + final IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl(TEST_PAGE_URL) + .build(); + + @SuppressWarnings("unchecked") + //noinspection unchecked - Creating a mock of a generic type + final TreeSet icons = (TreeSet) mock(TreeSet.class); + request.icons = icons; + + //noinspection SuspiciousMethodCalls + doReturn(false).when(request.icons).remove(anyObject()); + + request.moveToNextIcon(); + } +} diff --git a/components/browser/icons/src/test/java/mozilla/components/browser/icons/TestIconRequestBuilder.java b/components/browser/icons/src/test/java/mozilla/components/browser/icons/TestIconRequestBuilder.java new file mode 100644 index 00000000000..5e636164eb7 --- /dev/null +++ b/components/browser/icons/src/test/java/mozilla/components/browser/icons/TestIconRequestBuilder.java @@ -0,0 +1,262 @@ +/* + * 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 org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +import java.util.Iterator; + +import static org.junit.Assert.fail; + +@RunWith(RobolectricTestRunner.class) +public class TestIconRequestBuilder { + private static final String TEST_PAGE_URL_1 = "http://www.mozilla.org"; + private static final String TEST_PAGE_URL_2 = "http://www.example.org"; + private static final String TEST_ICON_URL_1 = "http://www.mozilla.org/favicon.ico"; + private static final String TEST_ICON_URL_2 = "http://www.example.org/favicon.ico"; + + @Test + public void testPrivileged() { + IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl(TEST_PAGE_URL_1) + .build(); + + Assert.assertFalse(request.isPrivileged()); + + request.modify() + .privileged(true) + .deferBuild(); + + Assert.assertTrue(request.isPrivileged()); + } + + @Test + public void testPageUrl() { + IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl(TEST_PAGE_URL_1) + .build(); + + Assert.assertEquals(TEST_PAGE_URL_1, request.getPageUrl()); + + request.modify() + .pageUrl(TEST_PAGE_URL_2) + .deferBuild(); + + Assert.assertEquals(TEST_PAGE_URL_2, request.getPageUrl()); + } + + @Test + public void testIcons() { + // Initially a request is empty. + IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl(TEST_PAGE_URL_1) + .build(); + + Assert.assertEquals(0, request.getIconCount()); + + // Adding one icon URL. + request.modify() + .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL_1)) + .deferBuild(); + + Assert.assertEquals(1, request.getIconCount()); + + // Adding the same icon URL again is ignored. + request.modify() + .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL_1)) + .deferBuild(); + + Assert.assertEquals(1, request.getIconCount()); + + // Adding another new icon URL. + request.modify() + .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL_2)) + .deferBuild(); + + Assert.assertEquals(2, request.getIconCount()); + } + + @Test + public void testPrivateMode() { + IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl(TEST_PAGE_URL_1) + .build(); + + Assert.assertFalse(request.isPrivateMode()); + + request.modify() + .setPrivateMode(true) + .deferBuild(); + + Assert.assertTrue(request.isPrivateMode()); + + request.modify() + .setPrivateMode(false) + .deferBuild(); + + Assert.assertFalse(request.isPrivateMode()); + } + + @Test + public void testSkipNetwork() { + IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl(TEST_PAGE_URL_1) + .build(); + + Assert.assertFalse(request.shouldSkipNetwork()); + + request.modify() + .skipNetwork() + .deferBuild(); + + Assert.assertTrue(request.shouldSkipNetwork()); + } + + @Test + public void testSkipNetworkIf() { + IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl(TEST_PAGE_URL_1) + .build(); + + Assert.assertFalse(request.shouldSkipNetwork()); + + request.modify() + .skipNetworkIf(true) + .deferBuild(); + + Assert.assertTrue(request.shouldSkipNetwork()); + + request.modify() + .skipNetworkIf(false) + .deferBuild(); + + Assert.assertFalse(request.shouldSkipNetwork()); + } + + @Test + public void testSkipDisk() { + IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl(TEST_PAGE_URL_1) + .build(); + + Assert.assertFalse(request.shouldSkipDisk()); + + request.modify() + .skipDisk() + .deferBuild(); + + Assert.assertTrue(request.shouldSkipDisk()); + } + + @Test + public void testSkipMemory() { + IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl(TEST_PAGE_URL_1) + .build(); + + Assert.assertFalse(request.shouldSkipMemory()); + + request.modify() + .skipMemory() + .deferBuild(); + + Assert.assertTrue(request.shouldSkipMemory()); + } + + @Test + public void testSkipMemoryIf() { + IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl(TEST_PAGE_URL_1) + .build(); + + Assert.assertFalse(request.shouldSkipMemory()); + + request.modify() + .skipMemoryIf(true) + .deferBuild(); + + Assert.assertTrue(request.shouldSkipMemory()); + + request.modify() + .skipMemoryIf(false) + .deferBuild(); + + Assert.assertFalse(request.shouldSkipMemory()); + } + + @Test + public void testExecutionOnBackgroundThread() { + IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl(TEST_PAGE_URL_1) + .build(); + + Assert.assertFalse(request.shouldRunOnBackgroundThread()); + + request.modify() + .executeCallbackOnBackgroundThread() + .deferBuild(); + + Assert.assertTrue(request.shouldRunOnBackgroundThread()); + } + + @Test + public void testForLauncherIcon() { + // This code will call into GeckoAppShell to determine the launcher icon size for this configuration + //TODO: Make sure this is correct. + //GeckoAppShell.setApplicationContext(RuntimeEnvironment.application); + + IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl(TEST_PAGE_URL_1) + .build(); + + Assert.assertEquals(112, request.getTargetSize()); + + request.modify() + .forLauncherIcon() + .deferBuild(); + + Assert.assertEquals(48, request.getTargetSize()); + } + + @Test + public void testConcurrentAccess() { + IconRequestBuilder builder = Icons.with(RuntimeEnvironment.application) + .pageUrl(TEST_PAGE_URL_1) + .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL_1)) + .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL_2)); + + // Call build() twice on a builder and verify that the two objects are not the same + IconRequest request = builder.build(); + IconRequest compare = builder.build(); + Assert.assertNotSame(request, compare); + Assert.assertNotSame(request.icons, compare.icons); + + // After building call methods on the builder and verify that the previously build object is not changed + int iconCount = request.getIconCount(); + builder.icon(IconDescriptor.createGenericIcon(TEST_PAGE_URL_2)) + .deferBuild(); + int iconCountAfterBuild = request.getIconCount(); + Assert.assertEquals(iconCount, iconCountAfterBuild); + + // Iterate the TreeSet and call methods on the builder + try { + final Iterator iterator = request.icons.iterator(); + while (iterator.hasNext()) { + iterator.next(); + builder.icon(IconDescriptor.createGenericIcon(TEST_PAGE_URL_2)) + .deferBuild(); + } + } catch (Exception e) { + e.printStackTrace(); + fail("Got exception."); + } + } +} diff --git a/components/browser/icons/src/test/java/mozilla/components/browser/icons/TestIconResponse.java b/components/browser/icons/src/test/java/mozilla/components/browser/icons/TestIconResponse.java new file mode 100644 index 00000000000..fb61a122f09 --- /dev/null +++ b/components/browser/icons/src/test/java/mozilla/components/browser/icons/TestIconResponse.java @@ -0,0 +1,150 @@ +/* + * 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.Color; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import static org.mockito.Mockito.mock; + +@RunWith(RobolectricTestRunner.class) +public class TestIconResponse { + private static final String ICON_URL = "http://www.mozilla.org/favicon.ico"; + + @Test + public void testDefaultResponse() { + final Bitmap bitmap = mock(Bitmap.class); + + final IconResponse response = IconResponse.create(bitmap); + + Assert.assertEquals(bitmap, response.getBitmap()); + Assert.assertFalse(response.hasUrl()); + Assert.assertNull(response.getUrl()); + + Assert.assertFalse(response.hasColor()); + Assert.assertEquals(0, response.getColor()); + + Assert.assertFalse(response.isGenerated()); + Assert.assertFalse(response.isFromNetwork()); + Assert.assertFalse(response.isFromDisk()); + Assert.assertFalse(response.isFromMemory()); + } + + @Test + public void testNetworkResponse() { + final Bitmap bitmap = mock(Bitmap.class); + + final IconResponse response = IconResponse.createFromNetwork(bitmap, ICON_URL); + + Assert.assertEquals(bitmap, response.getBitmap()); + Assert.assertTrue(response.hasUrl()); + Assert.assertEquals(ICON_URL, response.getUrl()); + + Assert.assertFalse(response.hasColor()); + Assert.assertEquals(0, response.getColor()); + + Assert.assertFalse(response.isGenerated()); + Assert.assertTrue(response.isFromNetwork()); + Assert.assertFalse(response.isFromDisk()); + Assert.assertFalse(response.isFromMemory()); + } + + @Test + public void testGeneratedResponse() { + final Bitmap bitmap = mock(Bitmap.class); + + final IconResponse response = IconResponse.createGenerated(bitmap, Color.CYAN); + + Assert.assertEquals(bitmap, response.getBitmap()); + Assert.assertFalse(response.hasUrl()); + Assert.assertNull(response.getUrl()); + + Assert.assertTrue(response.hasColor()); + Assert.assertEquals(Color.CYAN, response.getColor()); + + Assert.assertTrue(response.isGenerated()); + Assert.assertFalse(response.isFromNetwork()); + Assert.assertFalse(response.isFromDisk()); + Assert.assertFalse(response.isFromMemory()); + } + + @Test + public void testMemoryResponse() { + final Bitmap bitmap = mock(Bitmap.class); + + final IconResponse response = IconResponse.createFromMemory(bitmap, ICON_URL, Color.CYAN); + + Assert.assertEquals(bitmap, response.getBitmap()); + Assert.assertTrue(response.hasUrl()); + Assert.assertEquals(ICON_URL, response.getUrl()); + + Assert.assertTrue(response.hasColor()); + Assert.assertEquals(Color.CYAN, response.getColor()); + + Assert.assertFalse(response.isGenerated()); + Assert.assertFalse(response.isFromNetwork()); + Assert.assertFalse(response.isFromDisk()); + Assert.assertTrue(response.isFromMemory()); + } + + @Test + public void testDiskResponse() { + final Bitmap bitmap = mock(Bitmap.class); + + final IconResponse response = IconResponse.createFromDisk(bitmap, ICON_URL); + + Assert.assertEquals(bitmap, response.getBitmap()); + Assert.assertTrue(response.hasUrl()); + Assert.assertEquals(ICON_URL, response.getUrl()); + + Assert.assertFalse(response.hasColor()); + Assert.assertEquals(0, response.getColor()); + + Assert.assertFalse(response.isGenerated()); + Assert.assertFalse(response.isFromNetwork()); + Assert.assertTrue(response.isFromDisk()); + Assert.assertFalse(response.isFromMemory()); + } + + @Test + public void testUpdatingColor() { + final IconResponse response = IconResponse.create(mock(Bitmap.class)); + + Assert.assertFalse(response.hasColor()); + Assert.assertEquals(0, response.getColor()); + + response.updateColor(Color.YELLOW); + + Assert.assertTrue(response.hasColor()); + Assert.assertEquals(Color.YELLOW, response.getColor()); + + response.updateColor(Color.MAGENTA); + + Assert.assertTrue(response.hasColor()); + Assert.assertEquals(Color.MAGENTA, response.getColor()); + } + + @Test + public void testUpdatingBitmap() { + final Bitmap originalBitmap = mock(Bitmap.class); + final Bitmap updatedBitmap = mock(Bitmap.class); + + final IconResponse response = IconResponse.create(originalBitmap); + + Assert.assertEquals(originalBitmap, response.getBitmap()); + Assert.assertNotEquals(updatedBitmap, response.getBitmap()); + + response.updateBitmap(updatedBitmap); + + Assert.assertNotEquals(originalBitmap, response.getBitmap()); + Assert.assertEquals(updatedBitmap, response.getBitmap()); + } +} diff --git a/components/browser/icons/src/test/java/mozilla/components/browser/icons/TestIconTask.java b/components/browser/icons/src/test/java/mozilla/components/browser/icons/TestIconTask.java new file mode 100644 index 00000000000..bc5febad684 --- /dev/null +++ b/components/browser/icons/src/test/java/mozilla/components/browser/icons/TestIconTask.java @@ -0,0 +1,573 @@ +/* + * 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 mozilla.components.browser.icons.loader.IconLoader; +import mozilla.components.browser.icons.preparation.Preparer; +import mozilla.components.browser.icons.processing.Processor; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.*; + +@RunWith(RobolectricTestRunner.class) +public class TestIconTask { + @Test + public void testGeneratorIsInvokedIfAllLoadersFail() { + final List loaders = Arrays.asList( + createFailingLoader(), + createFailingLoader(), + createFailingLoader()); + + final Bitmap bitmap = mock(Bitmap.class); + final IconLoader generator = createSuccessfulLoader(bitmap); + + final IconRequest request = createIconRequest(); + + final IconTask task = new IconTask( + request, + Collections.emptyList(), + loaders, + Collections.emptyList(), + generator); + + final IconResponse response = task.call(); + + // Verify all loaders have been tried + for (IconLoader loader : loaders) { + verify(loader).load(request); + } + + // Verify generator was called + verify(generator).load(request); + + // Verify response contains generated bitmap + Assert.assertEquals(bitmap, response.getBitmap()); + } + + @Test + public void testGeneratorIsNotCalledIfOneLoaderWasSuccessful() { + final List loaders = Collections.singletonList( + createSuccessfulLoader(mock(Bitmap.class))); + + final IconLoader generator = createSuccessfulLoader(mock(Bitmap.class)); + + final IconRequest request = createIconRequest(); + + final IconTask task = new IconTask( + request, + Collections.emptyList(), + loaders, + Collections.emptyList(), + generator); + + final IconResponse response = task.call(); + + // Verify all loaders have been tried + for (IconLoader loader : loaders) { + verify(loader).load(request); + } + + // Verify generator was NOT called + verify(generator, never()).load(request); + + Assert.assertNotNull(response); + } + + @Test + public void testNoLoaderIsInvokedForRequestWithoutUrls() { + final List loaders = Collections.singletonList( + createSuccessfulLoader(mock(Bitmap.class))); + + final Bitmap bitmap = mock(Bitmap.class); + final IconLoader generator = createSuccessfulLoader(bitmap); + + final IconRequest request = createIconRequestWithoutUrls(); + + final IconTask task = new IconTask( + request, + Collections.emptyList(), + loaders, + Collections.emptyList(), + generator); + + final IconResponse response = task.call(); + + // Verify NO loaders have been called + for (IconLoader loader : loaders) { + verify(loader, never()).load(request); + } + + // Verify generator was called + verify(generator).load(request); + + // Verify response contains generated bitmap + Assert.assertEquals(bitmap, response.getBitmap()); + } + + @Test + public void testAllPreparersAreCalledBeforeLoading() { + final List preparers = Arrays.asList( + mock(Preparer.class), + mock(Preparer.class), + mock(Preparer.class), + mock(Preparer.class), + mock(Preparer.class), + mock(Preparer.class)); + + final IconRequest request = createIconRequest(); + + final IconTask task = new IconTask( + request, + preparers, + createListWithSuccessfulLoader(), + Collections.emptyList(), + createGenerator()); + + task.call(); + + // Verify all preparers have been called + for (Preparer preparer : preparers) { + verify(preparer).prepare(request); + } + } + + @Test + public void testSubsequentLoadersAreNotCalledAfterSuccessfulLoad() { + final Bitmap bitmap = mock(Bitmap.class); + + final List loaders = Arrays.asList( + createFailingLoader(), + createFailingLoader(), + createSuccessfulLoader(bitmap), + createSuccessfulLoader(mock(Bitmap.class)), + createFailingLoader(), + createSuccessfulLoader(mock(Bitmap.class))); + + final IconRequest request = createIconRequest(); + + final IconTask task = new IconTask( + request, + Collections.emptyList(), + loaders, + Collections.emptyList(), + createGenerator()); + + final IconResponse response = task.call(); + + // First loaders are called + verify(loaders.get(0)).load(request); + verify(loaders.get(1)).load(request); + verify(loaders.get(2)).load(request); + + // Loaders after successful load are not called + verify(loaders.get(3), never()).load(request); + verify(loaders.get(4), never()).load(request); + verify(loaders.get(5), never()).load(request); + + Assert.assertNotNull(response); + Assert.assertEquals(bitmap, response.getBitmap()); + } + + @Test + public void testNoProcessorIsCalledForUnsuccessfulLoads() { + final IconRequest request = createIconRequest(); + + final List loaders = createListWithFailingLoaders(); + + final List processors = Arrays.asList( + createProcessor(), + createProcessor(), + createProcessor()); + + final IconTask task = new IconTask( + request, + Collections.emptyList(), + loaders, + processors, + createFailingLoader()); + + task.call(); + + // Verify all loaders have been tried + for (IconLoader loader : loaders) { + verify(loader).load(request); + } + + // Verify no processor was called + for (Processor processor : processors) { + verify(processor, never()).process(any(IconRequest.class), any(IconResponse.class)); + } + } + + @Test + public void testAllProcessorsAreCalledAfterSuccessfulLoad() { + final IconRequest request = createIconRequest(); + + final List processors = Arrays.asList( + createProcessor(), + createProcessor(), + createProcessor()); + + final IconTask task = new IconTask( + request, + Collections.emptyList(), + createListWithSuccessfulLoader(), + processors, + createGenerator()); + + IconResponse response = task.call(); + + Assert.assertNotNull(response); + + // Verify that all processors have been called + for (Processor processor : processors) { + verify(processor).process(request, response); + } + } + + @Test + public void testCallbackIsExecutedForSuccessfulLoads() { + final IconCallback callback = mock(IconCallback.class); + + final IconRequest request = createIconRequest(); + request.setCallback(callback); + + final IconTask task = new IconTask( + request, + Collections.emptyList(), + createListWithSuccessfulLoader(), + Collections.emptyList(), + createGenerator()); + + final IconResponse response = task.call(); + + verify(callback).onIconResponse(response); + } + + @Test + public void testCallbackIsNotExecutedIfLoadingFailed() { + final IconCallback callback = mock(IconCallback.class); + + final IconRequest request = createIconRequest(); + request.setCallback(callback); + + final IconTask task = new IconTask( + request, + Collections.emptyList(), + createListWithFailingLoaders(), + Collections.emptyList(), + createFailingLoader()); + + task.call(); + + verify(callback, never()).onIconResponse(any(IconResponse.class)); + } + + @Test + public void testCallbackIsExecutedWithGeneratorResult() { + final IconCallback callback = mock(IconCallback.class); + + final IconRequest request = createIconRequest(); + request.setCallback(callback); + + final IconTask task = new IconTask( + request, + Collections.emptyList(), + createListWithFailingLoaders(), + Collections.emptyList(), + createGenerator()); + + final IconResponse response = task.call(); + + verify(callback).onIconResponse(response); + } + + @Test + public void testTaskCancellationWhileLoading() { + // We simulate the cancellation by injecting a loader that interrupts the thread. + final IconLoader cancellingLoader = spy(new IconLoader() { + @Override + public IconResponse load(IconRequest request) { + Thread.currentThread().interrupt(); + return null; + } + }); + + final List preparers = createListOfPreparers(); + final List processors = createListOfProcessors(); + + final List loaders = Arrays.asList( + createFailingLoader(), + createFailingLoader(), + cancellingLoader, + createFailingLoader(), + createSuccessfulLoader(mock(Bitmap.class))); + + final IconRequest request = createIconRequest(); + + final IconTask task = new IconTask( + request, + preparers, + loaders, + processors, + createGenerator()); + + final IconResponse response = task.call(); + Assert.assertNull(response); + + // Verify that all preparers are called + for (Preparer preparer : preparers) { + verify(preparer).prepare(request); + } + + // Verify that first loaders are called + verify(loaders.get(0)).load(request); + verify(loaders.get(1)).load(request); + + // Verify that our loader that interrupts the thread is called + verify(loaders.get(2)).load(request); + + // Verify that all other loaders are not called + verify(loaders.get(3), never()).load(request); + verify(loaders.get(4), never()).load(request); + + // Verify that no processors are called + for (Processor processor : processors) { + verify(processor, never()).process(eq(request), any(IconResponse.class)); + } + } + + @Test + public void testTaskCancellationWhileProcessing() { + final Processor cancellingProcessor = spy(new Processor() { + @Override + public void process(IconRequest request, IconResponse response) { + Thread.currentThread().interrupt(); + } + }); + + final List preparers = createListOfPreparers(); + + final List loaders = Arrays.asList( + createFailingLoader(), + createFailingLoader(), + createSuccessfulLoader(mock(Bitmap.class))); + + final List processors = Arrays.asList( + createProcessor(), + createProcessor(), + cancellingProcessor, + createProcessor(), + createProcessor()); + + final IconRequest request = createIconRequest(); + + final IconTask task = new IconTask( + request, + preparers, + loaders, + processors, + createGenerator()); + + final IconResponse response = task.call(); + Assert.assertNull(response); + + // Verify that all preparers are called + for (Preparer preparer : preparers) { + verify(preparer).prepare(request); + } + + // Verify that all loaders are called + for (IconLoader loader : loaders) { + verify(loader).load(request); + } + + // Verify that first processors are called + verify(processors.get(0)).process(eq(request), any(IconResponse.class)); + verify(processors.get(1)).process(eq(request), any(IconResponse.class)); + + // Verify that cancelling processor is called + verify(processors.get(2)).process(eq(request), any(IconResponse.class)); + + // Verify that subsequent processors are not called + verify(processors.get(3), never()).process(eq(request), any(IconResponse.class)); + verify(processors.get(4), never()).process(eq(request), any(IconResponse.class)); + } + + @Test + public void testTaskCancellationWhilePerparing() { + final Preparer failingPreparer = spy(new Preparer() { + @Override + public void prepare(IconRequest request) { + Thread.currentThread().interrupt(); + } + }); + + final List preparers = Arrays.asList( + mock(Preparer.class), + mock(Preparer.class), + failingPreparer, + mock(Preparer.class), + mock(Preparer.class)); + + final List loaders = createListWithSuccessfulLoader(); + final List processors = createListOfProcessors(); + + final IconRequest request = createIconRequest(); + + final IconTask task = new IconTask( + request, + preparers, + loaders, + processors, + createGenerator()); + + final IconResponse response = task.call(); + Assert.assertNull(response); + + // Verify that first preparers are called + verify(preparers.get(0)).prepare(request); + verify(preparers.get(1)).prepare(request); + + // Verify that cancelling preparer is called + verify(preparers.get(2)).prepare(request); + + // Verify that subsequent preparers are not called + verify(preparers.get(3), never()).prepare(request); + verify(preparers.get(4), never()).prepare(request); + + // Verify that no loaders are called + for (IconLoader loader : loaders) { + verify(loader, never()).load(request); + } + + // Verify that no processors are called + for (Processor processor : processors) { + verify(processor, never()).process(eq(request), any(IconResponse.class)); + } + } + + @Test + public void testNoLoadersOrProcessorsAreExecutedForPrepareOnlyTasks() { + final List preparers = createListOfPreparers(); + final List loaders = createListWithSuccessfulLoader(); + final List processors = createListOfProcessors(); + final IconLoader generator = createGenerator(); + + final IconRequest request = createIconRequest() + .modify() + .prepareOnly() + .build(); + + final IconTask task = new IconTask( + request, + preparers, + loaders, + processors, + generator); + + IconResponse response = task.call(); + + Assert.assertNull(response); + + // Verify that all preparers are called + for (Preparer preparer : preparers) { + verify(preparer).prepare(request); + } + + // Verify that no loaders are called + for (IconLoader loader : loaders) { + verify(loader, never()).load(request); + } + + // Verify that no processors are called + for (Processor processor : processors) { + verify(processor, never()).process(eq(request), any(IconResponse.class)); + } + } + + public List createListWithSuccessfulLoader() { + return Arrays.asList( + createFailingLoader(), + createFailingLoader(), + createSuccessfulLoader(mock(Bitmap.class)), + createFailingLoader()); + } + + public List createListWithFailingLoaders() { + return Arrays.asList( + createFailingLoader(), + createFailingLoader(), + createFailingLoader(), + createFailingLoader(), + createFailingLoader()); + } + + public List createListOfPreparers() { + return Arrays.asList( + mock(Preparer.class), + mock(Preparer.class), + mock(Preparer.class), + mock(Preparer.class), + mock(Preparer.class)); + } + + public IconLoader createFailingLoader() { + final IconLoader loader = mock(IconLoader.class); + doReturn(null).when(loader).load(any(IconRequest.class)); + return loader; + } + + public IconLoader createSuccessfulLoader(Bitmap bitmap) { + IconResponse response = IconResponse.create(bitmap); + + final IconLoader loader = mock(IconLoader.class); + doReturn(response).when(loader).load(any(IconRequest.class)); + return loader; + } + + public List createListOfProcessors() { + return Arrays.asList( + mock(Processor.class), + mock(Processor.class), + mock(Processor.class), + mock(Processor.class), + mock(Processor.class)); + } + + public IconRequest createIconRequest() { + return Icons.with(RuntimeEnvironment.application) + .pageUrl("http://www.mozilla.org") + .icon(IconDescriptor.createGenericIcon("http://www.mozilla.org/favicon.ico")) + .build(); + } + + public IconRequest createIconRequestWithoutUrls() { + return Icons.with(RuntimeEnvironment.application) + .pageUrl("http://www.mozilla.org") + .build(); + } + + public IconLoader createGenerator() { + return createSuccessfulLoader(mock(Bitmap.class)); + } + + public Processor createProcessor() { + return mock(Processor.class); + } +} diff --git a/components/browser/icons/src/test/java/mozilla/components/browser/icons/TestIconsHelper.java b/components/browser/icons/src/test/java/mozilla/components/browser/icons/TestIconsHelper.java new file mode 100644 index 00000000000..cf8fcc69974 --- /dev/null +++ b/components/browser/icons/src/test/java/mozilla/components/browser/icons/TestIconsHelper.java @@ -0,0 +1,136 @@ +/* + * 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 org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +@RunWith(RobolectricTestRunner.class) +public class TestIconsHelper { + @SuppressLint("AuthLeak") // Lint and Android Studio try to prevent developers from writing code + // with credentials in the URL (user:password@host). But in this case + // we explicitly want to do that, so we suppress the warnings. + @Test + public void testGuessDefaultFaviconURL() { + // Empty values + + Assert.assertNull(IconsHelper.guessDefaultFaviconURL(null)); + Assert.assertNull(IconsHelper.guessDefaultFaviconURL("")); + Assert.assertNull(IconsHelper.guessDefaultFaviconURL(" ")); + + // Special about: URLs. + + Assert.assertEquals( + "about:home", + IconsHelper.guessDefaultFaviconURL("about:home")); + + Assert.assertEquals( + "about:firefox", + IconsHelper.guessDefaultFaviconURL("about:firefox")); + + Assert.assertEquals( + "about:addons", + IconsHelper.guessDefaultFaviconURL("about:addons")); + + // Non http(s) URLS + + Assert.assertNull(IconsHelper.guessDefaultFaviconURL("content://some.random.provider/icons")); + + Assert.assertNull(IconsHelper.guessDefaultFaviconURL("ftp://ftp.public.mozilla.org/this/is/made/up")); + + Assert.assertNull(IconsHelper.guessDefaultFaviconURL("file:///")); + + Assert.assertNull(IconsHelper.guessDefaultFaviconURL("file:///system/path")); + + // Various http(s) URLs + + Assert.assertEquals("http://www.mozilla.org/favicon.ico", + IconsHelper.guessDefaultFaviconURL("http://www.mozilla.org/")); + + Assert.assertEquals("https://www.mozilla.org/favicon.ico", + IconsHelper.guessDefaultFaviconURL("https://www.mozilla.org/en-US/firefox/products/")); + + Assert.assertEquals("https://example.org/favicon.ico", + IconsHelper.guessDefaultFaviconURL("https://example.org")); + + Assert.assertEquals("http://user:password@example.org:9991/favicon.ico", + IconsHelper.guessDefaultFaviconURL("http://user:password@example.org:9991/status/760492829949001728")); + + Assert.assertEquals("https://localhost:8888/favicon.ico", + IconsHelper.guessDefaultFaviconURL("https://localhost:8888/path/folder/file?some=query¶ms=none")); + + Assert.assertEquals("http://192.168.0.1/favicon.ico", + IconsHelper.guessDefaultFaviconURL("http://192.168.0.1/local/action.cgi")); + + Assert.assertEquals("https://medium.com/favicon.ico", + IconsHelper.guessDefaultFaviconURL("https://medium.com/firefox-mobile-engineering/firefox-for-android-hack-week-recap-f1ab12f5cc44#.rpmzz15ia")); + + // Some broken, partial URLs + + Assert.assertNull(IconsHelper.guessDefaultFaviconURL("http:")); + Assert.assertNull(IconsHelper.guessDefaultFaviconURL("http://")); + Assert.assertNull(IconsHelper.guessDefaultFaviconURL("https:/")); + } + + @Test + public void testIsContainerType() { + // Empty values + Assert.assertFalse(IconsHelper.isContainerType(null)); + Assert.assertFalse(IconsHelper.isContainerType("")); + Assert.assertFalse(IconsHelper.isContainerType(" ")); + + // Values that don't make any sense. + Assert.assertFalse(IconsHelper.isContainerType("Hello World")); + Assert.assertFalse(IconsHelper.isContainerType("no/no/no")); + Assert.assertFalse(IconsHelper.isContainerType("42")); + + // Actual image MIME types that are not container types + Assert.assertFalse(IconsHelper.isContainerType("image/png")); + Assert.assertFalse(IconsHelper.isContainerType("application/bmp")); + Assert.assertFalse(IconsHelper.isContainerType("image/gif")); + Assert.assertFalse(IconsHelper.isContainerType("image/x-windows-bitmap")); + Assert.assertFalse(IconsHelper.isContainerType("image/jpeg")); + Assert.assertFalse(IconsHelper.isContainerType("application/x-png")); + + // MIME types of image container + Assert.assertTrue(IconsHelper.isContainerType("image/vnd.microsoft.icon")); + Assert.assertTrue(IconsHelper.isContainerType("image/ico")); + Assert.assertTrue(IconsHelper.isContainerType("image/icon")); + Assert.assertTrue(IconsHelper.isContainerType("image/x-icon")); + Assert.assertTrue(IconsHelper.isContainerType("text/ico")); + Assert.assertTrue(IconsHelper.isContainerType("application/ico")); + } + + @Test + public void testCanDecodeType() { + // Empty values + Assert.assertFalse(IconsHelper.canDecodeType(null)); + Assert.assertFalse(IconsHelper.canDecodeType("")); + Assert.assertFalse(IconsHelper.canDecodeType(" ")); + + // Some things we can't decode (or that just aren't images) + Assert.assertFalse(IconsHelper.canDecodeType("image/svg+xml")); + Assert.assertFalse(IconsHelper.canDecodeType("video/avi")); + Assert.assertFalse(IconsHelper.canDecodeType("text/plain")); + Assert.assertFalse(IconsHelper.canDecodeType("image/x-quicktime")); + Assert.assertFalse(IconsHelper.canDecodeType("image/tiff")); + Assert.assertFalse(IconsHelper.canDecodeType("application/zip")); + + // Some image MIME types we definitely can decode + Assert.assertTrue(IconsHelper.canDecodeType("image/bmp")); + Assert.assertTrue(IconsHelper.canDecodeType("image/x-icon")); + Assert.assertTrue(IconsHelper.canDecodeType("image/png")); + Assert.assertTrue(IconsHelper.canDecodeType("image/jpg")); + Assert.assertTrue(IconsHelper.canDecodeType("image/jpeg")); + Assert.assertTrue(IconsHelper.canDecodeType("image/ico")); + Assert.assertTrue(IconsHelper.canDecodeType("image/icon")); + } +} diff --git a/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/TestContentProviderLoader.java b/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/TestContentProviderLoader.java new file mode 100644 index 00000000000..4fdba59ab14 --- /dev/null +++ b/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/TestContentProviderLoader.java @@ -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 mozilla.components.browser.icons.IconDescriptor; +import mozilla.components.browser.icons.IconRequest; +import mozilla.components.browser.icons.IconResponse; +import mozilla.components.browser.icons.Icons; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +@RunWith(RobolectricTestRunner.class) +public class TestContentProviderLoader { + @Test + public void testNothingIsLoadedForHttpUrls() { + final IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl("http://www.mozilla.org") + .icon(IconDescriptor.createGenericIcon( + "https://www.mozilla.org/media/img/favicon/apple-touch-icon-180x180.00050c5b754e.png")) + .build(); + + IconLoader loader = new ContentProviderLoader(); + IconResponse response = loader.load(request); + + Assert.assertNull(response); + } +} diff --git a/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/TestDataUriLoader.java b/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/TestDataUriLoader.java new file mode 100644 index 00000000000..7d4c8a98d55 --- /dev/null +++ b/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/TestDataUriLoader.java @@ -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.loader; + +import mozilla.components.browser.icons.IconDescriptor; +import mozilla.components.browser.icons.IconRequest; +import mozilla.components.browser.icons.IconResponse; +import mozilla.components.browser.icons.Icons; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +@RunWith(RobolectricTestRunner.class) +public class TestDataUriLoader { + @Test + public void testNothingIsLoadedForHttpUrls() { + final IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl("http://www.mozilla.org") + .icon(IconDescriptor.createGenericIcon( + "https://www.mozilla.org/media/img/favicon/apple-touch-icon-180x180.00050c5b754e.png")) + .build(); + + IconLoader loader = new DataUriLoader(); + IconResponse response = loader.load(request); + + Assert.assertNull(response); + } + + @Test + public void testIconIsLoadedFromDataUri() { + final IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl("http://www.mozilla.org") + .icon(IconDescriptor.createGenericIcon( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEklEQVR4AWP4z8AAxCDiP8N/AB3wBPxcBee7AAAAAElFTkSuQmCC")) + .build(); + + IconLoader loader = new DataUriLoader(); + IconResponse response = loader.load(request); + + Assert.assertNotNull(response); + Assert.assertNotNull(response.getBitmap()); + } +} diff --git a/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/TestIconDownloader.java b/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/TestIconDownloader.java new file mode 100644 index 00000000000..1f868dcb111 --- /dev/null +++ b/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/TestIconDownloader.java @@ -0,0 +1,139 @@ +/* + * 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.IconDescriptor; +import mozilla.components.browser.icons.IconRequest; +import mozilla.components.browser.icons.IconResponse; +import mozilla.components.browser.icons.Icons; +import mozilla.components.browser.icons.storage.FailureCache; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +import java.io.IOException; +import java.net.HttpURLConnection; + +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyString; +import static org.mockito.Mockito.*; + +@RunWith(RobolectricTestRunner.class) +public class TestIconDownloader { + /** + * Scenario: A request with a non HTTP URL (data:image/*) is executed. + * + * Verify that: + * * No download is performed. + */ + @Test + public void testDownloaderDoesNothingForNonHttpUrls() throws Exception { + final IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl("http://www.mozilla.org") + .icon(IconDescriptor.createGenericIcon( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEklEQVR4AWP4z8AAxCDiP8N/AB3wBPxcBee7AAAAAElFTkSuQmCC")) + .build(); + + final IconDownloader downloader = spy(new IconDownloader()); + IconResponse response = downloader.load(request); + + Assert.assertNull(response); + + verify(downloader, never()).downloadAndDecodeImage(any(Context.class), anyString()); + verify(downloader, never()).connectTo(anyString()); + } + + /** + * Scenario: Request contains an URL and server returns 301 with location header (always the same URL). + * + * Verify that: + * * Download code stops and does not loop forever. + */ + @Test + public void testRedirectsAreFollowedButNotInCircles() throws Exception { + final IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl("http://www.mozilla.org") + .icon(IconDescriptor.createFavicon( + "https://www.mozilla.org/media/img/favicon.52506929be4c.ico", + 32, + "image/x-icon")) + .build(); + + HttpURLConnection mockedConnection = mock(HttpURLConnection.class); + doReturn(301).when(mockedConnection).getResponseCode(); + doReturn("http://example.org/favicon.ico").when(mockedConnection).getHeaderField("Location"); + + final IconDownloader downloader = spy(new IconDownloader()); + doReturn(mockedConnection).when(downloader).connectTo(anyString()); + IconResponse response = downloader.load(request); + + Assert.assertNull(response); + + verify(downloader).connectTo("https://www.mozilla.org/media/img/favicon.52506929be4c.ico"); + verify(downloader).connectTo("http://example.org/favicon.ico"); + } + + /** + * Scenario: Request contains an URL and server returns HTTP 404. + * + * Verify that: + * * URL is added to failure cache. + */ + @Test + public void testUrlIsAddedToFailureCacheIfServerReturnsClientError() throws Exception { + final String faviconUrl = "https://www.mozilla.org/404.ico"; + + final IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl("http://www.mozilla.org") + .icon(IconDescriptor.createFavicon(faviconUrl, 32, "image/x-icon")) + .build(); + + HttpURLConnection mockedConnection = mock(HttpURLConnection.class); + doReturn(404).when(mockedConnection).getResponseCode(); + + Assert.assertFalse(FailureCache.get().isKnownFailure(faviconUrl)); + + final IconDownloader downloader = spy(new IconDownloader()); + doReturn(mockedConnection).when(downloader).connectTo(anyString()); + IconResponse response = downloader.load(request); + + Assert.assertNull(response); + + Assert.assertTrue(FailureCache.get().isKnownFailure(faviconUrl)); + } + + /** + * Scenario: Connected to successfully to server but reading the response code throws an exception. + * + * Verify that: + * * disconnect() is called on HttpUrlConnection + */ + @Test + public void testConnectionIsClosedWhenReadingResponseCodeThrows() throws Exception { + final IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl("http://www.mozilla.org") + .icon(IconDescriptor.createFavicon( + "https://www.mozilla.org/media/img/favicon.52506929be4c.ico", + 32, + "image/x-icon")) + .build(); + + HttpURLConnection mockedConnection = mock(HttpURLConnection.class); + doThrow(new IOException()).when(mockedConnection).getResponseCode(); + + final IconDownloader downloader = spy(new IconDownloader()); + doReturn(mockedConnection).when(downloader).connectTo(anyString()); + IconResponse response = downloader.load(request); + + Assert.assertNull(response); + + verify(mockedConnection).disconnect(); + } +} diff --git a/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/TestIconGenerator.java b/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/TestIconGenerator.java new file mode 100644 index 00000000000..68ec6334024 --- /dev/null +++ b/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/TestIconGenerator.java @@ -0,0 +1,125 @@ +/* + * 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.*; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +@RunWith(RobolectricTestRunner.class) +public class TestIconGenerator { + @Test + public void testNoIconIsGeneratorIfThereAreIconUrlsToLoadFrom() { + final IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl("http://www.mozilla.org") + .icon(IconDescriptor.createGenericIcon( + "https://www.mozilla.org/media/img/favicon/apple-touch-icon-180x180.00050c5b754e.png")) + .icon(IconDescriptor.createGenericIcon( + "https://www.mozilla.org/media/img/favicon.52506929be4c.ico")) + .build(); + + IconLoader loader = new IconGenerator(); + IconResponse response = loader.load(request); + + Assert.assertNull(response); + } + + @Test + public void testIconIsGeneratedForLastUrl() { + final IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl("http://www.mozilla.org") + .icon(IconDescriptor.createGenericIcon( + "https://www.mozilla.org/media/img/favicon/apple-touch-icon-180x180.00050c5b754e.png")) + .build(); + + IconLoader loader = new IconGenerator(); + IconResponse response = loader.load(request); + + Assert.assertNotNull(response); + Assert.assertNotNull(response.getBitmap()); + } + + @Test + public void testRepresentativeCharacter() { + Assert.assertEquals("M", IconGenerator.getRepresentativeCharacter("https://mozilla.org")); + Assert.assertEquals("W", IconGenerator.getRepresentativeCharacter("http://wikipedia.org")); + Assert.assertEquals("P", IconGenerator.getRepresentativeCharacter("http://plus.google.com")); + Assert.assertEquals("E", IconGenerator.getRepresentativeCharacter("https://en.m.wikipedia.org/wiki/Main_Page")); + + // Stripping common prefixes + Assert.assertEquals("T", IconGenerator.getRepresentativeCharacter("http://www.theverge.com")); + Assert.assertEquals("F", IconGenerator.getRepresentativeCharacter("https://m.facebook.com")); + Assert.assertEquals("T", IconGenerator.getRepresentativeCharacter("https://mobile.twitter.com")); + + // Special urls + Assert.assertEquals("?", IconGenerator.getRepresentativeCharacter("file:///")); + Assert.assertEquals("S", IconGenerator.getRepresentativeCharacter("file:///system/")); + Assert.assertEquals("P", IconGenerator.getRepresentativeCharacter("ftp://people.mozilla.org/test")); + + // No values + Assert.assertEquals("?", IconGenerator.getRepresentativeCharacter("")); + Assert.assertEquals("?", IconGenerator.getRepresentativeCharacter(null)); + + // Rubbish + Assert.assertEquals("Z", IconGenerator.getRepresentativeCharacter("zZz")); + Assert.assertEquals("Ö", IconGenerator.getRepresentativeCharacter("ölkfdpou3rkjaslfdköasdfo8")); + Assert.assertEquals("?", IconGenerator.getRepresentativeCharacter("_*+*'##")); + Assert.assertEquals("ツ", IconGenerator.getRepresentativeCharacter("¯\\_(ツ)_/¯")); + Assert.assertEquals("ಠ", IconGenerator.getRepresentativeCharacter("ಠ_ಠ Look of Disapproval")); + + // Non-ASCII + Assert.assertEquals("Ä", IconGenerator.getRepresentativeCharacter("http://www.ätzend.de")); + Assert.assertEquals("名", IconGenerator.getRepresentativeCharacter("http://名がドメイン.com")); + Assert.assertEquals("C", IconGenerator.getRepresentativeCharacter("http://√.com")); + Assert.assertEquals("ß", IconGenerator.getRepresentativeCharacter("http://ß.de")); + Assert.assertEquals("Ԛ", IconGenerator.getRepresentativeCharacter("http://ԛәлп.com/")); // cyrillic + + // Punycode + Assert.assertEquals("X", IconGenerator.getRepresentativeCharacter("http://xn--tzend-fra.de")); // ätzend.de + Assert.assertEquals("X", IconGenerator.getRepresentativeCharacter("http://xn--V8jxj3d1dzdz08w.com")); // 名がドメイン.com + + // Numbers + Assert.assertEquals("1", IconGenerator.getRepresentativeCharacter("https://www.1and1.com/")); + + // IP + Assert.assertEquals("1", IconGenerator.getRepresentativeCharacter("https://192.168.0.1")); + } + + @Test + public void testPickColor() { + final int color = IconGenerator.pickColor("http://m.facebook.com"); + + // Color does not change + for (int i = 0; i < 100; i++) { + Assert.assertEquals(color, IconGenerator.pickColor("http://m.facebook.com")); + } + + // Color is stable for "similar" hosts. + Assert.assertEquals(color, IconGenerator.pickColor("https://m.facebook.com")); + Assert.assertEquals(color, IconGenerator.pickColor("http://facebook.com")); + Assert.assertEquals(color, IconGenerator.pickColor("http://www.facebook.com")); + Assert.assertEquals(color, IconGenerator.pickColor("http://www.facebook.com/foo/bar/foobar?mobile=1")); + } + + @Test + public void testGeneratingFavicon() { + final IconResponse response = IconGenerator.generate(RuntimeEnvironment.application, "http://m.facebook.com"); + final Bitmap bitmap = response.getBitmap(); + + Assert.assertNotNull(bitmap); + + final int size = RuntimeEnvironment.application.getResources().getDimensionPixelSize(R.dimen.favicon_bg); + Assert.assertEquals(size, bitmap.getWidth()); + Assert.assertEquals(size, bitmap.getHeight()); + + Assert.assertEquals(Bitmap.Config.ARGB_8888, bitmap.getConfig()); + } +} diff --git a/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/TestMemoryLoader.java b/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/TestMemoryLoader.java new file mode 100644 index 00000000000..088f272bb8e --- /dev/null +++ b/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/TestMemoryLoader.java @@ -0,0 +1,79 @@ +/* + * 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 android.graphics.Color; +import junit.framework.Assert; +import mozilla.components.browser.icons.IconDescriptor; +import mozilla.components.browser.icons.IconRequest; +import mozilla.components.browser.icons.IconResponse; +import mozilla.components.browser.icons.Icons; +import mozilla.components.browser.icons.storage.MemoryStorage; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +import static org.mockito.Mockito.mock; + +@RunWith(RobolectricTestRunner.class) +public class TestMemoryLoader { + private static final String TEST_PAGE_URL = "http://www.mozilla.org"; + private static final String TEST_ICON_URL = "https://example.org/favicon.ico"; + + @Before + public void setUp() { + // Make sure to start with an empty memory cache. + MemoryStorage.get().evictAll(); + } + + @Test + public void testStoringAndLoadingFromMemory() { + final IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl(TEST_PAGE_URL) + .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL)) + .build(); + + final IconLoader loader = new MemoryLoader(); + + Assert.assertNull(loader.load(request)); + + final Bitmap bitmap = mock(Bitmap.class); + final IconResponse response = IconResponse.create(bitmap); + response.updateColor(Color.MAGENTA); + + MemoryStorage.get().putIcon(TEST_ICON_URL, response); + + final IconResponse loadedResponse = loader.load(request); + + Assert.assertNotNull(loadedResponse); + Assert.assertEquals(bitmap, loadedResponse.getBitmap()); + Assert.assertEquals(Color.MAGENTA, loadedResponse.getColor()); + } + + @Test + public void testNothingIsLoadedIfMemoryShouldBeSkipped() { + final IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl(TEST_PAGE_URL) + .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL)) + .skipMemory() + .build(); + + final IconLoader loader = new MemoryLoader(); + + Assert.assertNull(loader.load(request)); + + final Bitmap bitmap = mock(Bitmap.class); + final IconResponse response = IconResponse.create(bitmap); + + MemoryStorage.get().putIcon(TEST_ICON_URL, response); + + Assert.assertNull(loader.load(request)); + } +} diff --git a/components/browser/icons/src/test/java/mozilla/components/browser/icons/preparation/TestAddDefaultIconUrl.java b/components/browser/icons/src/test/java/mozilla/components/browser/icons/preparation/TestAddDefaultIconUrl.java new file mode 100644 index 00000000000..9d071a79f91 --- /dev/null +++ b/components/browser/icons/src/test/java/mozilla/components/browser/icons/preparation/TestAddDefaultIconUrl.java @@ -0,0 +1,82 @@ +/* + * 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.preparation; + +import mozilla.components.browser.icons.IconDescriptor; +import mozilla.components.browser.icons.IconRequest; +import mozilla.components.browser.icons.Icons; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +import java.util.Iterator; + +@RunWith(RobolectricTestRunner.class) +public class TestAddDefaultIconUrl { + @Test + public void testAddingDefaultUrl() { + final IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl("http://www.mozilla.org") + .icon(IconDescriptor.createTouchicon( + "https://www.mozilla.org/media/img/favicon/apple-touch-icon-180x180.00050c5b754e.png", + 180, + "image/png")) + .icon(IconDescriptor.createFavicon( + "https://www.mozilla.org/media/img/favicon.52506929be4c.ico", + 32, + "image/x-icon")) + .icon(IconDescriptor.createFavicon( + "jar:jar:wtf.png", + 16, + "image/png")) + .build(); + + + Assert.assertEquals(3, request.getIconCount()); + Assert.assertFalse(containsUrl(request, "http://www.mozilla.org/favicon.ico")); + + Preparer preparer = new AddDefaultIconUrl(); + preparer.prepare(request); + + Assert.assertEquals(4, request.getIconCount()); + Assert.assertTrue(containsUrl(request, "http://www.mozilla.org/favicon.ico")); + } + + @Test + public void testDefaultUrlIsNotAddedIfItAlreadyExists() { + final IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl("http://www.mozilla.org") + .icon(IconDescriptor.createFavicon( + "http://www.mozilla.org/favicon.ico", + 32, + "image/x-icon")) + .build(); + + Assert.assertEquals(1, request.getIconCount()); + + Preparer preparer = new AddDefaultIconUrl(); + preparer.prepare(request); + + Assert.assertEquals(1, request.getIconCount()); + } + + private boolean containsUrl(IconRequest request, String url) { + final Iterator iterator = request.getIconIterator(); + + while (iterator.hasNext()) { + IconDescriptor descriptor = iterator.next(); + + if (descriptor.getUrl().equals(url)) { + return true; + } + } + + return false; + } +} diff --git a/components/browser/icons/src/test/java/mozilla/components/browser/icons/preparation/TestFilterKnownFailureUrls.java b/components/browser/icons/src/test/java/mozilla/components/browser/icons/preparation/TestFilterKnownFailureUrls.java new file mode 100644 index 00000000000..0e91cdcdac5 --- /dev/null +++ b/components/browser/icons/src/test/java/mozilla/components/browser/icons/preparation/TestFilterKnownFailureUrls.java @@ -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.preparation; + +import junit.framework.Assert; +import mozilla.components.browser.icons.IconDescriptor; +import mozilla.components.browser.icons.IconRequest; +import mozilla.components.browser.icons.Icons; +import mozilla.components.browser.icons.storage.FailureCache; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +@RunWith(RobolectricTestRunner.class) +public class TestFilterKnownFailureUrls { + private static final String TEST_PAGE_URL = "http://www.mozilla.org"; + private static final String TEST_ICON_URL = "https://example.org/favicon.ico"; + + @Before + public void setUp() { + // Make sure we always start with an empty cache. + FailureCache.get().evictAll(); + } + + @Test + public void testFilterDoesNothingByDefault() { + final IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl(TEST_PAGE_URL) + .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL)) + .build(); + + Assert.assertEquals(1, request.getIconCount()); + + final Preparer preparer = new FilterKnownFailureUrls(); + preparer.prepare(request); + + Assert.assertEquals(1, request.getIconCount()); + } + + @Test + public void testFilterKnownFailureUrls() { + final IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl(TEST_PAGE_URL) + .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL)) + .build(); + + Assert.assertEquals(1, request.getIconCount()); + + FailureCache.get().rememberFailure(TEST_ICON_URL); + + final Preparer preparer = new FilterKnownFailureUrls(); + preparer.prepare(request); + + Assert.assertEquals(0, request.getIconCount()); + } +} diff --git a/components/browser/icons/src/test/java/mozilla/components/browser/icons/preparation/TestFilterMimeTypes.java b/components/browser/icons/src/test/java/mozilla/components/browser/icons/preparation/TestFilterMimeTypes.java new file mode 100644 index 00000000000..44266661ea2 --- /dev/null +++ b/components/browser/icons/src/test/java/mozilla/components/browser/icons/preparation/TestFilterMimeTypes.java @@ -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.preparation; + +import mozilla.components.browser.icons.IconDescriptor; +import mozilla.components.browser.icons.IconRequest; +import mozilla.components.browser.icons.Icons; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +@RunWith(RobolectricTestRunner.class) +public class TestFilterMimeTypes { + private static final String TEST_PAGE_URL = "http://www.mozilla.org"; + private static final String TEST_ICON_URL = "https://example.org/favicon.ico"; + private static final String TEST_ICON_URL_2 = "https://mozilla.org/favicon.ico"; + + @Test + public void testUrlsWithoutMimeTypesAreNotFiltered() { + final IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl(TEST_PAGE_URL) + .icon(IconDescriptor.createGenericIcon(TEST_ICON_URL)) + .build(); + + Assert.assertEquals(1, request.getIconCount()); + + final Preparer preparer = new FilterMimeTypes(); + preparer.prepare(request); + + Assert.assertEquals(1, request.getIconCount()); + } + + @Test + public void testUnknownMimeTypesAreFiltered() { + final IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl(TEST_PAGE_URL) + .icon(IconDescriptor.createFavicon(TEST_ICON_URL, 256, "image/zaphod")) + .icon(IconDescriptor.createFavicon(TEST_ICON_URL_2, 128, "audio/mpeg")) + .build(); + + Assert.assertEquals(2, request.getIconCount()); + + final Preparer preparer = new FilterMimeTypes(); + preparer.prepare(request); + + Assert.assertEquals(0, request.getIconCount()); + } + + @Test + public void testKnownMimeTypesAreNotFiltered() { + final IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl(TEST_PAGE_URL) + .icon(IconDescriptor.createFavicon(TEST_ICON_URL, 256, "image/x-icon")) + .icon(IconDescriptor.createFavicon(TEST_ICON_URL_2, 128, "image/png")) + .build(); + + Assert.assertEquals(2, request.getIconCount()); + + final Preparer preparer = new FilterMimeTypes(); + preparer.prepare(request); + + Assert.assertEquals(2, request.getIconCount()); + } +} diff --git a/components/browser/icons/src/test/java/mozilla/components/browser/icons/preparation/TestFilterPrivilegedUrls.java b/components/browser/icons/src/test/java/mozilla/components/browser/icons/preparation/TestFilterPrivilegedUrls.java new file mode 100644 index 00000000000..bc4ef05fa71 --- /dev/null +++ b/components/browser/icons/src/test/java/mozilla/components/browser/icons/preparation/TestFilterPrivilegedUrls.java @@ -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/. + */ + +package mozilla.components.browser.icons.preparation; + +import mozilla.components.browser.icons.IconDescriptor; +import mozilla.components.browser.icons.IconRequest; +import mozilla.components.browser.icons.Icons; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +import java.util.Iterator; + +@RunWith(RobolectricTestRunner.class) +public class TestFilterPrivilegedUrls { + private static final String TEST_PAGE_URL = "http://www.mozilla.org"; + + private static final String TEST_ICON_HTTP_URL = "https://www.mozilla.org/media/img/favicon/apple-touch-icon-180x180.00050c5b754e.png"; + private static final String TEST_ICON_HTTP_URL_2 = "https://www.mozilla.org/media/img/favicon.52506929be4c.ico"; + private static final String TEST_ICON_JAR_URL = "jar:jar:wtf.png"; + + @Test + public void testFiltering() { + final IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl(TEST_PAGE_URL) + .icon(IconDescriptor.createGenericIcon(TEST_ICON_HTTP_URL)) + .icon(IconDescriptor.createGenericIcon(TEST_ICON_HTTP_URL_2)) + .icon(IconDescriptor.createGenericIcon(TEST_ICON_JAR_URL)) + .build(); + + Assert.assertEquals(3, request.getIconCount()); + + Assert.assertTrue(containsUrl(request, TEST_ICON_HTTP_URL)); + Assert.assertTrue(containsUrl(request, TEST_ICON_HTTP_URL_2)); + Assert.assertTrue(containsUrl(request, TEST_ICON_JAR_URL)); + + Preparer preparer = new FilterPrivilegedUrls(); + preparer.prepare(request); + + Assert.assertEquals(2, request.getIconCount()); + + Assert.assertTrue(containsUrl(request, TEST_ICON_HTTP_URL)); + Assert.assertTrue(containsUrl(request, TEST_ICON_HTTP_URL_2)); + Assert.assertFalse(containsUrl(request, TEST_ICON_JAR_URL)); + } + + @Test + public void testNothingIsFilteredForPrivilegedRequests() { + final IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl(TEST_PAGE_URL) + .icon(IconDescriptor.createGenericIcon(TEST_ICON_HTTP_URL)) + .icon(IconDescriptor.createGenericIcon(TEST_ICON_HTTP_URL_2)) + .icon(IconDescriptor.createGenericIcon(TEST_ICON_JAR_URL)) + .privileged(true) + .build(); + + Assert.assertEquals(3, request.getIconCount()); + + Assert.assertTrue(containsUrl(request, TEST_ICON_HTTP_URL)); + Assert.assertTrue(containsUrl(request, TEST_ICON_HTTP_URL_2)); + Assert.assertTrue(containsUrl(request, TEST_ICON_JAR_URL)); + + Preparer preparer = new FilterPrivilegedUrls(); + preparer.prepare(request); + + Assert.assertEquals(3, request.getIconCount()); + + Assert.assertTrue(containsUrl(request, TEST_ICON_HTTP_URL)); + Assert.assertTrue(containsUrl(request, TEST_ICON_HTTP_URL_2)); + Assert.assertTrue(containsUrl(request, TEST_ICON_JAR_URL)); + } + + private boolean containsUrl(IconRequest request, String url) { + final Iterator iterator = request.getIconIterator(); + + while (iterator.hasNext()) { + IconDescriptor descriptor = iterator.next(); + + if (descriptor.getUrl().equals(url)) { + return true; + } + } + + return false; + } +} diff --git a/components/browser/icons/src/test/java/mozilla/components/browser/icons/preparation/TestLookupIconUrl.java b/components/browser/icons/src/test/java/mozilla/components/browser/icons/preparation/TestLookupIconUrl.java new file mode 100644 index 00000000000..dce5e0d3fcc --- /dev/null +++ b/components/browser/icons/src/test/java/mozilla/components/browser/icons/preparation/TestLookupIconUrl.java @@ -0,0 +1,65 @@ +/* + * 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.preparation; + +import mozilla.components.browser.icons.IconRequest; +import mozilla.components.browser.icons.Icons; +import mozilla.components.browser.icons.storage.MemoryStorage; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +@RunWith(RobolectricTestRunner.class) +public class TestLookupIconUrl { + private static final String TEST_PAGE_URL = "http://www.mozilla.org"; + + private static final String TEST_ICON_URL_1 = "http://www.mozilla.org/favicon.ico"; + private static final String TEST_ICON_URL_2 = "http://example.org/favicon.ico"; + private static final String TEST_ICON_URL_3 = "http://example.com/favicon.ico"; + private static final String TEST_ICON_URL_4 = "http://example.net/favicon.ico"; + + + @Before + public void setUp() { + MemoryStorage.get().evictAll(); + } + + @Test + public void testNoIconUrlIsAddedByDefault() { + final IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl(TEST_PAGE_URL) + .build(); + + Assert.assertEquals(0, request.getIconCount()); + + Preparer preparer = new LookupIconUrl(); + preparer.prepare(request); + + Assert.assertEquals(0, request.getIconCount()); + } + + @Test + public void testIconUrlIsAddedFromMemory() { + final IconRequest request = Icons.with(RuntimeEnvironment.application) + .pageUrl(TEST_PAGE_URL) + .build(); + + MemoryStorage.get().putMapping(request, TEST_ICON_URL_1); + + Assert.assertEquals(0, request.getIconCount()); + + Preparer preparer = new LookupIconUrl(); + preparer.prepare(request); + + Assert.assertEquals(1, request.getIconCount()); + + Assert.assertEquals(TEST_ICON_URL_1, request.getBestIcon().getUrl()); + } +} diff --git a/components/browser/icons/src/test/java/mozilla/components/browser/icons/processing/TestColorProcessor.java b/components/browser/icons/src/test/java/mozilla/components/browser/icons/processing/TestColorProcessor.java new file mode 100644 index 00000000000..3473073a9c6 --- /dev/null +++ b/components/browser/icons/src/test/java/mozilla/components/browser/icons/processing/TestColorProcessor.java @@ -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.processing; + +import android.graphics.Bitmap; +import android.graphics.Color; +import mozilla.components.browser.icons.IconResponse; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; +import org.robolectric.RobolectricTestRunner; + +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyInt; +import static org.mockito.Mockito.*; + +@RunWith(RobolectricTestRunner.class) +public class TestColorProcessor { + @Test + public void testExtractingColor() { + final IconResponse response = IconResponse.create(createRedBitmapMock()); + + Assert.assertFalse(response.hasColor()); + Assert.assertEquals(0, response.getColor()); + + final Processor processor = new ColorProcessor(); + processor.process(null, response); + + Assert.assertTrue(response.hasColor()); + Assert.assertEquals(Color.RED, response.getColor()); + } + + private Bitmap createRedBitmapMock() { + final Bitmap bitmap = mock(Bitmap.class); + + doReturn(1).when(bitmap).getWidth(); + doReturn(1).when(bitmap).getHeight(); + + doAnswer(new Answer() { + @Override + public Void answer(InvocationOnMock invocation) throws Throwable { + Object[] args = invocation.getArguments(); + int[] pixels = (int[]) args[0]; + for (int i = 0; i < pixels.length; i++) { + pixels[i] = Color.RED; + } + return null; + } + }).when(bitmap).getPixels(any(int[].class), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt()); + + return bitmap; + } +} diff --git a/components/browser/icons/src/test/java/mozilla/components/browser/icons/processing/TestMemoryProcessor.java b/components/browser/icons/src/test/java/mozilla/components/browser/icons/processing/TestMemoryProcessor.java new file mode 100644 index 00000000000..a2fb5de8bfd --- /dev/null +++ b/components/browser/icons/src/test/java/mozilla/components/browser/icons/processing/TestMemoryProcessor.java @@ -0,0 +1,136 @@ +/* + * 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.processing; + +import android.graphics.Bitmap; +import android.graphics.Color; +import mozilla.components.browser.icons.IconDescriptor; +import mozilla.components.browser.icons.IconRequest; +import mozilla.components.browser.icons.IconResponse; +import mozilla.components.browser.icons.Icons; +import mozilla.components.browser.icons.storage.MemoryStorage; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +import static org.mockito.Mockito.mock; + +@RunWith(RobolectricTestRunner.class) +public class TestMemoryProcessor { + private static final String PAGE_URL = "https://www.mozilla.org"; + private static final String ICON_URL = "https://www.mozilla.org/favicon.ico"; + private static final String DATA_URL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEklEQVR4AWP4z8AAxCDiP8N/AB3wBPxcBee7AAAAAElFTkSuQmCC"; + + @Before + public void setUp() { + MemoryStorage.get().evictAll(); + } + + @Test + public void testResponsesAreStoredInMemory() { + final IconRequest request = createTestRequest(); + + Assert.assertNull(MemoryStorage.get().getIcon(ICON_URL)); + Assert.assertNull(MemoryStorage.get().getMapping(PAGE_URL)); + + final Processor processor = new MemoryProcessor(); + processor.process(request, createTestResponse()); + + Assert.assertNotNull(MemoryStorage.get().getIcon(ICON_URL)); + Assert.assertNotNull(MemoryStorage.get().getMapping(PAGE_URL)); + } + + @Test + public void testNothingIsStoredIfMemoryShouldBeSkipped() { + final IconRequest request = createTestRequest() + .modify() + .skipMemory() + .build(); + + Assert.assertNull(MemoryStorage.get().getIcon(ICON_URL)); + Assert.assertNull(MemoryStorage.get().getMapping(PAGE_URL)); + + final Processor processor = new MemoryProcessor(); + processor.process(request, createTestResponse()); + + Assert.assertNull(MemoryStorage.get().getIcon(ICON_URL)); + Assert.assertNull(MemoryStorage.get().getMapping(PAGE_URL)); + } + + @Test + public void testNothingIsStoredForRequestsWithoutUrl() { + final IconRequest request = createTestRequestWithoutIconUrl(); + + Assert.assertNull(MemoryStorage.get().getIcon(ICON_URL)); + Assert.assertNull(MemoryStorage.get().getMapping(PAGE_URL)); + + final Processor processor = new MemoryProcessor(); + processor.process(request, createTestResponse()); + + Assert.assertNull(MemoryStorage.get().getIcon(ICON_URL)); + Assert.assertNull(MemoryStorage.get().getMapping(PAGE_URL)); + } + + @Test + public void testNothingIsStoredForGeneratedResponses() { + final IconRequest request = createTestRequest(); + + Assert.assertNull(MemoryStorage.get().getIcon(ICON_URL)); + Assert.assertNull(MemoryStorage.get().getMapping(PAGE_URL)); + + final Processor processor = new MemoryProcessor(); + processor.process(request, createGeneratedTestResponse()); + + Assert.assertNull(MemoryStorage.get().getIcon(ICON_URL)); + Assert.assertNull(MemoryStorage.get().getMapping(PAGE_URL)); + } + + @Test + public void testNothingIsStoredForDataUris() { + final IconRequest request = createDataUriTestRequest(); + + Assert.assertNull(MemoryStorage.get().getIcon(DATA_URL)); + Assert.assertNull(MemoryStorage.get().getMapping(PAGE_URL)); + + final Processor processor = new MemoryProcessor(); + processor.process(request, createTestResponse()); + + Assert.assertNull(MemoryStorage.get().getIcon(DATA_URL)); + Assert.assertNull(MemoryStorage.get().getMapping(PAGE_URL)); + } + + private IconRequest createTestRequest() { + return Icons.with(RuntimeEnvironment.application) + .pageUrl(PAGE_URL) + .icon(IconDescriptor.createGenericIcon(ICON_URL)) + .build(); + } + + private IconRequest createTestRequestWithoutIconUrl() { + return Icons.with(RuntimeEnvironment.application) + .pageUrl(PAGE_URL) + .build(); + } + + private IconRequest createDataUriTestRequest() { + return Icons.with(RuntimeEnvironment.application) + .pageUrl(PAGE_URL) + .icon(IconDescriptor.createGenericIcon(DATA_URL)) + .build(); + } + + private IconResponse createTestResponse() { + return IconResponse.create(mock(Bitmap.class)); + } + + private IconResponse createGeneratedTestResponse() { + return IconResponse.createGenerated(mock(Bitmap.class), Color.GREEN); + } +} diff --git a/components/browser/icons/src/test/java/mozilla/components/browser/icons/processing/TestMinimumSizeProcessor.java b/components/browser/icons/src/test/java/mozilla/components/browser/icons/processing/TestMinimumSizeProcessor.java new file mode 100644 index 00000000000..3e4cfd2ed03 --- /dev/null +++ b/components/browser/icons/src/test/java/mozilla/components/browser/icons/processing/TestMinimumSizeProcessor.java @@ -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.processing; + +import android.graphics.Bitmap; +import mozilla.components.browser.icons.IconRequest; +import mozilla.components.browser.icons.IconResponse; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyInt; +import static org.mockito.Mockito.*; + +@RunWith(RobolectricTestRunner.class) +public class TestMinimumSizeProcessor { + + private MinimumSizeProcessor processor; + + @Before + public void setUp() { + processor = new MinimumSizeProcessor(); + } + + @Test + public void testProcessMinimumSizeZeroDoesNotReplaceSmallBitmap() throws Exception { + final IconResponse responseMock = getMockResponse(1); + processor.process(getMockRequest(0), responseMock); + + verify(responseMock, never()).updateBitmap(any(Bitmap.class)); + verify(responseMock, never()).updateColor(anyInt()); + } + + @Test + public void testProcessMinimumSizeZeroDoesNotReplaceLargeBitmap() throws Exception { + final IconResponse responseMock = getMockResponse(1000); + processor.process(getMockRequest(0), responseMock); + + verify(responseMock, never()).updateBitmap(any(Bitmap.class)); + verify(responseMock, never()).updateColor(anyInt()); + } + + @Test + public void testProcessMinimumSizeFiftyReplacesSmallerBitmap() throws Exception { + final IconResponse responseMock = getMockResponse(25); + processor.process(getMockRequest(50), responseMock); + + verify(responseMock, atLeastOnce()).updateBitmap(any(Bitmap.class)); + verify(responseMock, atLeastOnce()).updateColor(anyInt()); + } + + @Test + public void testProcessMinimumSizeFiftyDoesNotReplaceLargerBitmap() throws Exception { + final IconResponse responseMock = getMockResponse(1000); + processor.process(getMockRequest(50), responseMock); + + verify(responseMock, never()).updateBitmap(any(Bitmap.class)); + verify(responseMock, never()).updateColor(anyInt()); + } + + private IconRequest getMockRequest(final int minimumSizePx) { + final IconRequest requestMock = mock(IconRequest.class); + + // Under testing. + when(requestMock.getMinimumSizePxAfterScaling()).thenReturn(minimumSizePx); + + // Happened to be called. + when(requestMock.getPageUrl()).thenReturn("https://mozilla.org"); + when(requestMock.getContext()).thenReturn(RuntimeEnvironment.application); + return requestMock; + } + + private IconResponse getMockResponse(final int bitmapWidth) { + final Bitmap bitmapMock = mock(Bitmap.class); + when(bitmapMock.getWidth()).thenReturn(bitmapWidth); + when(bitmapMock.getHeight()).thenReturn(bitmapWidth); // not strictly necessary with the current impl. + + final IconResponse responseMock = mock(IconResponse.class); + when(responseMock.getBitmap()).thenReturn(bitmapMock); + return responseMock; + } +} \ No newline at end of file diff --git a/components/browser/icons/src/test/java/mozilla/components/browser/icons/processing/TestResizingProcessor.java b/components/browser/icons/src/test/java/mozilla/components/browser/icons/processing/TestResizingProcessor.java new file mode 100644 index 00000000000..3bae1e3c04e --- /dev/null +++ b/components/browser/icons/src/test/java/mozilla/components/browser/icons/processing/TestResizingProcessor.java @@ -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.processing; + +import android.graphics.Bitmap; +import mozilla.components.browser.icons.IconDescriptor; +import mozilla.components.browser.icons.IconRequest; +import mozilla.components.browser.icons.IconResponse; +import mozilla.components.browser.icons.Icons; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyInt; +import static org.mockito.Mockito.*; + +@RunWith(RobolectricTestRunner.class) +public class TestResizingProcessor { + private static final String PAGE_URL = "https://www.mozilla.org"; + private static final String ICON_URL = "https://www.mozilla.org/favicon.ico"; + + @Test + public void testBitmapIsNotResizedIfItAlreadyHasTheTargetSize() { + final IconRequest request = createTestRequest(); + + final Bitmap bitmap = createBitmapMock(request.getTargetSize()); + final IconResponse response = spy(IconResponse.create(bitmap)); + + final ResizingProcessor processor = spy(new ResizingProcessor()); + processor.process(request, response); + + verify(processor, never()).resize(any(Bitmap.class), anyInt()); + verify(bitmap, never()).recycle(); + verify(response, never()).updateBitmap(any(Bitmap.class)); + } + + @Test + public void testLargerBitmapsAreResized() { + final IconRequest request = createTestRequest(); + + final Bitmap bitmap = createBitmapMock(request.getTargetSize() * 2); + final IconResponse response = spy(IconResponse.create(bitmap)); + + final ResizingProcessor processor = spy(new ResizingProcessor()); + final Bitmap resizedBitmap = mock(Bitmap.class); + doReturn(resizedBitmap).when(processor).resize(any(Bitmap.class), anyInt()); + processor.process(request, response); + + verify(processor).resize(bitmap, request.getTargetSize()); + verify(bitmap).recycle(); + verify(response).updateBitmap(resizedBitmap); + } + + @Test + public void testBitmapIsUpscaledToTargetSize() { + final IconRequest request = createTestRequest(); + + final Bitmap bitmap = createBitmapMock(request.getTargetSize() / 2 + 1); + final IconResponse response = spy(IconResponse.create(bitmap)); + + final ResizingProcessor processor = spy(new ResizingProcessor()); + final Bitmap resizedBitmap = mock(Bitmap.class); + doReturn(resizedBitmap).when(processor).resize(any(Bitmap.class), anyInt()); + processor.process(request, response); + + verify(processor).resize(bitmap, request.getTargetSize()); + verify(bitmap).recycle(); + verify(response).updateBitmap(resizedBitmap); + } + + @Test + public void testBitmapIsNotScaledMoreThanMaxScaleFactor() { + final IconRequest request = createTestRequest(); + + final int initialSize = 5; + final Bitmap bitmap = createBitmapMock(initialSize); + final IconResponse response = spy(IconResponse.create(bitmap)); + + final ResizingProcessor processor = spy(new ResizingProcessor()); + final Bitmap resizedBitmap = mock(Bitmap.class); + doReturn(resizedBitmap).when(processor).resize(any(Bitmap.class), anyInt()); + processor.process(request, response); + + verify(processor).resize(bitmap, initialSize * ResizingProcessor.MAX_SCALE_FACTOR); + verify(bitmap).recycle(); + verify(response).updateBitmap(resizedBitmap); + } + + private IconRequest createTestRequest() { + return Icons.with(RuntimeEnvironment.application) + .pageUrl(PAGE_URL) + .icon(IconDescriptor.createGenericIcon(ICON_URL)) + .build(); + } + + private Bitmap createBitmapMock(int size) { + final Bitmap bitmap = mock(Bitmap.class); + + doReturn(size).when(bitmap).getWidth(); + doReturn(size).when(bitmap).getHeight(); + + return bitmap; + } +} diff --git a/components/browser/icons/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/components/browser/icons/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 00000000000..cf1c399ea81 --- /dev/null +++ b/components/browser/icons/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1,2 @@ +mock-maker-inline +// This allows mocking final classes (classes are final by default in Kotlin) diff --git a/components/support/ktx/build.gradle b/components/support/ktx/build.gradle index 453eb02afa7..e26d982ba90 100644 --- a/components/support/ktx/build.gradle +++ b/components/support/ktx/build.gradle @@ -24,6 +24,7 @@ android { } dependencies { + implementation project(':support-base') implementation Deps.kotlin_stdlib implementation Deps.support_compat diff --git a/components/support/ktx/src/main/java/mozilla/components/support/ktx/android/os/Build.kt b/components/support/ktx/src/main/java/mozilla/components/support/ktx/android/os/Build.kt new file mode 100644 index 00000000000..29fe665f60e --- /dev/null +++ b/components/support/ktx/src/main/java/mozilla/components/support/ktx/android/os/Build.kt @@ -0,0 +1,31 @@ +/* + * 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.support.ktx.android.os + +import android.os.Build.SUPPORTED_ABIS +import android.system.Os +import mozilla.components.support.base.log.logger.Logger + +/** + * Originally taken from [HardwareUtils#isX86System][0]. + * + * [0]: https://dxr.mozilla.org/mozilla-central/source/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareUtils.java + */ +object Build { + fun isX86System(): Boolean { + if ("x86" == SUPPORTED_ABIS[0]) { + return true + } + // On some devices we have to look into the kernel release string. + try { + return Os.uname().release.contains("-x86_") + } catch (e: Exception) { + Logger.warn("Cannot get uname", e) + } + return false + } +} diff --git a/components/support/ktx/src/main/java/mozilla/components/support/ktx/kotlin/String.kt b/components/support/ktx/src/main/java/mozilla/components/support/ktx/kotlin/String.kt index cbf2b2b96bc..8cf84c217a9 100644 --- a/components/support/ktx/src/main/java/mozilla/components/support/ktx/kotlin/String.kt +++ b/components/support/ktx/src/main/java/mozilla/components/support/ktx/kotlin/String.kt @@ -36,3 +36,29 @@ fun String.isPhone(): Boolean = contains("tel:", true) fun String.isEmail(): Boolean = contains("mailto:", true) fun String.isGeoLocation(): Boolean = contains("geo:", true) + +fun String.stripCommonSubdomains(): String? { + // In contrast to desktop, we also strip mobile subdomains, + // since its unlikely users are intentionally typing them + var start = 0 + + if (startsWith("www.")) { + start = 4 + } else if (startsWith("mobile.")) { + start = 7 + } else if (startsWith("m.")) { + start = 2 + } + + return substring(start) +} + +fun String.isHttpOrHttps(): Boolean = + if (TextUtils.isEmpty(this)) { + false + } else { + startsWith("http://") || startsWith("https://") + } + +fun String.isAboutPage(): Boolean = + startsWith("about:") diff --git a/components/support/utils/build.gradle b/components/support/utils/build.gradle index c047d1743b3..568ae4ce305 100644 --- a/components/support/utils/build.gradle +++ b/components/support/utils/build.gradle @@ -26,11 +26,13 @@ android { dependencies { implementation project(':support-base') + implementation project(':support-ktx') implementation Deps.kotlin_stdlib implementation Deps.support_annotations implementation Deps.support_compat + implementation Deps.support_palette // We expose the app-compat as API so that consumers get access to the Lifecycle classes automatically api Deps.support_appcompat diff --git a/components/support/utils/src/main/java/mozilla/components/support/utils/BitmapUtils.kt b/components/support/utils/src/main/java/mozilla/components/support/utils/BitmapUtils.kt new file mode 100644 index 00000000000..48bde6c267c --- /dev/null +++ b/components/support/utils/src/main/java/mozilla/components/support/utils/BitmapUtils.kt @@ -0,0 +1,157 @@ +/* + * 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.support.utils + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Color +import android.support.annotation.ColorInt +import android.support.v7.graphics.Palette +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.ktx.android.os.Build + +object BitmapUtils { + + @JvmStatic + fun decodeByteArray( + bytes: ByteArray, + offset: Int = 0, + length: Int = bytes.size, + options: BitmapFactory.Options? = null + ): Bitmap? { + if (bytes.isEmpty()) { + throw IllegalArgumentException( + "bytes.length " + bytes.size + + " must be a positive number" + ) + } + + var bitmap: Bitmap? = null + try { + bitmap = BitmapFactory.decodeByteArray(bytes, offset, length, options) + } catch (e: OutOfMemoryError) { + Logger.error( + ("decodeByteArray(bytes.length=" + bytes.size + + ", options= " + options + ") OOM!"), e + ) + return null + } + + if (bitmap == null) { + Logger.warn("decodeByteArray() returning null because BitmapFactory returned null") + return null + } + + if (bitmap.width <= 0 || bitmap.height <= 0) { + Logger.warn( + ("decodeByteArray() returning null because BitmapFactory returned " + + "a bitmap with dimensions " + bitmap.width + + "x" + bitmap.height) + ) + return null + } + + return bitmap + } + + @ColorInt + @JvmStatic + fun getDominantColor(source: Bitmap, @ColorInt defaultColor: Int): Int { + return if (Build.isX86System()) { + // (Bug 1318667) We are running into crashes when using the palette library with + // specific icons on x86 devices. They take down the whole VM and are not recoverable. + // Unfortunately our release icon is triggering this crash. Until we can switch to a + // newer version of the support library where this does not happen, we are using our + // own slower implementation. + getDominantColorCustomImplementation(source, true, defaultColor) + } else { + try { + val palette = Palette.from(source).generate() + palette.getVibrantColor(defaultColor) + } catch (e: ArrayIndexOutOfBoundsException) { + // We saw the palette library fail with an ArrayIndexOutOfBoundsException intermittently + // in automation. In this case lets just swallow the exception and move on without a + // color. This is a valid condition and callers should handle this gracefully (Bug 1318560). + Logger.warn("Palette generation failed with ArrayIndexOutOfBoundsException", e) + + defaultColor + } + } + } + + @ColorInt + @JvmStatic + fun getDominantColorCustomImplementation( + source: Bitmap?, + applyThreshold: Boolean = true, + @ColorInt defaultColor: Int = Color.WHITE + ): Int { + if (source == null) { + return defaultColor + } + + // Keep track of how many times a hue in a given bin appears in the image. + // Hue values range [0 .. 360), so dividing by 10, we get 36 bins. + val colorBins = IntArray(36) + + // The bin with the most colors. Initialize to -1 to prevent accidentally + // thinking the first bin holds the dominant color. + var maxBin = -1 + + // Keep track of sum hue/saturation/value per hue bin, which we'll use to + // compute an average to for the dominant color. + val sumHue = FloatArray(36) + val sumSat = FloatArray(36) + val sumVal = FloatArray(36) + val hsv = FloatArray(3) + + val height = source.height + val width = source.width + val pixels = IntArray(width * height) + source.getPixels(pixels, 0, width, 0, 0, width, height) + for (row in 0 until height) { + for (col in 0 until width) { + val c = pixels[col + row * width] + // Ignore pixels with a certain transparency. + if (Color.alpha(c) < 128) + continue + + Color.colorToHSV(c, hsv) + + // If a threshold is applied, ignore arbitrarily chosen values for "white" and "black". + if (applyThreshold && (hsv[1] <= 0.35f || hsv[2] <= 0.35f)) + continue + + // We compute the dominant color by putting colors in bins based on their hue. + val bin = Math.floor((hsv[0] / 10.0f).toDouble()).toInt() + + // Update the sum hue/saturation/value for this bin. + sumHue[bin] = sumHue[bin] + hsv[0] + sumSat[bin] = sumSat[bin] + hsv[1] + sumVal[bin] = sumVal[bin] + hsv[2] + + // Increment the number of colors in this bin. + colorBins[bin]++ + + // Keep track of the bin that holds the most colors. + if (maxBin < 0 || colorBins[bin] > colorBins[maxBin]) + maxBin = bin + } + } + + // maxBin may never get updated if the image holds only transparent and/or black/white pixels. + if (maxBin < 0) { + return defaultColor + } + + // Return a color with the average hue/saturation/value of the bin with the most colors. + hsv[0] = sumHue[maxBin] / colorBins[maxBin] + hsv[1] = sumSat[maxBin] / colorBins[maxBin] + hsv[2] = sumVal[maxBin] / colorBins[maxBin] + return Color.HSVToColor(hsv) + } +} \ No newline at end of file diff --git a/components/support/utils/src/main/java/mozilla/components/support/utils/ThreadUtils.kt b/components/support/utils/src/main/java/mozilla/components/support/utils/ThreadUtils.kt index d9921d90365..084ed770924 100644 --- a/components/support/utils/src/main/java/mozilla/components/support/utils/ThreadUtils.kt +++ b/components/support/utils/src/main/java/mozilla/components/support/utils/ThreadUtils.kt @@ -15,15 +15,18 @@ object ThreadUtils { private val handler = Handler(Looper.getMainLooper()) private val uiThread = Looper.getMainLooper().thread + @JvmStatic fun postToBackgroundThread(runnable: Runnable) { backgroundExecutorService.submit(runnable) } - fun postToMainThread(runnable: Runnable) { + @JvmStatic + fun postToUiThread(runnable: Runnable) { handler.post(runnable) } - fun postToMainThreadDelayed(runnable: Runnable, delayMillis: Long) { + @JvmStatic + fun postToUiThreadDelayed(runnable: Runnable, delayMillis: Long) { handler.postDelayed(runnable, delayMillis) } diff --git a/settings.gradle b/settings.gradle index 25f9793d837..9b406f827f2 100644 --- a/settings.gradle +++ b/settings.gradle @@ -53,6 +53,9 @@ project(':browser-engine-system').projectDir = new File(rootDir, 'components/bro include ':browser-errorpages' project(':browser-errorpages').projectDir = new File(rootDir, 'components/browser/errorpages') +include ':browser-icons' +project(':browser-icons').projectDir = new File(rootDir, 'components/browser/icons') + include ':browser-menu' project(':browser-menu').projectDir = new File(rootDir, 'components/browser/menu')