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