1 // Copyright 2019 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
4 
5 package org.chromium.chrome.browser.omnibox;
6 
7 import android.content.res.Resources;
8 import android.graphics.Bitmap;
9 import android.graphics.Canvas;
10 import android.graphics.Color;
11 import android.text.TextUtils;
12 
13 import androidx.annotation.IntDef;
14 import androidx.annotation.Nullable;
15 import androidx.annotation.VisibleForTesting;
16 
17 import org.chromium.base.Callback;
18 import org.chromium.base.Log;
19 import org.chromium.base.metrics.RecordHistogram;
20 import org.chromium.chrome.R;
21 import org.chromium.chrome.browser.flags.ChromeFeatureList;
22 import org.chromium.chrome.browser.locale.LocaleManager;
23 import org.chromium.chrome.browser.profiles.Profile;
24 import org.chromium.chrome.browser.search_engines.TemplateUrlServiceFactory;
25 import org.chromium.chrome.browser.ui.favicon.FaviconHelper;
26 import org.chromium.components.browser_ui.widget.RoundedIconGenerator;
27 import org.chromium.components.embedder_support.util.UrlUtilities;
28 import org.chromium.content_public.browser.BrowserStartupController;
29 
30 import java.lang.annotation.Retention;
31 import java.lang.annotation.RetentionPolicy;
32 import java.util.HashMap;
33 import java.util.Map;
34 
35 /**
36  * Collection of shared code for displaying search engine logos.
37  */
38 public class SearchEngineLogoUtils {
39     // Note: shortened to account for the 20 character limit.
40     private static final String TAG = "SearchLogoUtils";
41     private static final String ROUNDED_EDGES_VARIANT = "rounded_edges";
42     private static final String LOUPE_EVERYWHERE_VARIANT = "loupe_everywhere";
43     private static final String DUMMY_URL_QUERY = "replace_me";
44 
45     // Cache the logo and return it when the logo url that's cached matches the current logo url.
46     private static Bitmap sCachedComposedBackground;
47     private static String sCachedComposedBackgroundLogoUrl;
48     private static FaviconHelper sFaviconHelper;
49     private static RoundedIconGenerator sRoundedIconGenerator;
50 
51     // Cache these values so they don't need to be recalculated.
52     private static int sSearchEngineLogoTargetSizePixels;
53     private static int sSearchEngineLogoComposedSizePixels;
54 
55     /** Encapsulates methods that rely on static dependencies that aren't available for testing. */
56     static class Delegate {
57         /** @see SearchEngineLogoUtils#isSearchEngineLogoEnabled */
isSearchEngineLogoEnabled()58         public boolean isSearchEngineLogoEnabled() {
59             // Note: LocaleManager#needToCheckForSearchEnginePromo() checks several system features
60             // which risk throwing a security exception. Catching that here to prevent it from
61             // crashing the app.
62             try {
63                 return !LocaleManager.getInstance().needToCheckForSearchEnginePromo()
64                         && ChromeFeatureList.isInitialized()
65                         && ChromeFeatureList.isEnabled(
66                                 ChromeFeatureList.OMNIBOX_SEARCH_ENGINE_LOGO);
67             } catch (SecurityException e) {
68                 Log.e(TAG, "Security exception thrown by failed IPC, see crbug.com/1027709");
69                 return false;
70             }
71         }
72 
73         /** @see SearchEngineLogoUtils#shouldShowSearchEngineLogo */
shouldShowSearchEngineLogo(boolean isOffTheRecord)74         public boolean shouldShowSearchEngineLogo(boolean isOffTheRecord) {
75             return !isOffTheRecord
76                     && isSearchEngineLogoEnabled()
77                     // Using the profile now, so we need to pay attention to browser initialization.
78                     && BrowserStartupController.getInstance().isFullBrowserStarted();
79         }
80 
81         /** @see SearchEngineLogoUtils#shouldShowRoundedSearchEngineLogo */
shouldShowRoundedSearchEngineLogo(boolean isOffTheRecord)82         public boolean shouldShowRoundedSearchEngineLogo(boolean isOffTheRecord) {
83             return shouldShowSearchEngineLogo(isOffTheRecord) && ChromeFeatureList.isInitialized()
84                     && ChromeFeatureList.getFieldTrialParamByFeatureAsBoolean(
85                             ChromeFeatureList.OMNIBOX_SEARCH_ENGINE_LOGO, ROUNDED_EDGES_VARIANT,
86                             false);
87         }
88 
89         /** @see SearchEngineLogoUtils#shouldShowSearchLoupeEverywhere */
shouldShowSearchLoupeEverywhere(boolean isOffTheRecord)90         public boolean shouldShowSearchLoupeEverywhere(boolean isOffTheRecord) {
91             return shouldShowSearchEngineLogo(isOffTheRecord)
92                     && ChromeFeatureList.getFieldTrialParamByFeatureAsBoolean(
93                             ChromeFeatureList.OMNIBOX_SEARCH_ENGINE_LOGO, LOUPE_EVERYWHERE_VARIANT,
94                             false);
95         }
96     }
97     private static Delegate sDelegate = new Delegate();
98 
99     /**
100      * AndroidSearchEngineLogoEvents defined in tools/metrics/histograms/enums.xml. These values
101      * are persisted to logs. Entries should not be renumbered and numeric values should never be
102      * reused.
103      */
104     @IntDef({Events.FETCH_NON_GOOGLE_LOGO_REQUEST, Events.FETCH_FAILED_NULL_URL,
105             Events.FETCH_FAILED_FAVICON_HELPER_ERROR, Events.FETCH_FAILED_RETURNED_BITMAP_NULL,
106             Events.FETCH_SUCCESS_CACHE_HIT, Events.FETCH_SUCCESS})
107     @Retention(RetentionPolicy.SOURCE)
108     public @interface Events {
109         int FETCH_NON_GOOGLE_LOGO_REQUEST = 0;
110         int FETCH_FAILED_NULL_URL = 1;
111         int FETCH_FAILED_FAVICON_HELPER_ERROR = 2;
112         int FETCH_FAILED_RETURNED_BITMAP_NULL = 3;
113         int FETCH_SUCCESS_CACHE_HIT = 4;
114         int FETCH_SUCCESS = 5;
115 
116         int MAX = 6;
117     }
118 
119     /**
120      * Encapsulates complicated boolean check for reuse and readability.
121      * @return True if the search engine logo is enabled, regardless of visibility.
122      */
isSearchEngineLogoEnabled()123     public static boolean isSearchEngineLogoEnabled() {
124         return sDelegate.isSearchEngineLogoEnabled();
125     }
126 
127     /**
128      * Encapsulates complicated boolean check for reuse and readability.
129      * @param isOffTheRecord True if the user is currently using an incognito tab.
130      * @return True if we should show the search engine logo.
131      */
shouldShowSearchEngineLogo(boolean isOffTheRecord)132     public static boolean shouldShowSearchEngineLogo(boolean isOffTheRecord) {
133         return sDelegate.shouldShowSearchEngineLogo(isOffTheRecord);
134     }
135 
136     /**
137      * Encapsulates complicated boolean check for reuse and readability.
138      * @param isOffTheRecord True if the user is currently using an incognito tab.
139      * @return True if we should show the rounded search engine logo.
140      */
shouldShowRoundedSearchEngineLogo(boolean isOffTheRecord)141     public static boolean shouldShowRoundedSearchEngineLogo(boolean isOffTheRecord) {
142         return sDelegate.shouldShowRoundedSearchEngineLogo(isOffTheRecord);
143     }
144 
145     /** Ignores the incognito state for instances where a caller would otherwise pass "false". */
isRoundedSearchEngineLogoEnabled()146     static boolean isRoundedSearchEngineLogoEnabled() {
147         return shouldShowRoundedSearchEngineLogo(false);
148     }
149 
150     /**
151      * Encapsulates complicated boolean check for reuse and readability.
152      * @param isOffTheRecord True if the user is currently using an incognito tab.
153      * @return True if we should show the search engine logo as a loupe everywhere.
154      */
shouldShowSearchLoupeEverywhere(boolean isOffTheRecord)155     public static boolean shouldShowSearchLoupeEverywhere(boolean isOffTheRecord) {
156         return sDelegate.shouldShowSearchLoupeEverywhere(isOffTheRecord);
157     }
158 
159     /**
160      * @return True if the given url is the same domain as the DSE.
161      */
doesUrlMatchDefaultSearchEngine(String url)162     public static boolean doesUrlMatchDefaultSearchEngine(String url) {
163         if (TextUtils.isEmpty(url)) return false;
164         return UrlUtilities.sameDomainOrHost(url, getSearchLogoUrl(), false);
165     }
166 
167     /** @return Whether the status icon should be hidden when the LocationBar is unfocused. */
currentlyOnNTP(LocationBarDataProvider locationBarDataProvider)168     public static boolean currentlyOnNTP(LocationBarDataProvider locationBarDataProvider) {
169         return locationBarDataProvider != null
170                 && UrlUtilities.isNTPUrl(locationBarDataProvider.getCurrentUrl());
171     }
172 
173     /**
174      * @return The search URL of the current DSE or null if one cannot be found.
175      */
176     @Nullable
getSearchLogoUrl()177     public static String getSearchLogoUrl() {
178         String logoUrlWithPath =
179                 TemplateUrlServiceFactory.get().getUrlForSearchQuery(DUMMY_URL_QUERY);
180         if (logoUrlWithPath == null || !UrlUtilities.isHttpOrHttps(logoUrlWithPath)) {
181             return logoUrlWithPath;
182         }
183 
184         return UrlUtilities.stripPath(logoUrlWithPath);
185     }
186 
187     /**
188      * @param resources Android resources object, used to read the dimension.
189      * @return The size that the logo favicon should be.
190      */
getSearchEngineLogoSizePixels(Resources resources)191     public static int getSearchEngineLogoSizePixels(Resources resources) {
192         if (sSearchEngineLogoTargetSizePixels == 0) {
193             if (isRoundedSearchEngineLogoEnabled()) {
194                 sSearchEngineLogoTargetSizePixels = resources.getDimensionPixelSize(
195                         R.dimen.omnibox_search_engine_logo_favicon_size);
196             } else {
197                 sSearchEngineLogoTargetSizePixels =
198                         getSearchEngineLogoComposedSizePixels(resources);
199             }
200         }
201 
202         return sSearchEngineLogoTargetSizePixels;
203     }
204 
205     /**
206      * @param resources Android resources object, used to read the dimension.
207      * @return The total size the logo will be on screen.
208      */
getSearchEngineLogoComposedSizePixels(Resources resources)209     public static int getSearchEngineLogoComposedSizePixels(Resources resources) {
210         if (sSearchEngineLogoComposedSizePixels == 0) {
211             sSearchEngineLogoComposedSizePixels = resources.getDimensionPixelSize(
212                     R.dimen.omnibox_search_engine_logo_composed_size);
213         }
214 
215         return sSearchEngineLogoComposedSizePixels;
216     }
217 
218     /**
219      * Get the search engine logo favicon. This can return a null bitmap under certain
220      * circumstances, such as: no logo url found, network/cache error, etc.
221      *
222      * @param profile The current profile.
223      * @param resources Provides access to Android resources.
224      * @param callback How the bitmap will be returned to the caller.
225      */
getSearchEngineLogoFavicon( Profile profile, Resources resources, Callback<Bitmap> callback)226     public static void getSearchEngineLogoFavicon(
227             Profile profile, Resources resources, Callback<Bitmap> callback) {
228         recordEvent(Events.FETCH_NON_GOOGLE_LOGO_REQUEST);
229         if (sFaviconHelper == null) sFaviconHelper = new FaviconHelper();
230 
231         String logoUrl = getSearchLogoUrl();
232         if (logoUrl == null) {
233             callback.onResult(null);
234             recordEvent(Events.FETCH_FAILED_NULL_URL);
235             return;
236         }
237 
238         // Return a cached copy if it's available.
239         if (sCachedComposedBackground != null
240                 && sCachedComposedBackgroundLogoUrl.equals(getSearchLogoUrl())) {
241             callback.onResult(sCachedComposedBackground);
242             recordEvent(Events.FETCH_SUCCESS_CACHE_HIT);
243             return;
244         }
245 
246         final int logoSizePixels = SearchEngineLogoUtils.getSearchEngineLogoSizePixels(resources);
247         boolean willCallbackBeCalled = sFaviconHelper.getLocalFaviconImageForURL(
248                 profile, logoUrl, logoSizePixels, (image, iconUrl) -> {
249                     if (image == null) {
250                         callback.onResult(image);
251                         recordEvent(Events.FETCH_FAILED_RETURNED_BITMAP_NULL);
252                         return;
253                     }
254 
255                     processReturnedLogo(logoUrl, image, resources, callback);
256                     recordEvent(Events.FETCH_SUCCESS);
257                 });
258         if (!willCallbackBeCalled) {
259             callback.onResult(null);
260             recordEvent(Events.FETCH_FAILED_FAVICON_HELPER_ERROR);
261         }
262     }
263 
264     /**
265      * Process the image returned from a network fetch or cache hit. This method processes the logo
266      * to make it eligible for display. The logo is resized to ensure it will fill the required
267      * size. This is done because the icon returned from native could be a different size. If the
268      * rounded edges variant is active, then a smaller icon is downloaded and drawn on top of a
269      * circle background. This looks better and also has more predictable behavior than rounding the
270      * edges of the full size icon. The circle background is a solid color made up of the result
271      * from a call to getMostCommonEdgeColor(...).
272      * @param logoUrl The url for the given logo.
273      * @param image The logo to process.
274      * @param resources Android resources object used to access dimensions.
275      * @param callback The client callback to receive the processed logo.
276      */
processReturnedLogo( String logoUrl, Bitmap image, Resources resources, Callback<Bitmap> callback)277     private static void processReturnedLogo(
278             String logoUrl, Bitmap image, Resources resources, Callback<Bitmap> callback) {
279         // Scale the logo up to the desired size.
280         int logoSizePixels = SearchEngineLogoUtils.getSearchEngineLogoSizePixels(resources);
281         Bitmap scaledIcon = Bitmap.createScaledBitmap(image,
282                 SearchEngineLogoUtils.getSearchEngineLogoSizePixels(resources),
283                 SearchEngineLogoUtils.getSearchEngineLogoSizePixels(resources), true);
284 
285         Bitmap composedIcon = scaledIcon;
286         if (isRoundedSearchEngineLogoEnabled()) {
287             int composedSizePixels = getSearchEngineLogoComposedSizePixels(resources);
288             if (sRoundedIconGenerator == null) {
289                 sRoundedIconGenerator = new RoundedIconGenerator(composedSizePixels,
290                         composedSizePixels, composedSizePixels, Color.TRANSPARENT, 0);
291             }
292             int color = (image.getWidth() == 0 || image.getHeight() == 0)
293                     ? Color.TRANSPARENT
294                     : getMostCommonEdgeColor(image);
295             sRoundedIconGenerator.setBackgroundColor(color);
296 
297             // Generate a rounded background with no text.
298             composedIcon = sRoundedIconGenerator.generateIconForText("");
299             Canvas canvas = new Canvas(composedIcon);
300             // Draw the logo in the middle of the generated background.
301             int dx = (composedSizePixels - logoSizePixels) / 2;
302             canvas.drawBitmap(scaledIcon, dx, dx, null);
303         }
304         // Cache the result icon to reduce future work.
305         sCachedComposedBackground = composedIcon;
306         sCachedComposedBackgroundLogoUrl = logoUrl;
307 
308         callback.onResult(sCachedComposedBackground);
309     }
310 
311     /**
312      * Samples the edges of given bitmap and returns the most common color.
313      * @param icon Bitmap to be sampled.
314      */
315     @VisibleForTesting
getMostCommonEdgeColor(Bitmap icon)316     static int getMostCommonEdgeColor(Bitmap icon) {
317         Map<Integer, Integer> colorCount = new HashMap<>();
318         for (int i = 0; i < icon.getWidth(); i++) {
319             // top edge
320             int color = icon.getPixel(i, 0);
321             if (!colorCount.containsKey(color)) colorCount.put(color, 0);
322             colorCount.put(color, colorCount.get(color) + 1);
323 
324             // bottom edge
325             color = icon.getPixel(i, icon.getHeight() - 1);
326             if (!colorCount.containsKey(color)) colorCount.put(color, 0);
327             colorCount.put(color, colorCount.get(color) + 1);
328 
329             // Measure the lateral edges offset by 1 on each side.
330             if (i > 0 && i < icon.getWidth() - 1) {
331                 // left edge
332                 color = icon.getPixel(0, i);
333                 if (!colorCount.containsKey(color)) colorCount.put(color, 0);
334                 colorCount.put(color, colorCount.get(color) + 1);
335 
336                 // right edge
337                 color = icon.getPixel(icon.getWidth() - 1, i);
338                 if (!colorCount.containsKey(color)) colorCount.put(color, 0);
339                 colorCount.put(color, colorCount.get(color) + 1);
340             }
341         }
342 
343         // Find the most common color out of the map.
344         int maxKey = Color.TRANSPARENT;
345         int maxVal = -1;
346         for (int color : colorCount.keySet()) {
347             int count = colorCount.get(color);
348             if (count > maxVal) {
349                 maxKey = color;
350                 maxVal = count;
351             }
352         }
353         assert maxVal > -1;
354 
355         return maxKey;
356     }
357 
358     /**
359      * Records an event to the search engine logo histogram. See {@link Events} and histograms.xml
360      * for more details.
361      * @param event The {@link Events} to be reported.
362      */
363     @VisibleForTesting
recordEvent(@vents int event)364     static void recordEvent(@Events int event) {
365         RecordHistogram.recordEnumeratedHistogram(
366                 "AndroidSearchEngineLogo.Events", event, Events.MAX);
367     }
368 
369     /** Set the favicon helper for testing. */
setFaviconHelperForTesting(FaviconHelper faviconHelper)370     static void setFaviconHelperForTesting(FaviconHelper faviconHelper) {
371         sFaviconHelper = faviconHelper;
372     }
373 
374     /** Set the delegate for testing. */
setDelegateForTesting(Delegate mDelegate)375     static void setDelegateForTesting(Delegate mDelegate) {
376         sDelegate = mDelegate;
377     }
378 
379     /** Set the RoundedIconGenerator for testing. */
setRoundedIconGeneratorForTesting(RoundedIconGenerator roundedIconGenerator)380     static void setRoundedIconGeneratorForTesting(RoundedIconGenerator roundedIconGenerator) {
381         sRoundedIconGenerator = roundedIconGenerator;
382     }
383 
384     /** Reset the cache values for testing. */
resetCacheForTesting()385     static void resetCacheForTesting() {
386         sCachedComposedBackground = null;
387         sCachedComposedBackgroundLogoUrl = null;
388     }
389 }
390