/* 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 org.mozilla.gecko.favicons.cache; import android.graphics.Bitmap; import android.util.Log; import org.mozilla.gecko.favicons.Favicons; import java.util.Iterator; import java.util.LinkedList; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Semaphore; import java.util.concurrent.atomic.AtomicInteger; /** * Implements a Least-Recently-Used cache for Favicons, keyed by Favicon URL. * * When a favicon at a particular URL is decoded, it will yield one or more bitmaps. * While in memory, these bitmaps are stored in a list, sorted in ascending order of size, in a * FaviconsForURL object. * The collection of FaviconsForURL objects currently in the cache is stored in mBackingMap, keyed * by favicon URL. * * A second map exists for permanent cache entries -- ones that are never expired. These entries * are assumed to be disjoint from those in the normal cache, and this map is checked first. * * FaviconsForURL provides a method for obtaining the smallest icon larger than a given size - the * most appropriate icon for a particular size. * It also distinguishes between "primary" favicons (Ones that have merely been extracted from a * file downloaded from the website) and "secondary" favicons (Ones that have been computed locally * as resized versions of primary favicons.). * * FaviconsForURL is also responsible for storing URL-specific, as opposed to favicon-specific, * information. For the purposes of this cache, the simplifying assumption that the dominant colour * for all favicons served from a particular favicon URL shall be the same is made. (To violate this * would mandate serving an ICO or similar file with multiple radically different images in it - an * ill-advised and extremely uncommon use-case, for sure.) * The dominant colour information is updated as the element is being added to the cache - typically * on the background thread. * Also present here are the download timestamp and isFailed flag. Upon failure, the flag is set. * A constant exists in this file to specify the maximum time permitted between failures before * a retry is again permitted. * * TODO: Expiry of Favicons from the favicon database cache is not implemented. (Bug 914296) * * A typical request to the cache will consist of a Favicon URL and a target size. The FaviconsForURL * object for that URL will be obtained, queried for a favicon matching exactly the needed size, and * if successful, the result is returned. * If unsuccessful, the object is asked to return the smallest available primary favicon larger than * the target size. If this step works, the result is downscaled to create a new secondary favicon, * which is then stored (So subsequent requests will succeed at the first step) and returned. * If that step fails, the object finally walks backwards through its sequence of favicons until it * finds the largest primary favicon smaller than the target. This is then upscaled by a maximum of * 2x towards the target size, and the result cached and returned as above. * * The bitmaps themselves are encapsulated inside FaviconCacheElement objects. These objects contain, * as well as the bitmap, a pointer to the encapsulating FaviconsForURL object (Used by the LRU * culler), the size of the encapsulated image, a flag indicating if this is a primary favicon, and * a flag indicating if the entry is invalid. * All FaviconCacheElement objects are tracked in the mOrdering LinkedList. This is used to record * LRU information about FaviconCacheElements. In particular, the most recently used FaviconCacheElement * will be at the start of the list, the least recently used at the end of the list. * * When the cache runs out of space, it removes FaviconCacheElements starting from the end of the list * until a sufficient amount of space has been freed. * When a secondary favicon is removed in this way, it is simply deleted from its parent FaviconsForURLs * object's list of available favicons. * The backpointer field on the FaviconCacheElement is used to remove the element from the encapsulating * FaviconsForURL object, when this is required. * When a primary favicon is removed, its invalid flag is set to true and its bitmap payload is set * to null (So it is available for freeing by the garbage collector). This reduces the memory footprint * of the icon to essentially zero, but keeps track of which primary favicons exist for this favicon * URL. * If a subsequent request comes in for that favicon URL, it is then known that a primary of those * dimensions is available, just that it is not in the cache. The system is then able to load the * primary back into the cache from the database (Where the entirety of the initially encapsulating * container-formatted image file is stored). * If this were not done, then when processing requests after the culling of primary favicons it would * be impossible to distinguish between the nonexistence of a primary and the nonexistence of a primary * in the cache without querying the database. * * The implementation is safe to use from multiple threads and, while is it not entirely strongly * consistent all of the time, you almost certainly don't care. * The thread-safety implementation used is approximately MRSW with semaphores. An extra semaphore * is used to grant mutual exclusion over reordering operations from reader threads (Who thus gain * a quasi-writer status to do such limited mutation as is necessary). * * Reads which race with writes are liable to not see the ongoing write. The cache may return a * stale or now-removed value to the caller. Returned values are never invalid, even in the face * of concurrent reading and culling. */ public class FaviconCache { private String LOGTAG = "FaviconCache"; private static int sInstanceCount = 0; // The number of spaces to allocate for favicons in each node. private static final int NUM_FAVICON_SIZES = 4; // Dimensions of the largest favicon to store in the cache. Everything is downscaled to this. public final int mMaxCachedWidth; // Retry failed favicons after 20 minutes. public static final long FAILURE_RETRY_MILLISECONDS = 1000 * 60 * 20; // Map relating Favicon URLs with objects representing decoded favicons. // Since favicons may be container formats holding multiple icons, the underlying type holds a // sorted list of bitmap payloads in ascending order of size. The underlying type may be queried // for the least larger payload currently present. private final ConcurrentHashMap<String, FaviconsForURL> mBackingMap = new ConcurrentHashMap<String, FaviconsForURL>(); // And the same, but never evicted. private final ConcurrentHashMap<String, FaviconsForURL> mPermanentBackingMap = new ConcurrentHashMap<String, FaviconsForURL>(); // A linked list used to implement a queue, defining the LRU properties of the cache. Elements // contained within the various FaviconsForURL objects are held here, the least recently used // of which at the end of the list. When space needs to be reclaimed, the appropriate bitmap is // culled. private final LinkedList<FaviconCacheElement> mOrdering = new LinkedList<FaviconCacheElement>(); // The above structures, if used correctly, enable this cache to exhibit LRU semantics across all // favicon payloads in the system, as well as enabling the dynamic selection from the cache of // the primary bitmap most suited to the requested size (in cases where multiple primary bitmaps // are provided by the underlying file format). // Current size, in bytes, of the bitmap data present in the LRU cache. private final AtomicInteger mCurrentSize = new AtomicInteger(0); // The maximum quantity, in bytes, of bitmap data which may be stored in the cache. private final int mMaxSizeBytes; // Tracks the number of ongoing read operations. Enables the first one in to lock writers out and // the last one out to let them in. private final AtomicInteger mOngoingReads = new AtomicInteger(0); // Used to ensure transaction fairness - each txn acquires and releases this as the first operation. // The effect is an orderly, inexpensive ordering enforced on txns to prevent writer starvation. private final Semaphore mTurnSemaphore = new Semaphore(1); // A deviation from the usual MRSW solution - this semaphore is used to guard modification to the // ordering map. This allows for read transactions to update the most-recently-used value without // needing to take out the write lock. private final Semaphore mReorderingSemaphore = new Semaphore(1); // The semaphore one must acquire in order to perform a write. private final Semaphore mWriteLock = new Semaphore(1); /** * Called by txns performing only reads as they start. Prevents writer starvation with a turn * semaphore and locks writers out if this is the first concurrent reader txn starting up. */ private void startRead() { mTurnSemaphore.acquireUninterruptibly(); mTurnSemaphore.release(); if (mOngoingReads.incrementAndGet() == 1) { // First one in. Wait for writers to finish and lock them out. mWriteLock.acquireUninterruptibly(); } } /** * An alternative to startWrite to be used when in a read transaction and wanting to upgrade it * to a write transaction. Such a transaction should be terminated with finishWrite. */ private void upgradeReadToWrite() { mTurnSemaphore.acquireUninterruptibly(); if (mOngoingReads.decrementAndGet() == 0) { mWriteLock.release(); } mWriteLock.acquireUninterruptibly(); } /** * Called by transactions performing only reads as they finish. Ensures that if this is the last * concluding read transaction then then writers are subsequently allowed in. */ private void finishRead() { if (mOngoingReads.decrementAndGet() == 0) { mWriteLock.release(); } } /** * Called by writer transactions upon start. Ensures fairness and then obtains the write lock. * Upon return, no other txns will be executing concurrently. */ private void startWrite() { mTurnSemaphore.acquireUninterruptibly(); mWriteLock.acquireUninterruptibly(); } /** * Called by a concluding write transaction - unlocks the structure. */ private void finishWrite() { mTurnSemaphore.release(); mWriteLock.release(); } public FaviconCache(int maxSize, int maxWidthToCache) { mMaxSizeBytes = maxSize; mMaxCachedWidth = maxWidthToCache; LOGTAG += "[" + sInstanceCount + "]"; sInstanceCount++; } /** * Determine if the provided favicon URL is marked as a failure (Has failed to load before - * such icons get blacklisted for a time to prevent us endlessly retrying.) * * @param faviconURL Favicon URL to check if failed in memcache. * @return true if this favicon is blacklisted, false otherwise. */ public boolean isFailedFavicon(String faviconURL) { if (faviconURL == null) { return true; } startRead(); boolean isExpired = false; boolean isAborting = false; try { // If we don't have it in the cache, it certainly isn't a known failure. // Non-evictable favicons are never failed, so we don't need to // check mPermanentBackingMap. if (!mBackingMap.containsKey(faviconURL)) { return false; } FaviconsForURL container = mBackingMap.get(faviconURL); // If the has failed flag is not set, it's certainly not a known failure. if (!container.mHasFailed) { return false; } final long failureTimestamp = container.mDownloadTimestamp; // Calculate elapsed time since the failing download. final long failureDiff = System.currentTimeMillis() - failureTimestamp; // If long enough has passed, mark it as no longer a failure. if (failureDiff > FAILURE_RETRY_MILLISECONDS) { isExpired = true; } else { return true; } } catch (Exception unhandled) { // Handle any exception thrown and return the locks to a sensible state. finishRead(); // Flag to prevent finally from doubly-unlocking. isAborting = true; Log.e(LOGTAG, "FaviconCache exception!", unhandled); return true; } finally { if (!isAborting) { if (isExpired) { // No longer expired. upgradeReadToWrite(); } else { finishRead(); } } } try { recordRemoved(mBackingMap.get(faviconURL)); mBackingMap.remove(faviconURL); return false; } finally { finishWrite(); } } /** * Mark the indicated page URL as a failed Favicon until the provided time. * * @param faviconURL Page URL for which a Favicon load has failed. */ public void putFailed(String faviconURL) { startWrite(); if (mBackingMap.containsKey(faviconURL)) { recordRemoved(mBackingMap.get(faviconURL)); } FaviconsForURL container = new FaviconsForURL(0, true); mBackingMap.put(faviconURL, container); finishWrite(); } /** * Fetch a Favicon for the given URL as close as possible to the size provided. * If an icon of the given size is already in the cache, it is returned. * If an icon of the given size is not in the cache but a larger unscaled image does exist in * the cache, we downscale the larger image to the target size and cache the result. * If there is no image of the required size, null is returned. * * @param faviconURL The URL for which a Favicon is desired. Must not be null. * @param targetSize The size of the desired favicon. * @return A favicon of the requested size for the requested URL, or null if none cached. */ public Bitmap getFaviconForDimensions(String faviconURL, int targetSize) { if (faviconURL == null) { Log.e(LOGTAG, "You passed a null faviconURL to getFaviconForDimensions. Don't."); return null; } boolean doingWrites = false; boolean shouldComputeColour = false; boolean isAborting = false; boolean wasPermanent = false; FaviconsForURL container; final Bitmap newBitmap; startRead(); try { container = mPermanentBackingMap.get(faviconURL); if (container == null) { container = mBackingMap.get(faviconURL); if (container == null) { // We don't have it! return null; } } else { wasPermanent = true; } FaviconCacheElement cacheElement; int cacheElementIndex = container.getNextHighestIndex(targetSize); // cacheElementIndex now holds either the index of the next least largest bitmap from // targetSize, or -1 if targetSize > all bitmaps. if (cacheElementIndex != -1) { // If cacheElementIndex is not the sentinel value, then it is a valid index into mFavicons. cacheElement = container.mFavicons.get(cacheElementIndex); if (cacheElement.mInvalidated) { return null; } // If we found exactly what we wanted - we're done. if (cacheElement.mImageSize == targetSize) { setMostRecentlyUsed(cacheElement); return cacheElement.mFaviconPayload; } } else { // We requested an image larger than all primaries. Set the element to start the search // from to the element beyond the end of the array, so the search runs backwards. cacheElementIndex = container.mFavicons.size(); } // We did not find exactly what we wanted, but now have set cacheElementIndex to the index // where what we want should live in the list. We now request the next least larger primary // from the cache. We will downscale this to our target size. // If there is no such primary, we'll upscale the next least smaller one instead. cacheElement = container.getNextPrimary(cacheElementIndex); if (cacheElement == null) { // The primary has been invalidated! Fail! Need to get it back from the database. return null; } // Having got this far, we'll be needing to write the new secondary to the cache, which // involves us falling through to the next try block. This flag lets us do this (Other // paths prior to this end in returns.) doingWrites = true; // Scaling logic... Bitmap largestElementBitmap = cacheElement.mFaviconPayload; int largestSize = cacheElement.mImageSize; Bitmap scaledBitmap = null; try { if (largestSize >= targetSize) { // The largest we have is larger than the target - downsize to target. scaledBitmap = Bitmap.createScaledBitmap(largestElementBitmap, targetSize, targetSize, true); } else { // Our largest primary is smaller than the desired size. Upscale by a maximum of 2x. // largestSize now reflects the maximum size we can upscale to. largestSize *= 2; if (largestSize >= targetSize) { // Perfect! We can upscale by less than 2x and reach the needed size. Do it. scaledBitmap = Bitmap.createScaledBitmap(largestElementBitmap, targetSize, targetSize, true); } else { shouldComputeColour = true; // We don't have enough information to make the target size look nonterrible. Best effort: scaledBitmap = Bitmap.createScaledBitmap(largestElementBitmap, largestSize, largestSize, true); } } } catch (OutOfMemoryError e) { scaledBitmap = largestElementBitmap; } newBitmap = scaledBitmap; } catch (Exception unhandled) { isAborting = true; // Handle any exception thrown and return the locks to a sensible state. finishRead(); // Flag to prevent finally from doubly-unlocking. Log.e(LOGTAG, "FaviconCache exception!", unhandled); return null; } finally { if (!isAborting) { if (doingWrites) { upgradeReadToWrite(); } else { finishRead(); } } } try { if (shouldComputeColour) { // And since we failed, we'll need the dominant colour. container.ensureDominantColor(); } // While the image might not actually BE that size, we set the size field to the target // because this is the best image you can get for a request of that size using the Favicon // information provided by this website. // This way, subsequent requests hit straight away. FaviconCacheElement newElement = container.addSecondary(newBitmap, targetSize); if (!wasPermanent) { setMostRecentlyUsed(newElement); mCurrentSize.addAndGet(newElement.sizeOf()); } } finally { finishWrite(); } return newBitmap; } /** * Query the cache for the dominant colour stored for the Favicon URL provided, if any. * * @param key The URL of the Favicon for which a dominant colour is desired. * @return The cached dominant colour, or null if none is cached. */ public int getDominantColor(String key) { startRead(); try { FaviconsForURL element = mPermanentBackingMap.get(key); if (element == null) { element = mBackingMap.get(key); } if (element == null) { Log.w(LOGTAG, "Cannot compute dominant color of non-cached favicon. Cache fullness " + mCurrentSize.get() + '/' + mMaxSizeBytes); finishRead(); return 0xFFFFFF; } return element.ensureDominantColor(); } finally { finishRead(); } } /** * Remove all payloads stored in the given container from the LRU cache. Must be called while * holding the write lock. * * @param wasRemoved The container to purge from the cache. */ private void recordRemoved(FaviconsForURL wasRemoved) { // If there was an existing value, strip it from the insertion-order cache. if (wasRemoved == null) { return; } int sizeRemoved = 0; for (FaviconCacheElement e : wasRemoved.mFavicons) { sizeRemoved += e.sizeOf(); mOrdering.remove(e); } mCurrentSize.addAndGet(-sizeRemoved); } private Bitmap produceCacheableBitmap(Bitmap favicon) { // Never cache the default Favicon, or the null Favicon. if (favicon == Favicons.sDefaultFavicon || favicon == null) { return null; } // Some sites serve up insanely huge Favicons (Seen 512x512 ones...) // While we want to cache nice big icons, we apply a limit based on screen density for the // sake of space. if (favicon.getWidth() > mMaxCachedWidth) { try { return Bitmap.createScaledBitmap(favicon, mMaxCachedWidth, mMaxCachedWidth, true); } catch (OutOfMemoryError e) { // Fall through } } return favicon; } /** * Set an existing element as the most recently used element. May be called from either type of * transaction. * * @param element The element that is to become the most recently used one. */ private void setMostRecentlyUsed(FaviconCacheElement element) { mReorderingSemaphore.acquireUninterruptibly(); mOrdering.remove(element); mOrdering.offer(element); mReorderingSemaphore.release(); } /** * Add the provided bitmap to the cache as the only available primary for this URL. * Should never be called with scaled Favicons. The input is assumed to be an unscaled Favicon. * * @param faviconURL The URL of the Favicon being stored. * @param aFavicon The Favicon to store. */ public void putSingleFavicon(String faviconURL, Bitmap aFavicon) { Bitmap favicon = produceCacheableBitmap(aFavicon); if (favicon == null) { return; } // Create a fresh container for the favicons associated with this URL. Allocate extra slots // in the underlying ArrayList in case multiple secondary favicons are later created. // Currently set to the number of favicon sizes used in the UI, plus 1, at time of writing. // Ought to be tuned as things change for maximal performance. FaviconsForURL toInsert = new FaviconsForURL(NUM_FAVICON_SIZES); // Create the cache element for the single element we are inserting, and configure it. FaviconCacheElement newElement = toInsert.addPrimary(favicon); startWrite(); try { // Set the new element as the most recently used one. setMostRecentlyUsed(newElement); mCurrentSize.addAndGet(newElement.sizeOf()); // Update the value in the LruCache... FaviconsForURL wasRemoved; wasRemoved = mBackingMap.put(faviconURL, toInsert); recordRemoved(wasRemoved); } finally { finishWrite(); } cullIfRequired(); } /** * Set the collection of primary favicons for the given URL to the provided collection of bitmaps. * * @param faviconURL The URL from which the favicons originate. * @param favicons A List of favicons decoded from this URL. * @param permanently If true, the added favicons are never subject to eviction. */ public void putFavicons(String faviconURL, Iterator<Bitmap> favicons, boolean permanently) { // We don't know how many icons we'll have - let's just take a guess. FaviconsForURL toInsert = new FaviconsForURL(5 * NUM_FAVICON_SIZES); int sizeGained = 0; while (favicons.hasNext()) { Bitmap favicon = produceCacheableBitmap(favicons.next()); if (favicon == null) { continue; } FaviconCacheElement newElement = toInsert.addPrimary(favicon); sizeGained += newElement.sizeOf(); } startRead(); boolean abortingRead = false; // Not using setMostRecentlyUsed, because the elements are known to be new. This can be done // without taking the write lock, via the magic of the reordering semaphore. mReorderingSemaphore.acquireUninterruptibly(); try { if (!permanently) { for (FaviconCacheElement newElement : toInsert.mFavicons) { mOrdering.offer(newElement); } } } catch (Exception e) { abortingRead = true; mReorderingSemaphore.release(); finishRead(); Log.e(LOGTAG, "Favicon cache exception!", e); return; } finally { if (!abortingRead) { mReorderingSemaphore.release(); upgradeReadToWrite(); } } try { if (permanently) { mPermanentBackingMap.put(faviconURL, toInsert); } else { mCurrentSize.addAndGet(sizeGained); // Update the value in the LruCache... recordRemoved(mBackingMap.put(faviconURL, toInsert)); } } finally { finishWrite(); } cullIfRequired(); } /** * If cache too large, drop stuff from the cache to get the size back into the acceptable range. * Otherwise, do nothing. */ private void cullIfRequired() { Log.d(LOGTAG, "Favicon cache fullness: " + mCurrentSize.get() + '/' + mMaxSizeBytes); if (mCurrentSize.get() <= mMaxSizeBytes) { return; } startWrite(); try { while (mCurrentSize.get() > mMaxSizeBytes) { // Cull the least recently used element. FaviconCacheElement victim; victim = mOrdering.poll(); mCurrentSize.addAndGet(-victim.sizeOf()); victim.onEvictedFromCache(); Log.d(LOGTAG, "After cull: " + mCurrentSize.get() + '/' + mMaxSizeBytes); } } finally { finishWrite(); } } /** * Purge all elements from the FaviconCache. Handy if you want to reclaim some memory. */ public void evictAll() { startWrite(); // Note that we neither clear, nor track the size of, the permanent map. try { mCurrentSize.set(0); mBackingMap.clear(); mOrdering.clear(); } finally { finishWrite(); } } }