// Copyright 2015 The Chromium Authors. All rights reserved. // 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.bookmarkswidget; import android.annotation.SuppressLint; import android.appwidget.AppWidgetManager; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.res.Resources; import android.graphics.Bitmap; import android.net.Uri; import android.os.StrictMode; import android.support.annotation.BinderThread; import android.support.annotation.UiThread; import android.text.TextUtils; import android.widget.RemoteViews; import android.widget.RemoteViewsService; import com.google.android.apps.chrome.appwidget.bookmarks.BookmarkThumbnailWidgetProvider; import org.chromium.base.ApiCompatibilityUtils; import org.chromium.base.Log; import org.chromium.base.ThreadUtils; import org.chromium.base.annotations.SuppressFBWarnings; import org.chromium.base.library_loader.ProcessInitException; import org.chromium.base.metrics.RecordUserAction; import org.chromium.chrome.R; import org.chromium.chrome.browser.bookmarks.BookmarkBridge.BookmarkItem; import org.chromium.chrome.browser.bookmarks.BookmarkBridge.BookmarkModelObserver; import org.chromium.chrome.browser.bookmarks.BookmarkModel; import org.chromium.chrome.browser.favicon.LargeIconBridge; import org.chromium.chrome.browser.favicon.LargeIconBridge.LargeIconCallback; import org.chromium.chrome.browser.init.ChromeBrowserInitializer; import org.chromium.chrome.browser.partnerbookmarks.PartnerBookmarksShim; import org.chromium.chrome.browser.profiles.Profile; import org.chromium.chrome.browser.util.IntentUtils; import org.chromium.chrome.browser.widget.RoundedIconGenerator; import org.chromium.components.bookmarks.BookmarkId; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.concurrent.LinkedBlockingQueue; import javax.annotation.Nullable; /** * Service to support the bookmarks widget. * * This provides the list of bookmarks to show in the widget via a RemoteViewsFactory (the * RemoteViews equivalent of an Adapter), and updates the widget when the bookmark model changes. * * Threading note: Be careful! Android calls some methods in this class on the UI thread and others * on (multiple) binder threads. Additionally, all interaction with the BookmarkModel must happen on * the UI thread. To keep the situation clear, every non-static method is annotated with either * {@link UiThread} or {@link BinderThread}. */ public class BookmarkWidgetService extends RemoteViewsService { private static final String TAG = "BookmarkWidget"; private static final String ACTION_CHANGE_FOLDER_SUFFIX = ".CHANGE_FOLDER"; private static final String PREF_CURRENT_FOLDER = "bookmarkswidget.current_folder"; private static final String EXTRA_FOLDER_ID = "folderId"; @UiThread @Override public RemoteViewsFactory onGetViewFactory(Intent intent) { int widgetId = IntentUtils.safeGetIntExtra(intent, AppWidgetManager.EXTRA_APPWIDGET_ID, -1); if (widgetId < 0) { Log.w(TAG, "Missing EXTRA_APPWIDGET_ID!"); return null; } return new BookmarkAdapter(this, widgetId); } static String getChangeFolderAction(Context context) { return context.getPackageName() + ACTION_CHANGE_FOLDER_SUFFIX; } // TODO(crbug.com/635567): Fix this properly. @SuppressLint("DefaultLocale") static SharedPreferences getWidgetState(Context context, int widgetId) { StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskReads(); StrictMode.allowThreadDiskWrites(); try { return context.getSharedPreferences( String.format("widgetState-%d", widgetId), Context.MODE_PRIVATE); } finally { StrictMode.setThreadPolicy(oldPolicy); } } static void deleteWidgetState(Context context, int widgetId) { SharedPreferences preferences = getWidgetState(context, widgetId); if (preferences != null) preferences.edit().clear().apply(); } static void changeFolder(Context context, Intent intent) { int widgetId = IntentUtils.safeGetIntExtra(intent, AppWidgetManager.EXTRA_APPWIDGET_ID, -1); String serializedFolder = IntentUtils.safeGetStringExtra(intent, EXTRA_FOLDER_ID); if (widgetId >= 0 && serializedFolder != null) { SharedPreferences prefs = getWidgetState(context, widgetId); prefs.edit().putString(PREF_CURRENT_FOLDER, serializedFolder).apply(); AppWidgetManager.getInstance(context) .notifyAppWidgetViewDataChanged(widgetId, R.id.bookmarks_list); } } /** * Holds data describing a bookmark or bookmark folder. */ private static class Bookmark { public String title; public String url; public BookmarkId id; public BookmarkId parentId; public boolean isFolder; public Bitmap favicon; public static Bookmark fromBookmarkItem(BookmarkItem item) { if (item == null) return null; Bookmark bookmark = new Bookmark(); bookmark.title = item.getTitle(); bookmark.url = item.getUrl(); bookmark.id = item.getId(); bookmark.parentId = item.getParentId(); bookmark.isFolder = item.isFolder(); return bookmark; } } /** * Holds the list of bookmarks in a folder, as well as information about the folder itself and * its parent folder, if any. */ private static class BookmarkFolder { public Bookmark folder; @Nullable public Bookmark parent; public final List<Bookmark> children = new ArrayList<>(); } /** * Called when the BookmarkLoader has finished loading the bookmark folder. */ private interface BookmarkLoaderCallback { @UiThread void onBookmarksLoaded(BookmarkFolder folder); } /** * Loads a BookmarkFolder asynchronously, and returns the result via BookmarkLoaderCallback. * * This class must be used only on the UI thread. */ private static class BookmarkLoader { private BookmarkLoaderCallback mCallback; private BookmarkFolder mFolder; private BookmarkModel mBookmarkModel; private LargeIconBridge mLargeIconBridge; private RoundedIconGenerator mIconGenerator; private int mMinIconSizeDp; private int mDisplayedIconSize; private int mCornerRadius; private int mRemainingTaskCount; @UiThread public void initialize(Context context, final BookmarkId folderId, BookmarkLoaderCallback callback) { mCallback = callback; Resources res = context.getResources(); mLargeIconBridge = new LargeIconBridge( Profile.getLastUsedProfile().getOriginalProfile()); mMinIconSizeDp = (int) res.getDimension(R.dimen.default_favicon_min_size); mDisplayedIconSize = res.getDimensionPixelSize(R.dimen.default_favicon_size); mCornerRadius = res.getDimensionPixelSize(R.dimen.default_favicon_corner_radius); int textSize = res.getDimensionPixelSize(R.dimen.default_favicon_icon_text_size); int iconColor = ApiCompatibilityUtils.getColor(res, R.color.default_favicon_background_color); mIconGenerator = new RoundedIconGenerator(mDisplayedIconSize, mDisplayedIconSize, mCornerRadius, iconColor, textSize); mRemainingTaskCount = 1; mBookmarkModel = new BookmarkModel(); mBookmarkModel.runAfterBookmarkModelLoaded(new Runnable() { @Override public void run() { loadBookmarks(folderId); } }); } @UiThread private void loadBookmarks(BookmarkId folderId) { mFolder = new BookmarkFolder(); // Load the requested folder if it exists. Otherwise, fall back to the default folder. if (folderId != null) { mFolder.folder = Bookmark.fromBookmarkItem(mBookmarkModel.getBookmarkById( folderId)); } if (mFolder.folder == null) { folderId = mBookmarkModel.getDefaultFolder(); mFolder.folder = Bookmark.fromBookmarkItem(mBookmarkModel.getBookmarkById( folderId)); } mFolder.parent = Bookmark.fromBookmarkItem(mBookmarkModel.getBookmarkById( mFolder.folder.parentId)); List<BookmarkItem> items = mBookmarkModel.getBookmarksForFolder(folderId); // Move folders to the beginning of the list. Collections.sort(items, new Comparator<BookmarkItem>() { @Override public int compare(BookmarkItem lhs, BookmarkItem rhs) { return lhs.isFolder() == rhs.isFolder() ? 0 : lhs.isFolder() ? -1 : 1; } }); for (BookmarkItem item : items) { Bookmark bookmark = Bookmark.fromBookmarkItem(item); loadFavicon(bookmark); mFolder.children.add(bookmark); } taskFinished(); } @UiThread private void loadFavicon(final Bookmark bookmark) { if (bookmark.isFolder) return; mRemainingTaskCount++; LargeIconCallback callback = new LargeIconCallback() { @Override public void onLargeIconAvailable( Bitmap icon, int fallbackColor, boolean isFallbackColorDefault) { if (icon == null) { mIconGenerator.setBackgroundColor(fallbackColor); icon = mIconGenerator.generateIconForUrl(bookmark.url); } else { icon = Bitmap.createScaledBitmap(icon, mDisplayedIconSize, mDisplayedIconSize, true); } bookmark.favicon = icon; taskFinished(); } }; mLargeIconBridge.getLargeIconForUrl(bookmark.url, mMinIconSizeDp, callback); } @UiThread private void taskFinished() { mRemainingTaskCount--; if (mRemainingTaskCount == 0) { mCallback.onBookmarksLoaded(mFolder); destroy(); } } @UiThread private void destroy() { mBookmarkModel.destroy(); mLargeIconBridge.destroy(); } } /** * Provides the RemoteViews, one per bookmark, to be shown in the widget. */ private static class BookmarkAdapter implements RemoteViewsService.RemoteViewsFactory { // Can be accessed on any thread private final Context mContext; private final int mWidgetId; private final SharedPreferences mPreferences; // Accessed only on the UI thread private BookmarkModel mBookmarkModel; // Accessed only on binder threads. private BookmarkFolder mCurrentFolder; @UiThread public BookmarkAdapter(Context context, int widgetId) { mContext = context; mWidgetId = widgetId; mPreferences = getWidgetState(mContext, mWidgetId); } @UiThread @SuppressFBWarnings("DM_EXIT") @Override public void onCreate() { // Required to be applied here redundantly to prevent crashes in the cases where the // package data is deleted or the Chrome application forced to stop. try { ChromeBrowserInitializer.getInstance(mContext).handleSynchronousStartup(); } catch (ProcessInitException e) { Log.e(TAG, "Failed to start browser process.", e); // Since the library failed to initialize nothing in the application // can work, so kill the whole application not just the activity System.exit(-1); } if (isWidgetNewlyCreated()) { RecordUserAction.record("BookmarkNavigatorWidgetAdded"); } // Partner bookmarks need to be loaded explicitly. PartnerBookmarksShim.kickOffReading(mContext); mBookmarkModel = new BookmarkModel(); mBookmarkModel.addObserver(new BookmarkModelObserver() { @Override public void bookmarkModelLoaded() { // Do nothing. No need to refresh. } @Override public void bookmarkModelChanged() { refreshWidget(); } }); } @UiThread private boolean isWidgetNewlyCreated() { // This method relies on the fact that PREF_CURRENT_FOLDER is not yet // set when onCreate is called for a newly created widget. String serializedFolder = mPreferences.getString(PREF_CURRENT_FOLDER, null); return serializedFolder == null; } @UiThread private void refreshWidget() { mContext.sendBroadcast(new Intent( BookmarkWidgetProvider.getBookmarkAppWidgetUpdateAction(mContext), null, mContext, BookmarkThumbnailWidgetProvider.class) .putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mWidgetId)); } // ---------------------------------------------------------------- // // Methods below this line are called on binder threads. // // ---------------------------------------------------------------- // // Different methods may be called on *different* binder threads, // // but the system ensures that the effects of each method call will // // be visible before the next method is called. Thus, additional // // synchronization is not needed when accessing mCurrentFolder. // // ---------------------------------------------------------------- // @BinderThread @Override public void onDestroy() { ThreadUtils.runOnUiThread(new Runnable() { @Override public void run() { if (mBookmarkModel != null) mBookmarkModel.destroy(); } }); deleteWidgetState(mContext, mWidgetId); } @BinderThread @Override public void onDataSetChanged() { updateBookmarkList(); } @BinderThread private void updateBookmarkList() { BookmarkId folderId = BookmarkId .getBookmarkIdFromString(mPreferences.getString(PREF_CURRENT_FOLDER, null)); mCurrentFolder = loadBookmarks(folderId); mPreferences.edit().putString(PREF_CURRENT_FOLDER, mCurrentFolder.folder.id.toString()) .apply(); } @BinderThread private BookmarkFolder loadBookmarks(final BookmarkId folderId) { final LinkedBlockingQueue<BookmarkFolder> resultQueue = new LinkedBlockingQueue<>(1); //A reference of BookmarkLoader is needed in binder thread to //prevent it from being garbage collected. final BookmarkLoader bookmarkLoader = new BookmarkLoader(); ThreadUtils.runOnUiThread(new Runnable() { @Override public void run() { bookmarkLoader.initialize(mContext, folderId, new BookmarkLoaderCallback() { @Override public void onBookmarksLoaded(BookmarkFolder folder) { resultQueue.add(folder); } }); } }); try { return resultQueue.take(); } catch (InterruptedException e) { return null; } } @BinderThread private Bookmark getBookmarkForPosition(int position) { if (mCurrentFolder == null) return null; // The position 0 is saved for an entry of the current folder used to go up. // This is not the case when the current node has no parent (it's the root node). if (mCurrentFolder.parent != null) { if (position == 0) return mCurrentFolder.folder; position--; } return mCurrentFolder.children.get(position); } @BinderThread @Override public int getViewTypeCount() { return 2; } @BinderThread @Override public boolean hasStableIds() { return false; } @BinderThread @Override public int getCount() { //On some Sony devices, getCount() could be called before onDatasetChanged() //returns. If it happens, refresh widget until the bookmarks are all loaded. if (mCurrentFolder == null || !mPreferences.getString(PREF_CURRENT_FOLDER, "") .equals(mCurrentFolder.folder.id.toString())) { ThreadUtils.runOnUiThread(new Runnable() { @Override public void run() { refreshWidget(); } }); } if (mCurrentFolder == null) { return 0; } return mCurrentFolder.children.size() + (mCurrentFolder.parent != null ? 1 : 0); } @BinderThread @Override public long getItemId(int position) { return getBookmarkForPosition(position).id.getId(); } @BinderThread @Override public RemoteViews getLoadingView() { return new RemoteViews(mContext.getPackageName(), R.layout.bookmark_widget_item); } @BinderThread @Override public RemoteViews getViewAt(int position) { if (mCurrentFolder == null) { Log.w(TAG, "No current folder data available."); return null; } Bookmark bookmark = getBookmarkForPosition(position); if (bookmark == null) { Log.w(TAG, "Couldn't get bookmark for position %d", position); return null; } String title = bookmark.title; String url = bookmark.url; BookmarkId id = (bookmark == mCurrentFolder.folder) ? mCurrentFolder.parent.id : bookmark.id; RemoteViews views = new RemoteViews(mContext.getPackageName(), R.layout.bookmark_widget_item); // Set the title of the bookmark. Use the url as a backup. views.setTextViewText(R.id.title, TextUtils.isEmpty(title) ? url : title); if (bookmark == mCurrentFolder.folder) { views.setImageViewResource(R.id.favicon, R.drawable.back_normal); } else if (bookmark.isFolder) { views.setImageViewResource(R.id.favicon, R.drawable.bookmark_folder); } else { views.setImageViewBitmap(R.id.favicon, bookmark.favicon); } Intent fillIn; if (bookmark.isFolder) { fillIn = new Intent(getChangeFolderAction(mContext)) .putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mWidgetId) .putExtra(EXTRA_FOLDER_ID, id.toString()); } else { fillIn = new Intent(Intent.ACTION_VIEW); if (!TextUtils.isEmpty(url)) { fillIn = fillIn.addCategory(Intent.CATEGORY_BROWSABLE) .setData(Uri.parse(url)); } else { fillIn = fillIn.addCategory(Intent.CATEGORY_LAUNCHER); } } views.setOnClickFillInIntent(R.id.list_item, fillIn); return views; } } }