// Copyright 2015 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.partnerbookmarks;
import android.content.Context;
import org.jni_zero.CalledByNative;
import org.jni_zero.NativeMethods;
import org.chromium.base.Log;
import org.chromium.base.task.AsyncTask;
import org.chromium.chrome.browser.AppHooks;
import org.chromium.chrome.browser.partnercustomizations.PartnerBrowserCustomizations;
import org.chromium.ui.base.ViewUtils;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Set;
import javax.annotation.concurrent.GuardedBy;
/** Reads bookmarks from the partner content provider (if any). */
public class PartnerBookmarksReader {
private static final String TAG = "PartnerBMReader";
private static Set<FaviconUpdateObserver> sFaviconUpdateObservers = new HashSet<>();
private static final float DESIRED_FAVICON_SIZE_DP = 16.0f;
private static boolean sInitialized;
private static boolean sForceDisableEditing;
/** Root bookmark id reserved for the implied root of the bookmarks */
static final long ROOT_FOLDER_ID = 0;
/** ID used to indicate an invalid bookmark node. */
static final long INVALID_BOOKMARK_ID = -1;
/** Storage for failed favicon retrieval attempts to throttle future requests. **/
private PartnerBookmarksFaviconThrottle mFaviconThrottle;
// JNI c++ pointer
private long mNativePartnerBookmarksReader;
/** The context (used to get a ContentResolver) */
protected Context mContext;
// Favicons are loaded asynchronously so we need to keep track of how many are currently in
// progress, as well as whether or not we've finished reading bookmarks from this class so we
// don't end up shutting the bookmark reader down prematurely.
private final Object mProgressLock = new Object();
@GuardedBy("mProgressLock")
private int mNumFaviconsInProgress;
@GuardedBy("mProgressLock")
private boolean mShutDown;
@GuardedBy("mProgressLock")
private boolean mFaviconsFetchedFromServer;
private boolean mFinishedReading;
private boolean mFinishedResolvingBrowserCustomizations;
/** Observer for listeners to receive updates when changes are made to the favicon cache. */
public interface FaviconUpdateObserver {
/**
* Called when a favicon has been updated, so observers can update their view if necessary.
*
* @param url The URL of the page for the favicon being updated.
*/
void onUpdateFavicon(String url);
/** Called when all favicon loading for the partner bookmarks has completed. */
void onCompletedFaviconLoading();
}
/**
* A callback used to indicate success or failure of favicon fetching when retrieving favicons
* from cache or server.
*/
interface FetchFaviconCallback {
@CalledByNative("FetchFaviconCallback")
void onFaviconFetched(@FaviconFetchResult int result);
@CalledByNative("FetchFaviconCallback")
void onFaviconFetch();
}
/**
* Creates the instance of the reader.
* @param context A Context object.
* @param browserCustomizations Provides status of partner customizations.
*/
public PartnerBookmarksReader(
Context context, PartnerBrowserCustomizations browserCustomizations) {
mContext = context;
mNativePartnerBookmarksReader =
PartnerBookmarksReaderJni.get().init(PartnerBookmarksReader.this);
if (!browserCustomizations.isInitialized()) {
browserCustomizations.initializeAsync(context);
}
browserCustomizations.setOnInitializeAsyncFinished(
() -> {
if (browserCustomizations.isBookmarksEditingDisabled()) {
PartnerBookmarksReaderJni.get().disablePartnerBookmarksEditing();
}
mFinishedResolvingBrowserCustomizations = true;
maybeMarkCreationComplete();
});
}
/**
* Adds an observer for favicon updates as a result of fetching favicons from server during
* partner bookmark loading.
*
* @param observer The observer to add to the static list of observers.
*/
public static void addFaviconUpdateObserver(FaviconUpdateObserver observer) {
sFaviconUpdateObservers.add(observer);
}
/**
* Removes an observer for favicon updates as a result of fetching favicons from server during
* partner bookmark loading.
*
* @param observer The observer to remove from the static list of observers.
*/
public static void removeFaviconUpdateObserver(FaviconUpdateObserver observer) {
sFaviconUpdateObservers.remove(observer);
}
/** Asynchronously read bookmarks from the partner content provider */
public void readBookmarks() {
if (mNativePartnerBookmarksReader == 0) {
assert false : "readBookmarks called after PartnerBookmarksReaderJni.get().destroy.";
return;
}
new ReadBookmarksTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
/**
* Called when the partner bookmark needs to be pushed.
* @param url The URL.
* @param title The title.
* @param isFolder True if it's a folder.
* @param parentId NATIVE parent folder id.
* @param favicon .PNG blob for icon; used if no touchicon is set.
* @param touchicon .PNG blob for icon.
* @return NATIVE id of a bookmark
*/
private long onBookmarkPush(
String url,
String title,
boolean isFolder,
long parentId,
byte[] favicon,
byte[] touchicon) {
FetchFaviconCallback callback =
new FetchFaviconCallback() {
@Override
public void onFaviconFetched(@FaviconFetchResult int result) {
synchronized (mProgressLock) {
if (result == FaviconFetchResult.SUCCESS_FROM_SERVER) {
// If we've fetched a new favicon from a server, store a flag to
// indicate this so we can refresh bookmarks when all favicons are
// fetched.
mFaviconsFetchedFromServer = true;
for (FaviconUpdateObserver observer : sFaviconUpdateObservers) {
observer.onUpdateFavicon(
PartnerBookmarksReaderJni.get()
.getNativeUrlString(url));
}
}
mFaviconThrottle.onFaviconFetched(url, result);
--mNumFaviconsInProgress;
if (canShutdown()) shutDown();
}
}
@Override
public void onFaviconFetch() {
synchronized (mProgressLock) {
++mNumFaviconsInProgress;
}
}
};
return PartnerBookmarksReaderJni.get()
.addPartnerBookmark(
mNativePartnerBookmarksReader,
PartnerBookmarksReader.this,
url,
title,
isFolder,
parentId,
favicon,
touchicon,
mFaviconThrottle.shouldFetchFromServerIfNecessary(url),
ViewUtils.dpToPx(mContext, DESIRED_FAVICON_SIZE_DP),
callback);
}
/**
* Sets our finished reading flag, and if there is no work being done on the native side, shuts
* down the bookmark reader.
*/
protected void onBookmarksRead() {
mFinishedReading = true;
maybeMarkCreationComplete();
synchronized (mProgressLock) {
if (canShutdown()) shutDown();
}
}
private void maybeMarkCreationComplete() {
if (!mFinishedReading || !mFinishedResolvingBrowserCustomizations) return;
PartnerBookmarksReaderJni.get()
.partnerBookmarksCreationComplete(
mNativePartnerBookmarksReader, PartnerBookmarksReader.this);
}
@GuardedBy("mProgressLock")
private boolean canShutdown() {
return mNumFaviconsInProgress == 0
&& mFinishedReading
&& mFinishedResolvingBrowserCustomizations;
}
/**
* Notifies the reader is complete, refreshes the partner bookmarks if necessary, and kills the
* native object
*/
protected void shutDown() {
synchronized (mProgressLock) {
if (mShutDown) return;
if (mFaviconThrottle != null) {
mFaviconThrottle.commit();
}
// Make sure we refresh the bookmarks if we were fetching favicons from server, now that
// we have them all.
if (mFaviconsFetchedFromServer) {
for (FaviconUpdateObserver observer : sFaviconUpdateObservers) {
observer.onCompletedFaviconLoading();
}
}
PartnerBookmarksReaderJni.get()
.destroy(mNativePartnerBookmarksReader, PartnerBookmarksReader.this);
mNativePartnerBookmarksReader = 0;
mShutDown = true;
}
}
/** Handles fetching partner bookmarks in a background thread. */
private class ReadBookmarksTask extends AsyncTask<Void> {
private final Object mRootSync = new Object();
@Override
protected Void doInBackground() {
if (mFaviconThrottle == null) {
// Initialize the throttle here since we need to load shared preferences on the
// background thread as well.
mFaviconThrottle = new PartnerBookmarksFaviconThrottle();
}
PartnerBookmark.BookmarkIterator bookmarkIterator =
AppHooks.get().getPartnerBookmarkIterator();
if (bookmarkIterator == null) return null;
// Get a snapshot of the bookmarks.
LinkedHashMap<Long, PartnerBookmark> idMap = new LinkedHashMap<Long, PartnerBookmark>();
HashSet<String> urlSet = new HashSet<String>();
PartnerBookmark rootBookmarksFolder = createRootBookmarksFolderBookmark();
idMap.put(ROOT_FOLDER_ID, rootBookmarksFolder);
while (bookmarkIterator.hasNext()) {
PartnerBookmark bookmark = bookmarkIterator.next();
if (bookmark == null) continue;
// Check for duplicate ids.
if (idMap.containsKey(bookmark.mId)) {
Log.i(TAG, "Duplicate bookmark id: " + bookmark.mId + ". Dropping bookmark.");
continue;
}
// Check for duplicate URLs.
if (!bookmark.mIsFolder && urlSet.contains(bookmark.mUrl)) {
Log.i(
TAG,
"More than one bookmark pointing to "
+ bookmark.mUrl
+ ". "
+ "Keeping only the first one for consistency with Chromium.");
continue;
}
idMap.put(bookmark.mId, bookmark);
urlSet.add(bookmark.mUrl);
}
bookmarkIterator.close();
// Recreate the folder hierarchy and read it.
recreateFolderHierarchy(idMap);
if (rootBookmarksFolder.mEntries.size() == 0) {
Log.e(TAG, "ATTENTION: not using partner bookmarks as none were provided");
return null;
}
if (rootBookmarksFolder.mEntries.size() != 1) {
Log.e(TAG, "ATTENTION: more than one top-level partner bookmarks, ignored");
return null;
}
readBookmarkHierarchy(rootBookmarksFolder, new HashSet<PartnerBookmark>());
return null;
}
@Override
protected void onPostExecute(Void v) {
synchronized (mRootSync) {
onBookmarksRead();
}
}
private void recreateFolderHierarchy(LinkedHashMap<Long, PartnerBookmark> idMap) {
for (PartnerBookmark bookmark : idMap.values()) {
if (bookmark.mId == ROOT_FOLDER_ID) continue;
// Look for invalid parent ids and self-cycles.
if (!idMap.containsKey(bookmark.mParentId) || bookmark.mParentId == bookmark.mId) {
bookmark.mParent = idMap.get(ROOT_FOLDER_ID);
bookmark.mParent.mEntries.add(bookmark);
continue;
}
bookmark.mParent = idMap.get(bookmark.mParentId);
bookmark.mParent.mEntries.add(bookmark);
}
}
private PartnerBookmark createRootBookmarksFolderBookmark() {
PartnerBookmark root = new PartnerBookmark();
root.mId = ROOT_FOLDER_ID;
root.mTitle = "[IMPLIED_ROOT]";
root.mNativeId = INVALID_BOOKMARK_ID;
root.mParentId = ROOT_FOLDER_ID;
root.mIsFolder = true;
return root;
}
private void readBookmarkHierarchy(
PartnerBookmark bookmark, HashSet<PartnerBookmark> processedNodes) {
// Avoid cycles in the hierarchy that could lead to infinite loops.
if (processedNodes.contains(bookmark)) return;
processedNodes.add(bookmark);
if (bookmark.mId != ROOT_FOLDER_ID) {
try {
synchronized (mRootSync) {
bookmark.mNativeId =
onBookmarkPush(
bookmark.mUrl,
bookmark.mTitle,
bookmark.mIsFolder,
bookmark.mParentId,
bookmark.mFavicon,
bookmark.mTouchicon);
}
} catch (IllegalArgumentException e) {
Log.w(TAG, "Error inserting bookmark " + bookmark.mTitle, e);
}
if (bookmark.mNativeId == INVALID_BOOKMARK_ID) {
Log.e(TAG, "Error creating bookmark '" + bookmark.mTitle + "'.");
return;
}
}
if (bookmark.mIsFolder) {
for (PartnerBookmark entry : bookmark.mEntries) {
if (entry.mParent != bookmark) {
Log.w(
TAG,
"Hierarchy error in bookmark '" + bookmark.mTitle + "'. Skipping.");
continue;
}
entry.mParentId = bookmark.mNativeId;
readBookmarkHierarchy(entry, processedNodes);
}
}
}
}
@NativeMethods
interface Natives {
long init(PartnerBookmarksReader caller);
void reset(long nativePartnerBookmarksReader, PartnerBookmarksReader caller);
void destroy(long nativePartnerBookmarksReader, PartnerBookmarksReader caller);
long addPartnerBookmark(
long nativePartnerBookmarksReader,
PartnerBookmarksReader caller,
String url,
String title,
boolean isFolder,
long parentId,
byte[] favicon,
byte[] touchicon,
boolean fetchUncachedFaviconsFromServer,
int desiredFaviconSizePx,
FetchFaviconCallback callback);
void partnerBookmarksCreationComplete(
long nativePartnerBookmarksReader, PartnerBookmarksReader caller);
String getNativeUrlString(String url);
void disablePartnerBookmarksEditing();
}
}