summaryrefslogtreecommitdiffstats
path: root/mobile/android/base/java/org/mozilla/gecko/home
diff options
context:
space:
mode:
authorMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
committerMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
commit5f8de423f190bbb79a62f804151bc24824fa32d8 (patch)
tree10027f336435511475e392454359edea8e25895d /mobile/android/base/java/org/mozilla/gecko/home
parent49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff)
downloadUXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip
Add m-esr52 at 52.6.0
Diffstat (limited to 'mobile/android/base/java/org/mozilla/gecko/home')
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/BookmarkFolderView.java147
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/BookmarkScreenshotRow.java67
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/BookmarksListAdapter.java352
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/BookmarksListView.java218
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/BookmarksPanel.java316
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/BrowserSearch.java1316
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/ClientsAdapter.java373
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryAdapter.java433
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryItem.java127
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryPanel.java697
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryRecyclerView.java145
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/DynamicPanel.java393
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/FramePanelLayout.java52
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/HistorySectionsHelper.java80
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/HomeAdapter.java224
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/HomeBanner.java315
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/HomeConfig.java1694
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/HomeConfigLoader.java83
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/HomeConfigPrefsBackend.java663
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/HomeContextMenuInfo.java82
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/HomeExpandableListView.java68
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/HomeFragment.java498
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/HomeListView.java138
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/HomePager.java564
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/HomePanelsManager.java368
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/HomeScreen.java57
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/ImageLoader.java164
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/MultiTypeCursorAdapter.java100
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/PanelAuthCache.java82
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/PanelAuthLayout.java63
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/PanelBackItemView.java48
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/PanelHeaderView.java28
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/PanelInfoManager.java162
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/PanelItemView.java136
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/PanelLayout.java747
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/PanelListView.java83
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/PanelRecyclerView.java178
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/PanelRecyclerViewAdapter.java137
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/PanelRefreshLayout.java90
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/PanelViewAdapter.java113
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/PanelViewItemHandler.java59
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/PinSiteDialog.java256
-rwxr-xr-xmobile/android/base/java/org/mozilla/gecko/home/RecentTabsAdapter.java454
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/RemoteTabsExpandableListState.java163
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/SearchEngine.java102
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/SearchEngineAdapter.java122
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/SearchEngineBar.java148
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/SearchEngineRow.java494
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/SearchLoader.java114
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/SimpleCursorLoader.java147
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/SpacingDecoration.java20
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/TabMenuStrip.java127
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/TabMenuStripLayout.java246
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/TopSitesGridItemView.java312
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/TopSitesGridView.java169
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/TopSitesPanel.java968
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/TopSitesThumbnailView.java102
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/TwoLinePageRow.java324
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStream.java145
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStreamHomeFragment.java39
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStreamHomeScreen.java73
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamItem.java196
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamRecyclerAdapter.java135
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/ActivityStreamContextMenu.java239
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/BottomSheetContextMenu.java102
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/PopupContextMenu.java76
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/CirclePageIndicator.java568
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesCard.java105
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPage.java38
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPageAdapter.java117
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPagerAdapter.java124
71 files changed, 17885 insertions, 0 deletions
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/BookmarkFolderView.java b/mobile/android/base/java/org/mozilla/gecko/home/BookmarkFolderView.java
new file mode 100644
index 000000000..566422faf
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/BookmarkFolderView.java
@@ -0,0 +1,147 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.util.UIAsyncTask;
+
+import android.content.Context;
+import android.support.annotation.NonNull;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import java.lang.ref.WeakReference;
+import java.util.Collections;
+import java.util.Set;
+import java.util.TreeSet;
+
+public class BookmarkFolderView extends LinearLayout {
+ private static final Set<Integer> FOLDERS_WITH_COUNT;
+
+ static {
+ final Set<Integer> folders = new TreeSet<>();
+ folders.add(BrowserContract.Bookmarks.FAKE_READINGLIST_SMARTFOLDER_ID);
+
+ FOLDERS_WITH_COUNT = Collections.unmodifiableSet(folders);
+ }
+
+ public enum FolderState {
+ /**
+ * A standard folder, i.e. a folder in a list of bookmarks and folders.
+ */
+ FOLDER(R.drawable.folder_closed),
+
+ /**
+ * The parent folder: this indicates that you are able to return to the previous
+ * folder ("Back to {name}").
+ */
+ PARENT(R.drawable.bookmark_folder_arrow_up),
+
+ /**
+ * The reading list smartfolder: this displays a reading list icon instead of the
+ * normal folder icon.
+ */
+ READING_LIST(R.drawable.reading_list_folder);
+
+ public final int image;
+
+ FolderState(final int image) { this.image = image; }
+ }
+
+ private final TextView mTitle;
+ private final TextView mSubtitle;
+
+ private final ImageView mIcon;
+
+ public BookmarkFolderView(Context context) {
+ this(context, null);
+ }
+
+ public BookmarkFolderView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ LayoutInflater.from(context).inflate(R.layout.two_line_folder_row, this);
+
+ mTitle = (TextView) findViewById(R.id.title);
+ mSubtitle = (TextView) findViewById(R.id.subtitle);
+ mIcon = (ImageView) findViewById(R.id.icon);
+ }
+
+ public void update(String title, int folderID) {
+ setTitle(title);
+ setID(folderID);
+ }
+
+ private void setTitle(String title) {
+ mTitle.setText(title);
+ }
+
+ private static class ItemCountUpdateTask extends UIAsyncTask.WithoutParams<Integer> {
+ private final WeakReference<TextView> mTextViewReference;
+ private final int mFolderID;
+
+ public ItemCountUpdateTask(final WeakReference<TextView> textViewReference,
+ final int folderID) {
+ super(ThreadUtils.getBackgroundHandler());
+
+ mTextViewReference = textViewReference;
+ mFolderID = folderID;
+ }
+
+ @Override
+ protected Integer doInBackground() {
+ final TextView textView = mTextViewReference.get();
+
+ if (textView == null) {
+ return null;
+ }
+
+ final BrowserDB db = BrowserDB.from(textView.getContext());
+ return db.getBookmarkCountForFolder(textView.getContext().getContentResolver(), mFolderID);
+ }
+
+ @Override
+ protected void onPostExecute(Integer count) {
+ final TextView textView = mTextViewReference.get();
+
+ if (textView == null) {
+ return;
+ }
+
+ final String text;
+ if (count == 1) {
+ text = textView.getContext().getResources().getString(R.string.bookmark_folder_one_item);
+ } else {
+ text = textView.getContext().getResources().getString(R.string.bookmark_folder_items, count);
+ }
+
+ textView.setText(text);
+ textView.setVisibility(View.VISIBLE);
+ }
+ }
+
+ private void setID(final int folderID) {
+ if (FOLDERS_WITH_COUNT.contains(folderID)) {
+ final WeakReference<TextView> subTitleReference = new WeakReference<TextView>(mSubtitle);
+
+ new ItemCountUpdateTask(subTitleReference, folderID).execute();
+ } else {
+ mSubtitle.setVisibility(View.GONE);
+ }
+ }
+
+ public void setState(@NonNull FolderState state) {
+ mIcon.setImageResource(state.image);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/BookmarkScreenshotRow.java b/mobile/android/base/java/org/mozilla/gecko/home/BookmarkScreenshotRow.java
new file mode 100644
index 000000000..a1efff049
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/BookmarkScreenshotRow.java
@@ -0,0 +1,67 @@
+/* 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.home;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.util.AttributeSet;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.BrowserContract.UrlAnnotations;
+
+import java.text.DateFormat;
+import java.text.FieldPosition;
+import java.util.Date;
+
+/**
+ * An entry of the screenshot list in the bookmarks panel.
+ */
+class BookmarkScreenshotRow extends LinearLayout {
+ private TextView titleView;
+ private TextView dateView;
+
+ // This DateFormat uses the current locale at instantiation time, which won't get updated if the locale is changed.
+ // Since it's just a date, it's probably not worth the code complexity to fix that.
+ private static final DateFormat dateFormat = DateFormat.getDateInstance(DateFormat.LONG);
+ private static final DateFormat timeFormat = DateFormat.getTimeInstance(DateFormat.SHORT);
+
+ // This parameter to DateFormat.format has no impact on the result but rather gets mutated by the method to
+ // identify where a certain field starts and ends (by index). This is useful if you want to later modify the String;
+ // I'm not sure why this argument isn't optional.
+ private static final FieldPosition dummyFieldPosition = new FieldPosition(-1);
+
+ public BookmarkScreenshotRow(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ public void onFinishInflate() {
+ super.onFinishInflate();
+ titleView = (TextView) findViewById(R.id.title);
+ dateView = (TextView) findViewById(R.id.date);
+ }
+
+ public void updateFromCursor(final Cursor c) {
+ titleView.setText(getTitleFromCursor(c));
+ dateView.setText(getDateFromCursor(c));
+ }
+
+ private static String getTitleFromCursor(final Cursor c) {
+ final int index = c.getColumnIndexOrThrow(UrlAnnotations.URL);
+ return c.getString(index);
+ }
+
+ private static String getDateFromCursor(final Cursor c) {
+ final long timestamp = c.getLong(c.getColumnIndexOrThrow(UrlAnnotations.DATE_CREATED));
+ final Date date = new Date(timestamp);
+ final StringBuffer sb = new StringBuffer();
+ dateFormat.format(date, sb, dummyFieldPosition)
+ .append(" - ");
+ timeFormat.format(date, sb, dummyFieldPosition);
+ return sb.toString();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/BookmarksListAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/BookmarksListAdapter.java
new file mode 100644
index 000000000..b31116693
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/BookmarksListAdapter.java
@@ -0,0 +1,352 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import java.util.Collections;
+import java.util.LinkedList;
+import java.util.List;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.BrowserContract.Bookmarks;
+import org.mozilla.gecko.home.BookmarkFolderView.FolderState;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.view.View;
+
+/**
+ * Adapter to back the BookmarksListView with a list of bookmarks.
+ */
+class BookmarksListAdapter extends MultiTypeCursorAdapter {
+ private static final int VIEW_TYPE_BOOKMARK_ITEM = 0;
+ private static final int VIEW_TYPE_FOLDER = 1;
+ private static final int VIEW_TYPE_SCREENSHOT = 2;
+
+ private static final int[] VIEW_TYPES = new int[] { VIEW_TYPE_BOOKMARK_ITEM, VIEW_TYPE_FOLDER, VIEW_TYPE_SCREENSHOT };
+ private static final int[] LAYOUT_TYPES =
+ new int[] { R.layout.bookmark_item_row, R.layout.bookmark_folder_row, R.layout.bookmark_screenshot_row };
+
+ public enum RefreshType implements Parcelable {
+ PARENT,
+ CHILD;
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(ordinal());
+ }
+
+ public static final Creator<RefreshType> CREATOR = new Creator<RefreshType>() {
+ @Override
+ public RefreshType createFromParcel(final Parcel source) {
+ return RefreshType.values()[source.readInt()];
+ }
+
+ @Override
+ public RefreshType[] newArray(final int size) {
+ return new RefreshType[size];
+ }
+ };
+ }
+
+ public static class FolderInfo implements Parcelable {
+ public final int id;
+ public final String title;
+
+ public FolderInfo(int id) {
+ this(id, "");
+ }
+
+ public FolderInfo(Parcel in) {
+ this(in.readInt(), in.readString());
+ }
+
+ public FolderInfo(int id, String title) {
+ this.id = id;
+ this.title = title;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(id);
+ dest.writeString(title);
+ }
+
+ public static final Creator<FolderInfo> CREATOR = new Creator<FolderInfo>() {
+ @Override
+ public FolderInfo createFromParcel(Parcel in) {
+ return new FolderInfo(in);
+ }
+
+ @Override
+ public FolderInfo[] newArray(int size) {
+ return new FolderInfo[size];
+ }
+ };
+ }
+
+ // A listener that knows how to refresh the list for a given folder id.
+ // This is usually implemented by the enclosing fragment/activity.
+ public static interface OnRefreshFolderListener {
+ // The folder id to refresh the list with.
+ public void onRefreshFolder(FolderInfo folderInfo, RefreshType refreshType);
+ }
+
+ /**
+ * The type of data a bookmarks folder can display. This can be used to
+ * distinguish bookmark folders from "smart folders" that contain non-bookmark
+ * entries but still appear in the Bookmarks panel.
+ */
+ public enum FolderType {
+ BOOKMARKS,
+ SCREENSHOTS,
+ }
+
+ // mParentStack holds folder info instances (id + title) that allow
+ // us to navigate back up the folder hierarchy.
+ private LinkedList<FolderInfo> mParentStack;
+
+ // Refresh folder listener.
+ private OnRefreshFolderListener mListener;
+
+ private FolderType openFolderType = FolderType.BOOKMARKS;
+
+ public BookmarksListAdapter(Context context, Cursor cursor, List<FolderInfo> parentStack) {
+ // Initializing with a null cursor.
+ super(context, cursor, VIEW_TYPES, LAYOUT_TYPES);
+
+ if (parentStack == null) {
+ mParentStack = new LinkedList<FolderInfo>();
+ } else {
+ mParentStack = new LinkedList<FolderInfo>(parentStack);
+ }
+ }
+
+ public void restoreData(List<FolderInfo> parentStack) {
+ mParentStack = new LinkedList<FolderInfo>(parentStack);
+ notifyDataSetChanged();
+ }
+
+ public List<FolderInfo> getParentStack() {
+ return Collections.unmodifiableList(mParentStack);
+ }
+
+ public FolderType getOpenFolderType() {
+ return openFolderType;
+ }
+
+ /**
+ * Moves to parent folder, if one exists.
+ *
+ * @return Whether the adapter successfully moved to a parent folder.
+ */
+ public boolean moveToParentFolder() {
+ // If we're already at the root, we can't move to a parent folder.
+ // An empty parent stack here means we're still waiting for the
+ // initial list of bookmarks and can't go to a parent folder.
+ if (mParentStack.size() <= 1) {
+ return false;
+ }
+
+ if (mListener != null) {
+ // We pick the second folder in the stack as it represents
+ // the parent folder.
+ mListener.onRefreshFolder(mParentStack.get(1), RefreshType.PARENT);
+ }
+
+ return true;
+ }
+
+ /**
+ * Moves to child folder, given a folderId.
+ *
+ * @param folderId The id of the folder to show.
+ * @param folderTitle The title of the folder to show.
+ */
+ public void moveToChildFolder(int folderId, String folderTitle) {
+ FolderInfo folderInfo = new FolderInfo(folderId, folderTitle);
+
+ if (mListener != null) {
+ mListener.onRefreshFolder(folderInfo, RefreshType.CHILD);
+ }
+ }
+
+ /**
+ * Set a listener that can refresh this adapter.
+ *
+ * @param listener The listener that can refresh the adapter.
+ */
+ public void setOnRefreshFolderListener(OnRefreshFolderListener listener) {
+ mListener = listener;
+ }
+
+ private boolean isCurrentFolder(FolderInfo folderInfo) {
+ return (mParentStack.size() > 0 &&
+ mParentStack.peek().id == folderInfo.id);
+ }
+
+ public void swapCursor(Cursor c, FolderInfo folderInfo, RefreshType refreshType) {
+ updateOpenFolderType(folderInfo);
+ switch (refreshType) {
+ case PARENT:
+ if (!isCurrentFolder(folderInfo)) {
+ mParentStack.removeFirst();
+ }
+ break;
+
+ case CHILD:
+ if (!isCurrentFolder(folderInfo)) {
+ mParentStack.addFirst(folderInfo);
+ }
+ break;
+
+ default:
+ // Do nothing;
+ }
+
+ swapCursor(c);
+ }
+
+ private void updateOpenFolderType(final FolderInfo folderInfo) {
+ if (folderInfo.id == Bookmarks.FIXED_SCREENSHOT_FOLDER_ID) {
+ openFolderType = FolderType.SCREENSHOTS;
+ } else {
+ openFolderType = FolderType.BOOKMARKS;
+ }
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ // The position also reflects the opened child folder row.
+ if (isShowingChildFolder()) {
+ if (position == 0) {
+ return VIEW_TYPE_FOLDER;
+ }
+
+ // Accounting for the folder view.
+ position--;
+ }
+
+ if (openFolderType == FolderType.SCREENSHOTS) {
+ return VIEW_TYPE_SCREENSHOT;
+ }
+
+ final Cursor c = getCursor(position);
+ if (c.getInt(c.getColumnIndexOrThrow(Bookmarks.TYPE)) == Bookmarks.TYPE_FOLDER) {
+ return VIEW_TYPE_FOLDER;
+ }
+
+ // Default to returning normal item type.
+ return VIEW_TYPE_BOOKMARK_ITEM;
+ }
+
+ /**
+ * Get the title of the folder given a cursor moved to the position.
+ *
+ * @param context The context of the view.
+ * @param cursor A cursor moved to the required position.
+ * @return The title of the folder at the position.
+ */
+ public String getFolderTitle(Context context, Cursor c) {
+ String guid = c.getString(c.getColumnIndexOrThrow(Bookmarks.GUID));
+
+ // If we don't have a special GUID, just return the folder title from the DB.
+ if (guid == null || guid.length() == 12) {
+ return c.getString(c.getColumnIndexOrThrow(Bookmarks.TITLE));
+ }
+
+ Resources res = context.getResources();
+
+ // Use localized strings for special folder names.
+ if (guid.equals(Bookmarks.FAKE_DESKTOP_FOLDER_GUID)) {
+ return res.getString(R.string.bookmarks_folder_desktop);
+ } else if (guid.equals(Bookmarks.MENU_FOLDER_GUID)) {
+ return res.getString(R.string.bookmarks_folder_menu);
+ } else if (guid.equals(Bookmarks.TOOLBAR_FOLDER_GUID)) {
+ return res.getString(R.string.bookmarks_folder_toolbar);
+ } else if (guid.equals(Bookmarks.UNFILED_FOLDER_GUID)) {
+ return res.getString(R.string.bookmarks_folder_unfiled);
+ } else if (guid.equals(Bookmarks.SCREENSHOT_FOLDER_GUID)) {
+ return res.getString(R.string.screenshot_folder_label_in_bookmarks);
+ } else if (guid.equals(Bookmarks.FAKE_READINGLIST_SMARTFOLDER_GUID)) {
+ return res.getString(R.string.readinglist_smartfolder_label_in_bookmarks);
+ }
+
+ // If for some reason we have a folder with a special GUID, but it's not one of
+ // the special folders we expect in the UI, just return the title from the DB.
+ return c.getString(c.getColumnIndexOrThrow(Bookmarks.TITLE));
+ }
+
+ /**
+ * @return true, if currently showing a child folder, false otherwise.
+ */
+ public boolean isShowingChildFolder() {
+ if (mParentStack.size() == 0) {
+ return false;
+ }
+
+ return (mParentStack.peek().id != Bookmarks.FIXED_ROOT_ID);
+ }
+
+ @Override
+ public int getCount() {
+ return super.getCount() + (isShowingChildFolder() ? 1 : 0);
+ }
+
+ @Override
+ public void bindView(View view, Context context, int position) {
+ final int viewType = getItemViewType(position);
+
+ final Cursor cursor;
+ if (isShowingChildFolder()) {
+ if (position == 0) {
+ cursor = null;
+ } else {
+ // Accounting for the folder view.
+ position--;
+ cursor = getCursor(position);
+ }
+ } else {
+ cursor = getCursor(position);
+ }
+
+ if (viewType == VIEW_TYPE_SCREENSHOT) {
+ ((BookmarkScreenshotRow) view).updateFromCursor(cursor);
+ } else if (viewType == VIEW_TYPE_BOOKMARK_ITEM) {
+ final TwoLinePageRow row = (TwoLinePageRow) view;
+ row.updateFromCursor(cursor);
+ } else {
+ final BookmarkFolderView row = (BookmarkFolderView) view;
+ if (cursor == null) {
+ final Resources res = context.getResources();
+ row.update(res.getString(R.string.home_move_back_to_filter, mParentStack.get(1).title), -1);
+ row.setState(FolderState.PARENT);
+ } else {
+ int id = cursor.getInt(cursor.getColumnIndexOrThrow(Bookmarks._ID));
+
+ row.update(getFolderTitle(context, cursor), id);
+
+ if (id == Bookmarks.FAKE_READINGLIST_SMARTFOLDER_ID) {
+ row.setState(FolderState.READING_LIST);
+ } else {
+ row.setState(FolderState.FOLDER);
+ }
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/BookmarksListView.java b/mobile/android/base/java/org/mozilla/gecko/home/BookmarksListView.java
new file mode 100644
index 000000000..94157be10
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/BookmarksListView.java
@@ -0,0 +1,218 @@
+ /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import java.util.EnumSet;
+import java.util.List;
+
+import android.util.Log;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserContract.Bookmarks;
+import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.util.AttributeSet;
+import android.view.KeyEvent;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.HeaderViewListAdapter;
+import android.widget.ListAdapter;
+
+import org.mozilla.gecko.reader.SavedReaderViewHelper;
+import org.mozilla.gecko.util.NetworkUtils;
+
+/**
+ * A ListView of bookmarks.
+ */
+public class BookmarksListView extends HomeListView
+ implements AdapterView.OnItemClickListener {
+ public static final String LOGTAG = "GeckoBookmarksListView";
+
+ public BookmarksListView(Context context) {
+ this(context, null);
+ }
+
+ public BookmarksListView(Context context, AttributeSet attrs) {
+ this(context, attrs, R.attr.bookmarksListViewStyle);
+ }
+
+ public BookmarksListView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+
+ setOnItemClickListener(this);
+
+ setOnKeyListener(new View.OnKeyListener() {
+ @Override
+ public boolean onKey(View v, int keyCode, KeyEvent event) {
+ final int action = event.getAction();
+
+ // If the user hit the BACK key, try to move to the parent folder.
+ if (action == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) {
+ return getBookmarksListAdapter().moveToParentFolder();
+ }
+ return false;
+ }
+ });
+ }
+
+ /**
+ * Get the appropriate telemetry extra for a given folder.
+ *
+ * baseFolderID is the ID of the first-level folder in the parent stack, i.e. the first folder
+ * that was selected from the root hierarchy (e.g. Desktop, Reading List, or any mobile first-level
+ * subfolder). If the current folder is a first-level folder, then the fixed root ID may be used
+ * instead.
+ *
+ * We use baseFolderID only to distinguish whether or not we're currently in a desktop subfolder.
+ * If it isn't equal to FAKE_DESKTOP_FOLDER_ID we know we're in a mobile subfolder, or one
+ * of the smartfolders.
+ */
+ private String getTelemetryExtraForFolder(int folderID, int baseFolderID) {
+ if (folderID == Bookmarks.FAKE_DESKTOP_FOLDER_ID) {
+ return "folder_desktop";
+ } else if (folderID == Bookmarks.FIXED_SCREENSHOT_FOLDER_ID) {
+ return "folder_screenshots";
+ } else if (folderID == Bookmarks.FAKE_READINGLIST_SMARTFOLDER_ID) {
+ return "folder_reading_list";
+ } else {
+ // The stack depth is 2 for either the fake desktop folder, or any subfolder of mobile
+ // bookmarks, we subtract these offsets so that any direct subfolder of mobile
+ // has a level equal to 1. (Desktop folders will be one level deeper due to the
+ // fake desktop folder, hence subtract 2.)
+ if (baseFolderID == Bookmarks.FAKE_DESKTOP_FOLDER_ID) {
+ return "folder_desktop_subfolder";
+ } else {
+ return "folder_mobile_subfolder";
+ }
+ }
+ }
+
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ final BookmarksListAdapter adapter = getBookmarksListAdapter();
+ if (adapter.isShowingChildFolder()) {
+ if (position == 0) {
+ // If we tap on an opened folder, move back to parent folder.
+
+ final List<BookmarksListAdapter.FolderInfo> parentStack = ((BookmarksListAdapter) getAdapter()).getParentStack();
+ if (parentStack.size() < 2) {
+ throw new IllegalStateException("Cannot move to parent folder if we are already in the root folder");
+ }
+
+ // The first item (top of stack) is the current folder, we're returning to the next one
+ BookmarksListAdapter.FolderInfo folder = parentStack.get(1);
+ final int parentID = folder.id;
+ final int baseFolderID;
+ if (parentStack.size() > 2) {
+ baseFolderID = parentStack.get(parentStack.size() - 2).id;
+ } else {
+ baseFolderID = Bookmarks.FIXED_ROOT_ID;
+ }
+
+ final String extra = getTelemetryExtraForFolder(parentID, baseFolderID);
+
+ // Move to parent _after_ retrieving stack information
+ adapter.moveToParentFolder();
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.LIST_ITEM, extra);
+ return;
+ }
+
+ // Accounting for the folder view.
+ position--;
+ }
+
+ final Cursor cursor = adapter.getCursor();
+ if (cursor == null) {
+ return;
+ }
+
+ cursor.moveToPosition(position);
+
+ if (adapter.getOpenFolderType() == BookmarksListAdapter.FolderType.SCREENSHOTS) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.LIST_ITEM, "bookmarks-screenshot");
+
+ final String fileUrl = "file://" + cursor.getString(cursor.getColumnIndex(BrowserContract.UrlAnnotations.VALUE));
+ getOnUrlOpenListener().onUrlOpen(fileUrl, EnumSet.of(OnUrlOpenListener.Flags.ALLOW_SWITCH_TO_TAB));
+ return;
+ }
+
+ int type = cursor.getInt(cursor.getColumnIndexOrThrow(Bookmarks.TYPE));
+ if (type == Bookmarks.TYPE_FOLDER) {
+ // If we're clicking on a folder, update adapter to move to that folder
+ final int folderId = cursor.getInt(cursor.getColumnIndexOrThrow(Bookmarks._ID));
+ final String folderTitle = adapter.getFolderTitle(parent.getContext(), cursor);
+ adapter.moveToChildFolder(folderId, folderTitle);
+
+ final List<BookmarksListAdapter.FolderInfo> parentStack = ((BookmarksListAdapter) getAdapter()).getParentStack();
+
+ final int baseFolderID;
+ if (parentStack.size() > 2) {
+ baseFolderID = parentStack.get(parentStack.size() - 2).id;
+ } else {
+ baseFolderID = Bookmarks.FIXED_ROOT_ID;
+ }
+
+ final String extra = getTelemetryExtraForFolder(folderId, baseFolderID);
+ Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.LIST_ITEM, extra);
+ } else {
+ // Otherwise, just open the URL
+ final String url = cursor.getString(cursor.getColumnIndexOrThrow(Bookmarks.URL));
+
+ final SavedReaderViewHelper rvh = SavedReaderViewHelper.getSavedReaderViewHelper(getContext());
+
+ final String extra;
+ if (rvh.isURLCached(url)) {
+ extra = "bookmarks-reader";
+ } else {
+ extra = "bookmarks";
+ }
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.LIST_ITEM, extra);
+ Telemetry.addToHistogram("FENNEC_LOAD_SAVED_PAGE", NetworkUtils.isConnected(getContext()) ? 2 : 3);
+
+ // This item is a TwoLinePageRow, so we allow switch-to-tab.
+ getOnUrlOpenListener().onUrlOpen(url, EnumSet.of(OnUrlOpenListener.Flags.ALLOW_SWITCH_TO_TAB));
+ }
+ }
+
+ @Override
+ public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
+ // Adjust the item position to account for the parent folder row that is inserted
+ // at the top of the list when viewing the contents of a folder.
+ final BookmarksListAdapter adapter = getBookmarksListAdapter();
+ if (adapter.isShowingChildFolder()) {
+ position--;
+ }
+
+ // Temporarily prevent crashes until we figure out what we actually want to do here (bug 1252316).
+ if (adapter.getOpenFolderType() == BookmarksListAdapter.FolderType.SCREENSHOTS) {
+ return false;
+ }
+
+ return super.onItemLongClick(parent, view, position, id);
+ }
+
+ private BookmarksListAdapter getBookmarksListAdapter() {
+ BookmarksListAdapter adapter;
+ ListAdapter listAdapter = getAdapter();
+ if (listAdapter instanceof HeaderViewListAdapter) {
+ adapter = (BookmarksListAdapter) ((HeaderViewListAdapter) listAdapter).getWrappedAdapter();
+ } else {
+ adapter = (BookmarksListAdapter) listAdapter;
+ }
+ return adapter;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/BookmarksPanel.java b/mobile/android/base/java/org/mozilla/gecko/home/BookmarksPanel.java
new file mode 100644
index 000000000..4b4781996
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/BookmarksPanel.java
@@ -0,0 +1,316 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import java.util.ArrayList;
+import java.util.LinkedList;
+import java.util.List;
+
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserContract.Bookmarks;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.distribution.PartnerBookmarksProviderProxy;
+import org.mozilla.gecko.home.BookmarksListAdapter.FolderInfo;
+import org.mozilla.gecko.home.BookmarksListAdapter.OnRefreshFolderListener;
+import org.mozilla.gecko.home.BookmarksListAdapter.RefreshType;
+import org.mozilla.gecko.home.HomeContextMenuInfo.RemoveItemType;
+import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
+import org.mozilla.gecko.preferences.GeckoPreferences;
+
+import android.app.Activity;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.res.Configuration;
+import android.database.Cursor;
+import android.database.MergeCursor;
+import android.os.Bundle;
+import android.os.Parcelable;
+import android.support.annotation.NonNull;
+import android.support.v4.app.LoaderManager;
+import android.support.v4.content.Loader;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewStub;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+/**
+ * A page in about:home that displays a ListView of bookmarks.
+ */
+public class BookmarksPanel extends HomeFragment {
+ public static final String LOGTAG = "GeckoBookmarksPanel";
+
+ // Cursor loader ID for list of bookmarks.
+ private static final int LOADER_ID_BOOKMARKS_LIST = 0;
+
+ // Information about the target bookmarks folder.
+ private static final String BOOKMARKS_FOLDER_INFO = "folder_info";
+
+ // Refresh type for folder refreshing loader.
+ private static final String BOOKMARKS_REFRESH_TYPE = "refresh_type";
+
+ // List of bookmarks.
+ private BookmarksListView mList;
+
+ // Adapter for list of bookmarks.
+ private BookmarksListAdapter mListAdapter;
+
+ // Adapter's parent stack.
+ private List<FolderInfo> mSavedParentStack;
+
+ // Reference to the View to display when there are no results.
+ private View mEmptyView;
+
+ // Callback for cursor loaders.
+ private CursorLoaderCallbacks mLoaderCallbacks;
+
+ @Override
+ public void restoreData(@NonNull Bundle data) {
+ final ArrayList<FolderInfo> stack = data.getParcelableArrayList("parentStack");
+ if (stack == null) {
+ return;
+ }
+
+ if (mListAdapter == null) {
+ mSavedParentStack = new LinkedList<FolderInfo>(stack);
+ } else {
+ mListAdapter.restoreData(stack);
+ }
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ final View view = inflater.inflate(R.layout.home_bookmarks_panel, container, false);
+
+ mList = (BookmarksListView) view.findViewById(R.id.bookmarks_list);
+
+ mList.setContextMenuInfoFactory(new HomeContextMenuInfo.Factory() {
+ @Override
+ public HomeContextMenuInfo makeInfoForCursor(View view, int position, long id, Cursor cursor) {
+ final int type = cursor.getInt(cursor.getColumnIndexOrThrow(Bookmarks.TYPE));
+ if (type == Bookmarks.TYPE_FOLDER) {
+ // We don't show a context menu for folders
+ return null;
+ }
+ final HomeContextMenuInfo info = new HomeContextMenuInfo(view, position, id);
+ info.url = cursor.getString(cursor.getColumnIndexOrThrow(Bookmarks.URL));
+ info.title = cursor.getString(cursor.getColumnIndexOrThrow(Bookmarks.TITLE));
+ info.bookmarkId = cursor.getInt(cursor.getColumnIndexOrThrow(Bookmarks._ID));
+ info.itemType = RemoveItemType.BOOKMARKS;
+ return info;
+ }
+ });
+
+ return view;
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ OnUrlOpenListener listener = null;
+ try {
+ listener = (OnUrlOpenListener) getActivity();
+ } catch (ClassCastException e) {
+ throw new ClassCastException(getActivity().toString()
+ + " must implement HomePager.OnUrlOpenListener");
+ }
+
+ mList.setTag(HomePager.LIST_TAG_BOOKMARKS);
+ mList.setOnUrlOpenListener(listener);
+
+ registerForContextMenu(mList);
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+
+ final Activity activity = getActivity();
+
+ // Setup the list adapter.
+ mListAdapter = new BookmarksListAdapter(activity, null, mSavedParentStack);
+ mListAdapter.setOnRefreshFolderListener(new OnRefreshFolderListener() {
+ @Override
+ public void onRefreshFolder(FolderInfo folderInfo, RefreshType refreshType) {
+ // Restart the loader with folder as the argument.
+ Bundle bundle = new Bundle();
+ bundle.putParcelable(BOOKMARKS_FOLDER_INFO, folderInfo);
+ bundle.putParcelable(BOOKMARKS_REFRESH_TYPE, refreshType);
+ getLoaderManager().restartLoader(LOADER_ID_BOOKMARKS_LIST, bundle, mLoaderCallbacks);
+ }
+ });
+ mList.setAdapter(mListAdapter);
+
+ // Create callbacks before the initial loader is started.
+ mLoaderCallbacks = new CursorLoaderCallbacks();
+ loadIfVisible();
+ }
+
+ @Override
+ public void onDestroyView() {
+ mList = null;
+ mListAdapter = null;
+ mEmptyView = null;
+ super.onDestroyView();
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+
+ if (isVisible()) {
+ // The parent stack is saved just so that the folder state can be
+ // restored on rotation.
+ mSavedParentStack = mListAdapter.getParentStack();
+ }
+ }
+
+ @Override
+ protected void load() {
+ final Bundle bundle;
+ if (mSavedParentStack != null && mSavedParentStack.size() > 1) {
+ bundle = new Bundle();
+ bundle.putParcelable(BOOKMARKS_FOLDER_INFO, mSavedParentStack.get(0));
+ bundle.putParcelable(BOOKMARKS_REFRESH_TYPE, RefreshType.CHILD);
+ } else {
+ bundle = null;
+ }
+
+ getLoaderManager().initLoader(LOADER_ID_BOOKMARKS_LIST, bundle, mLoaderCallbacks);
+ }
+
+ private void updateUiFromCursor(Cursor c) {
+ if ((c == null || c.getCount() == 0) && mEmptyView == null) {
+ // Set empty page view. We delay this so that the empty view won't flash.
+ final ViewStub emptyViewStub = (ViewStub) getView().findViewById(R.id.home_empty_view_stub);
+ mEmptyView = emptyViewStub.inflate();
+
+ final ImageView emptyIcon = (ImageView) mEmptyView.findViewById(R.id.home_empty_image);
+ emptyIcon.setImageResource(R.drawable.icon_bookmarks_empty);
+
+ final TextView emptyText = (TextView) mEmptyView.findViewById(R.id.home_empty_text);
+ emptyText.setText(R.string.home_bookmarks_empty);
+
+ mList.setEmptyView(mEmptyView);
+ }
+ }
+
+ /**
+ * Loader for the list for bookmarks.
+ */
+ private static class BookmarksLoader extends SimpleCursorLoader {
+ private final FolderInfo mFolderInfo;
+ private final RefreshType mRefreshType;
+ private final BrowserDB mDB;
+
+ public BookmarksLoader(Context context) {
+ this(context,
+ new FolderInfo(Bookmarks.FIXED_ROOT_ID, context.getResources().getString(R.string.bookmarks_title)),
+ RefreshType.CHILD);
+ }
+
+ public BookmarksLoader(Context context, FolderInfo folderInfo, RefreshType refreshType) {
+ super(context);
+ mFolderInfo = folderInfo;
+ mRefreshType = refreshType;
+ mDB = BrowserDB.from(context);
+ }
+
+ @Override
+ public Cursor loadCursor() {
+ final boolean isRootFolder = mFolderInfo.id == BrowserContract.Bookmarks.FIXED_ROOT_ID;
+
+ final ContentResolver contentResolver = getContext().getContentResolver();
+
+ Cursor partnerCursor = null;
+ Cursor userCursor = null;
+
+ if (GeckoSharedPrefs.forProfile(getContext()).getBoolean(GeckoPreferences.PREFS_READ_PARTNER_BOOKMARKS_PROVIDER, false)
+ && (isRootFolder || mFolderInfo.id <= Bookmarks.FAKE_PARTNER_BOOKMARKS_START)) {
+ partnerCursor = contentResolver.query(PartnerBookmarksProviderProxy.getUriForBookmarks(getContext(), mFolderInfo.id), null, null, null, null, null);
+ }
+
+ if (isRootFolder || mFolderInfo.id > Bookmarks.FAKE_PARTNER_BOOKMARKS_START) {
+ userCursor = mDB.getBookmarksInFolder(contentResolver, mFolderInfo.id);
+ }
+
+
+ if (partnerCursor == null && userCursor == null) {
+ return null;
+ } else if (partnerCursor == null) {
+ return userCursor;
+ } else if (userCursor == null) {
+ return partnerCursor;
+ } else {
+ return new MergeCursor(new Cursor[] { partnerCursor, userCursor });
+ }
+ }
+
+ @Override
+ public void onContentChanged() {
+ // Invalidate the cached value that keeps track of whether or
+ // not desktop bookmarks exist.
+ mDB.invalidate();
+ super.onContentChanged();
+ }
+
+ public FolderInfo getFolderInfo() {
+ return mFolderInfo;
+ }
+
+ public RefreshType getRefreshType() {
+ return mRefreshType;
+ }
+ }
+
+ /**
+ * Loader callbacks for the LoaderManager of this fragment.
+ */
+ private class CursorLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> {
+ @Override
+ public Loader<Cursor> onCreateLoader(int id, Bundle args) {
+ if (args == null) {
+ return new BookmarksLoader(getActivity());
+ } else {
+ FolderInfo folderInfo = (FolderInfo) args.getParcelable(BOOKMARKS_FOLDER_INFO);
+ RefreshType refreshType = (RefreshType) args.getParcelable(BOOKMARKS_REFRESH_TYPE);
+ return new BookmarksLoader(getActivity(), folderInfo, refreshType);
+ }
+ }
+
+ @Override
+ public void onLoadFinished(Loader<Cursor> loader, Cursor c) {
+ BookmarksLoader bl = (BookmarksLoader) loader;
+ mListAdapter.swapCursor(c, bl.getFolderInfo(), bl.getRefreshType());
+
+ if (mPanelStateChangeListener != null) {
+ final List<FolderInfo> parentStack = mListAdapter.getParentStack();
+ final Bundle bundle = new Bundle();
+
+ // Bundle likes to store ArrayLists or Arrays, but we've got a generic List (which
+ // is actually an unmodifiable wrapper around a LinkedList). We'll need to do a
+ // LinkedList conversion at the other end, when saving we need to use this awkward
+ // syntax to create an Array.
+ bundle.putParcelableArrayList("parentStack", new ArrayList<FolderInfo>(parentStack));
+
+ mPanelStateChangeListener.onStateChanged(bundle);
+ }
+ updateUiFromCursor(c);
+ }
+
+ @Override
+ public void onLoaderReset(Loader<Cursor> loader) {
+ if (mList != null) {
+ mListAdapter.swapCursor(null);
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/BrowserSearch.java b/mobile/android/base/java/org/mozilla/gecko/home/BrowserSearch.java
new file mode 100644
index 000000000..7732932fe
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/BrowserSearch.java
@@ -0,0 +1,1316 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+
+import android.content.SharedPreferences;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoApp;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.PrefsHelper;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.SuggestClient;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.Tabs;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserContract.History;
+import org.mozilla.gecko.db.BrowserContract.URLColumns;
+import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
+import org.mozilla.gecko.home.SearchLoader.SearchCursorLoader;
+import org.mozilla.gecko.preferences.GeckoPreferences;
+import org.mozilla.gecko.toolbar.AutocompleteHandler;
+import org.mozilla.gecko.util.GeckoEventListener;
+import org.mozilla.gecko.util.StringUtils;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.app.Activity;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.view.ContextMenu.ContextMenuInfo;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.support.v4.app.LoaderManager.LoaderCallbacks;
+import android.support.v4.content.AsyncTaskLoader;
+import android.support.v4.content.Loader;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.ContextMenu;
+import android.view.LayoutInflater;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.view.ViewStub;
+import android.view.WindowManager.LayoutParams;
+import android.view.animation.AccelerateInterpolator;
+import android.view.animation.Animation;
+import android.view.animation.TranslateAnimation;
+import android.widget.AdapterView;
+import android.widget.LinearLayout;
+import android.widget.ListView;
+import android.widget.TextView;
+
+/**
+ * Fragment that displays frecency search results in a ListView.
+ */
+public class BrowserSearch extends HomeFragment
+ implements GeckoEventListener,
+ SearchEngineBar.OnSearchBarClickListener {
+
+ @RobocopTarget
+ public interface SuggestClientFactory {
+ public SuggestClient getSuggestClient(Context context, String template, int timeout, int max);
+ }
+
+ @RobocopTarget
+ public static class DefaultSuggestClientFactory implements SuggestClientFactory {
+ @Override
+ public SuggestClient getSuggestClient(Context context, String template, int timeout, int max) {
+ return new SuggestClient(context, template, timeout, max, true);
+ }
+ }
+
+ /**
+ * Set this to mock the suggestion mechanism. Public for access from tests.
+ */
+ @RobocopTarget
+ public static volatile SuggestClientFactory sSuggestClientFactory = new DefaultSuggestClientFactory();
+
+ // Logging tag name
+ private static final String LOGTAG = "GeckoBrowserSearch";
+
+ // Cursor loader ID for search query
+ private static final int LOADER_ID_SEARCH = 0;
+
+ // AsyncTask loader ID for suggestion query
+ private static final int LOADER_ID_SUGGESTION = 1;
+ private static final int LOADER_ID_SAVED_SUGGESTION = 2;
+
+ // Timeout for the suggestion client to respond
+ private static final int SUGGESTION_TIMEOUT = 3000;
+
+ // Maximum number of suggestions from the search engine's suggestion client. This impacts network traffic and device
+ // data consumption whereas R.integer.max_saved_suggestions controls how many suggestion to show in the UI.
+ private static final int NETWORK_SUGGESTION_MAX = 3;
+
+ // The maximum number of rows deep in a search we'll dig
+ // for an autocomplete result
+ private static final int MAX_AUTOCOMPLETE_SEARCH = 20;
+
+ // Length of https:// + 1 required to make autocomplete
+ // fill in the domain, for both http:// and https://
+ private static final int HTTPS_PREFIX_LENGTH = 9;
+
+ // Duration for fade-in animation
+ private static final int ANIMATION_DURATION = 250;
+
+ // Holds the current search term to use in the query
+ private volatile String mSearchTerm;
+
+ // Adapter for the list of search results
+ private SearchAdapter mAdapter;
+
+ // The view shown by the fragment
+ private LinearLayout mView;
+
+ // The list showing search results
+ private HomeListView mList;
+
+ // The bar on the bottom of the screen displaying search engine options.
+ private SearchEngineBar mSearchEngineBar;
+
+ // Client that performs search suggestion queries.
+ // Public for testing.
+ @RobocopTarget
+ public volatile SuggestClient mSuggestClient;
+
+ // List of search engines from Gecko.
+ // Do not mutate this list.
+ // Access to this member must only occur from the UI thread.
+ private List<SearchEngine> mSearchEngines;
+
+ // Search history suggestions
+ private ArrayList<String> mSearchHistorySuggestions;
+
+ // Track the locale that was last in use when we filled mSearchEngines.
+ // Access to this member must only occur from the UI thread.
+ private Locale mLastLocale;
+
+ // Whether search suggestions are enabled or not
+ private boolean mSuggestionsEnabled;
+
+ // Whether history suggestions are enabled or not
+ private boolean mSavedSearchesEnabled;
+
+ // Callbacks used for the search loader
+ private CursorLoaderCallbacks mCursorLoaderCallbacks;
+
+ // Callbacks used for the search suggestion loader
+ private SearchEngineSuggestionLoaderCallbacks mSearchEngineSuggestionLoaderCallbacks;
+ private SearchHistorySuggestionLoaderCallbacks mSearchHistorySuggestionLoaderCallback;
+
+ // Autocomplete handler used when filtering results
+ private AutocompleteHandler mAutocompleteHandler;
+
+ // On search listener
+ private OnSearchListener mSearchListener;
+
+ // On edit suggestion listener
+ private OnEditSuggestionListener mEditSuggestionListener;
+
+ // Whether the suggestions will fade in when shown
+ private boolean mAnimateSuggestions;
+
+ // Opt-in prompt view for search suggestions
+ private View mSuggestionsOptInPrompt;
+
+ public interface OnSearchListener {
+ void onSearch(SearchEngine engine, String text, TelemetryContract.Method method);
+ }
+
+ public interface OnEditSuggestionListener {
+ public void onEditSuggestion(String suggestion);
+ }
+
+ public static BrowserSearch newInstance() {
+ BrowserSearch browserSearch = new BrowserSearch();
+
+ final Bundle args = new Bundle();
+ args.putBoolean(HomePager.CAN_LOAD_ARG, true);
+ browserSearch.setArguments(args);
+
+ return browserSearch;
+ }
+
+ public BrowserSearch() {
+ mSearchTerm = "";
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+
+ try {
+ mSearchListener = (OnSearchListener) activity;
+ } catch (ClassCastException e) {
+ throw new ClassCastException(activity.toString()
+ + " must implement BrowserSearch.OnSearchListener");
+ }
+
+ try {
+ mEditSuggestionListener = (OnEditSuggestionListener) activity;
+ } catch (ClassCastException e) {
+ throw new ClassCastException(activity.toString()
+ + " must implement BrowserSearch.OnEditSuggestionListener");
+ }
+ }
+
+ @Override
+ public void onDetach() {
+ super.onDetach();
+
+ mAutocompleteHandler = null;
+ mSearchListener = null;
+ mEditSuggestionListener = null;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ mSearchEngines = new ArrayList<SearchEngine>();
+ mSearchHistorySuggestions = new ArrayList<>();
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+
+ mSearchEngines = null;
+ }
+
+ @Override
+ public void onHiddenChanged(boolean hidden) {
+ if (!hidden) {
+ final Tab tab = Tabs.getInstance().getSelectedTab();
+ final boolean isPrivate = (tab != null && tab.isPrivate());
+
+ // Removes Search Suggestions Loader if in private browsing mode
+ // Loader may have been inserted when browsing in normal tab
+ if (isPrivate) {
+ getLoaderManager().destroyLoader(LOADER_ID_SUGGESTION);
+ }
+
+ GeckoAppShell.notifyObservers("SearchEngines:GetVisible", null);
+ }
+ super.onHiddenChanged(hidden);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ final SharedPreferences prefs = GeckoSharedPrefs.forApp(getContext());
+ mSavedSearchesEnabled = prefs.getBoolean(GeckoPreferences.PREFS_HISTORY_SAVED_SEARCH, true);
+
+ // Fetch engines if we need to.
+ if (mSearchEngines.isEmpty() || !Locale.getDefault().equals(mLastLocale)) {
+ GeckoAppShell.notifyObservers("SearchEngines:GetVisible", null);
+ } else {
+ updateSearchEngineBar();
+ }
+
+ Telemetry.startUISession(TelemetryContract.Session.FRECENCY);
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+
+ Telemetry.stopUISession(TelemetryContract.Session.FRECENCY);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ // All list views are styled to look the same with a global activity theme.
+ // If the style of the list changes, inflate it from an XML.
+ mView = (LinearLayout) inflater.inflate(R.layout.browser_search, container, false);
+ mList = (HomeListView) mView.findViewById(R.id.home_list_view);
+ mSearchEngineBar = (SearchEngineBar) mView.findViewById(R.id.search_engine_bar);
+
+ return mView;
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+
+ GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this,
+ "SearchEngines:Data");
+
+ mSearchEngineBar.setAdapter(null);
+ mSearchEngineBar = null;
+
+ mList.setAdapter(null);
+ mList = null;
+
+ mView = null;
+ mSuggestionsOptInPrompt = null;
+ mSuggestClient = null;
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ mList.setTag(HomePager.LIST_TAG_BROWSER_SEARCH);
+
+ mList.setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ // Perform the user-entered search if the user clicks on a search engine row.
+ // This row will be disabled if suggestions (in addition to the user-entered term) are showing.
+ if (view instanceof SearchEngineRow) {
+ ((SearchEngineRow) view).performUserEnteredSearch();
+ return;
+ }
+
+ // Account for the search engine rows.
+ position -= getPrimaryEngineCount();
+ final Cursor c = mAdapter.getCursor(position);
+ final String url = c.getString(c.getColumnIndexOrThrow(URLColumns.URL));
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.LIST_ITEM, "frecency");
+
+ // This item is a TwoLinePageRow, so we allow switch-to-tab.
+ mUrlOpenListener.onUrlOpen(url, EnumSet.of(OnUrlOpenListener.Flags.ALLOW_SWITCH_TO_TAB));
+ }
+ });
+
+ mList.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() {
+ @Override
+ public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
+ // Don't do anything when the user long-clicks on a search engine row.
+ if (view instanceof SearchEngineRow) {
+ return true;
+ }
+
+ // Account for the search engine rows.
+ position -= getPrimaryEngineCount();
+ return mList.onItemLongClick(parent, view, position, id);
+ }
+ });
+
+ final ListSelectionListener listener = new ListSelectionListener();
+ mList.setOnItemSelectedListener(listener);
+ mList.setOnFocusChangeListener(listener);
+
+ mList.setContextMenuInfoFactory(new HomeContextMenuInfo.Factory() {
+ @Override
+ public HomeContextMenuInfo makeInfoForCursor(View view, int position, long id, Cursor cursor) {
+ final HomeContextMenuInfo info = new HomeContextMenuInfo(view, position, id);
+ info.url = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.URL));
+ info.title = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.TITLE));
+
+ int bookmarkId = cursor.getInt(cursor.getColumnIndexOrThrow(BrowserContract.Combined.BOOKMARK_ID));
+ info.bookmarkId = bookmarkId;
+
+ int historyId = cursor.getInt(cursor.getColumnIndexOrThrow(BrowserContract.Combined.HISTORY_ID));
+ info.historyId = historyId;
+
+ boolean isBookmark = bookmarkId != -1;
+ boolean isHistory = historyId != -1;
+
+ if (isBookmark && isHistory) {
+ info.itemType = HomeContextMenuInfo.RemoveItemType.COMBINED;
+ } else if (isBookmark) {
+ info.itemType = HomeContextMenuInfo.RemoveItemType.BOOKMARKS;
+ } else if (isHistory) {
+ info.itemType = HomeContextMenuInfo.RemoveItemType.HISTORY;
+ }
+
+ return info;
+ }
+ });
+
+ mList.setOnKeyListener(new View.OnKeyListener() {
+ @Override
+ public boolean onKey(View v, int keyCode, android.view.KeyEvent event) {
+ final View selected = mList.getSelectedView();
+
+ if (selected instanceof SearchEngineRow) {
+ return selected.onKeyDown(keyCode, event);
+ }
+ return false;
+ }
+ });
+
+ registerForContextMenu(mList);
+ GeckoApp.getEventDispatcher().registerGeckoThreadListener(this,
+ "SearchEngines:Data");
+
+ mSearchEngineBar.setOnSearchBarClickListener(this);
+ }
+
+ @Override
+ public void onCreateContextMenu(ContextMenu menu, View view, ContextMenuInfo menuInfo) {
+ if (!(menuInfo instanceof HomeContextMenuInfo)) {
+ return;
+ }
+
+ HomeContextMenuInfo info = (HomeContextMenuInfo) menuInfo;
+
+ MenuInflater inflater = new MenuInflater(view.getContext());
+ inflater.inflate(R.menu.browsersearch_contextmenu, menu);
+
+ menu.setHeaderTitle(info.getDisplayTitle());
+ }
+
+ @Override
+ public boolean onContextItemSelected(MenuItem item) {
+ ContextMenuInfo menuInfo = item.getMenuInfo();
+ if (!(menuInfo instanceof HomeContextMenuInfo)) {
+ return false;
+ }
+
+ final HomeContextMenuInfo info = (HomeContextMenuInfo) menuInfo;
+ final Context context = getActivity();
+
+ final int itemId = item.getItemId();
+
+ if (itemId == R.id.browsersearch_remove) {
+ // Position for Top Sites grid items, but will always be -1 since this is only for BrowserSearch result
+ final int position = -1;
+
+ new RemoveItemByUrlTask(context, info.url, info.itemType, position).execute();
+ return true;
+ }
+
+ return false;
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+
+ // Initialize the search adapter
+ mAdapter = new SearchAdapter(getActivity());
+ mList.setAdapter(mAdapter);
+
+ // Only create an instance when we need it
+ mSearchEngineSuggestionLoaderCallbacks = null;
+ mSearchHistorySuggestionLoaderCallback = null;
+
+ // Create callbacks before the initial loader is started
+ mCursorLoaderCallbacks = new CursorLoaderCallbacks();
+ loadIfVisible();
+ }
+
+ @Override
+ public void handleMessage(String event, final JSONObject message) {
+ if (event.equals("SearchEngines:Data")) {
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ setSearchEngines(message);
+ }
+ });
+ }
+ }
+
+ @Override
+ protected void load() {
+ SearchLoader.init(getLoaderManager(), LOADER_ID_SEARCH, mCursorLoaderCallbacks, mSearchTerm);
+ }
+
+ private void handleAutocomplete(String searchTerm, Cursor c) {
+ if (c == null ||
+ mAutocompleteHandler == null ||
+ TextUtils.isEmpty(searchTerm)) {
+ return;
+ }
+
+ // Avoid searching the path if we don't have to. Currently just
+ // decided by whether there is a '/' character in the string.
+ final boolean searchPath = searchTerm.indexOf('/') > 0;
+ final String autocompletion = findAutocompletion(searchTerm, c, searchPath);
+
+ if (autocompletion == null || mAutocompleteHandler == null) {
+ return;
+ }
+
+ // Prefetch auto-completed domain since it's a likely target
+ GeckoAppShell.notifyObservers("Session:Prefetch", "http://" + autocompletion);
+
+ mAutocompleteHandler.onAutocomplete(autocompletion);
+ mAutocompleteHandler = null;
+ }
+
+ /**
+ * Returns the substring of a provided URI, starting at the given offset,
+ * and extending up to the end of the path segment in which the provided
+ * index is found.
+ *
+ * For example, given
+ *
+ * "www.reddit.com/r/boop/abcdef", 0, ?
+ *
+ * this method returns
+ *
+ * ?=2: "www.reddit.com/"
+ * ?=17: "www.reddit.com/r/boop/"
+ * ?=21: "www.reddit.com/r/boop/"
+ * ?=22: "www.reddit.com/r/boop/abcdef"
+ *
+ */
+ private static String uriSubstringUpToMatchedPath(final String url, final int offset, final int begin) {
+ final int afterEnd = url.length();
+
+ // We want to include the trailing slash, but not other characters.
+ int chop = url.indexOf('/', begin);
+ if (chop != -1) {
+ ++chop;
+ if (chop < offset) {
+ // This isn't supposed to happen. Fall back to returning the whole damn thing.
+ return url;
+ }
+ } else {
+ chop = url.indexOf('?', begin);
+ if (chop == -1) {
+ chop = url.indexOf('#', begin);
+ }
+ if (chop == -1) {
+ chop = afterEnd;
+ }
+ }
+
+ return url.substring(offset, chop);
+ }
+
+ LinkedHashSet<String> domains = null;
+ private LinkedHashSet<String> getDomains() {
+ if (domains == null) {
+ domains = new LinkedHashSet<String>(500);
+ BufferedReader buf = null;
+ try {
+ buf = new BufferedReader(new InputStreamReader(getResources().openRawResource(R.raw.topdomains)));
+ String res = null;
+
+ do {
+ res = buf.readLine();
+ if (res != null) {
+ domains.add(res);
+ }
+ } while (res != null);
+ } catch (IOException e) {
+ Log.e(LOGTAG, "Error reading domains", e);
+ } finally {
+ if (buf != null) {
+ try {
+ buf.close();
+ } catch (IOException e) { }
+ }
+ }
+ }
+ return domains;
+ }
+
+ private String searchDomains(String search) {
+ for (String domain : getDomains()) {
+ if (domain.startsWith(search)) {
+ return domain;
+ }
+ }
+ return null;
+ }
+
+ private String findAutocompletion(String searchTerm, Cursor c, boolean searchPath) {
+ if (!c.moveToFirst()) {
+ // No cursor probably means no history, so let's try the fallback list.
+ return searchDomains(searchTerm);
+ }
+
+ final int searchLength = searchTerm.length();
+ final int urlIndex = c.getColumnIndexOrThrow(History.URL);
+ int searchCount = 0;
+
+ do {
+ final String url = c.getString(urlIndex);
+
+ if (searchCount == 0) {
+ // Prefetch the first item in the list since it's weighted the highest
+ GeckoAppShell.notifyObservers("Session:Prefetch", url);
+ }
+
+ // Does the completion match against the whole URL? This will match
+ // about: pages, as well as user input including "http://...".
+ if (url.startsWith(searchTerm)) {
+ return uriSubstringUpToMatchedPath(url, 0,
+ (searchLength > HTTPS_PREFIX_LENGTH) ? searchLength : HTTPS_PREFIX_LENGTH);
+ }
+
+ final Uri uri = Uri.parse(url);
+ final String host = uri.getHost();
+
+ // Host may be null for about pages.
+ if (host == null) {
+ continue;
+ }
+
+ if (host.startsWith(searchTerm)) {
+ return host + "/";
+ }
+
+ final String strippedHost = StringUtils.stripCommonSubdomains(host);
+ if (strippedHost.startsWith(searchTerm)) {
+ return strippedHost + "/";
+ }
+
+ ++searchCount;
+
+ if (!searchPath) {
+ continue;
+ }
+
+ // Otherwise, if we're matching paths, let's compare against the string itself.
+ final int hostOffset = url.indexOf(strippedHost);
+ if (hostOffset == -1) {
+ // This was a URL string that parsed to a different host (normalized?).
+ // Give up.
+ continue;
+ }
+
+ // We already matched the non-stripped host, so now we're
+ // substring-searching in the part of the URL without the common
+ // subdomains.
+ if (url.startsWith(searchTerm, hostOffset)) {
+ // Great! Return including the rest of the path segment.
+ return uriSubstringUpToMatchedPath(url, hostOffset, hostOffset + searchLength);
+ }
+ } while (searchCount < MAX_AUTOCOMPLETE_SEARCH && c.moveToNext());
+
+ // If we can't find an autocompletion domain from history, let's try using the fallback list.
+ return searchDomains(searchTerm);
+ }
+
+ public void resetScrollState() {
+ mSearchEngineBar.scrollToPosition(0);
+ }
+
+ private void filterSuggestions() {
+ Tab tab = Tabs.getInstance().getSelectedTab();
+ final boolean isPrivate = (tab != null && tab.isPrivate());
+
+ // mSuggestClient may be null if we haven't received our search engine list yet - hence
+ // we need to exit here in that case.
+ if (isPrivate || mSuggestClient == null || (!mSuggestionsEnabled && !mSavedSearchesEnabled)) {
+ mSearchHistorySuggestions.clear();
+ return;
+ }
+
+ // Suggestions from search engine
+ if (mSearchEngineSuggestionLoaderCallbacks == null) {
+ mSearchEngineSuggestionLoaderCallbacks = new SearchEngineSuggestionLoaderCallbacks();
+ }
+ getLoaderManager().restartLoader(LOADER_ID_SUGGESTION, null, mSearchEngineSuggestionLoaderCallbacks);
+
+ // Saved suggestions
+ if (mSearchHistorySuggestionLoaderCallback == null) {
+ mSearchHistorySuggestionLoaderCallback = new SearchHistorySuggestionLoaderCallbacks();
+ }
+ getLoaderManager().restartLoader(LOADER_ID_SAVED_SUGGESTION, null, mSearchHistorySuggestionLoaderCallback);
+ }
+
+ private void setSuggestions(ArrayList<String> suggestions) {
+ ThreadUtils.assertOnUiThread();
+
+ // mSearchEngines may be null if the setSuggestions calls after onDestroy (bug 1310621).
+ // So drop the suggestions if search engines are not available
+ if (mSearchEngines != null && !mSearchEngines.isEmpty()) {
+ mSearchEngines.get(0).setSuggestions(suggestions);
+ mAdapter.notifyDataSetChanged();
+ }
+
+ }
+
+ private void setSavedSuggestions(ArrayList<String> savedSuggestions) {
+ ThreadUtils.assertOnUiThread();
+
+ mSearchHistorySuggestions = savedSuggestions;
+ mAdapter.notifyDataSetChanged();
+ }
+
+ private boolean shouldUpdateSearchEngine(ArrayList<SearchEngine> searchEngines) {
+ if (searchEngines.size() != mSearchEngines.size()) {
+ return true;
+ }
+
+ int size = searchEngines.size();
+
+ for (int i = 0; i < size; i++) {
+ if (!mSearchEngines.get(i).name.equals(searchEngines.get(i).name)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private void setSearchEngines(JSONObject data) {
+ ThreadUtils.assertOnUiThread();
+
+ // This method is called via a Runnable posted from the Gecko thread, so
+ // it's possible the fragment and/or its view has been destroyed by the
+ // time we get here. If so, just abort.
+ if (mView == null) {
+ return;
+ }
+
+ try {
+ final JSONObject suggest = data.getJSONObject("suggest");
+ final String suggestEngine = suggest.optString("engine", null);
+ final String suggestTemplate = suggest.optString("template", null);
+ final boolean suggestionsPrompted = suggest.getBoolean("prompted");
+ final JSONArray engines = data.getJSONArray("searchEngines");
+
+ mSuggestionsEnabled = suggest.getBoolean("enabled");
+
+ ArrayList<SearchEngine> searchEngines = new ArrayList<SearchEngine>();
+ for (int i = 0; i < engines.length(); i++) {
+ final JSONObject engineJSON = engines.getJSONObject(i);
+ final SearchEngine engine = new SearchEngine((Context) getActivity(), engineJSON);
+
+ if (engine.name.equals(suggestEngine) && suggestTemplate != null) {
+ // Suggest engine should be at the front of the list.
+ // We're baking in an assumption here that the suggest engine
+ // is also the default engine.
+ searchEngines.add(0, engine);
+
+ ensureSuggestClientIsSet(suggestTemplate);
+ } else {
+ searchEngines.add(engine);
+ }
+ }
+
+ // checking if the new searchEngine is different from mSearchEngine, will have to re-layout if yes
+ boolean change = shouldUpdateSearchEngine(searchEngines);
+
+ if (mAdapter != null && change) {
+ mSearchEngines = Collections.unmodifiableList(searchEngines);
+ mLastLocale = Locale.getDefault();
+ updateSearchEngineBar();
+
+ mAdapter.notifyDataSetChanged();
+ }
+
+ final Tab tab = Tabs.getInstance().getSelectedTab();
+ final boolean isPrivate = (tab != null && tab.isPrivate());
+
+ // Show suggestions opt-in prompt only if suggestions are not enabled yet,
+ // user hasn't been prompted and we're not on a private browsing tab.
+ // The prompt might have been inflated already when this view was previously called.
+ // Remove the opt-in prompt if it has been inflated in the view and dealt with by the user,
+ // or if we're on a private browsing tab
+ if (!mSuggestionsEnabled && !suggestionsPrompted && !isPrivate) {
+ showSuggestionsOptIn();
+ } else {
+ removeSuggestionsOptIn();
+ }
+
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error getting search engine JSON", e);
+ }
+
+ filterSuggestions();
+ }
+
+ private void updateSearchEngineBar() {
+ final int primaryEngineCount = getPrimaryEngineCount();
+
+ if (primaryEngineCount < mSearchEngines.size()) {
+ mSearchEngineBar.setSearchEngines(
+ mSearchEngines.subList(primaryEngineCount, mSearchEngines.size())
+ );
+ mSearchEngineBar.setVisibility(View.VISIBLE);
+ } else {
+ mSearchEngineBar.setVisibility(View.GONE);
+ }
+ }
+
+ @Override
+ public void onSearchBarClickListener(final SearchEngine searchEngine) {
+ final TelemetryContract.Method method = TelemetryContract.Method.LIST_ITEM;
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, method, "searchenginebar");
+ mSearchListener.onSearch(searchEngine, mSearchTerm, method);
+ }
+
+ private void ensureSuggestClientIsSet(final String suggestTemplate) {
+ // Don't update the suggestClient if we already have a client with the correct template
+ if (mSuggestClient != null && suggestTemplate.equals(mSuggestClient.getSuggestTemplate())) {
+ return;
+ }
+
+ mSuggestClient = sSuggestClientFactory.getSuggestClient(getActivity(), suggestTemplate, SUGGESTION_TIMEOUT, NETWORK_SUGGESTION_MAX);
+ }
+
+ private void showSuggestionsOptIn() {
+ // Only make the ViewStub visible again if it has already previously been shown.
+ // (An inflated ViewStub is removed from the View hierarchy so a second call to findViewById will return null,
+ // which also further necessitates handling this separately.)
+ if (mSuggestionsOptInPrompt != null) {
+ mSuggestionsOptInPrompt.setVisibility(View.VISIBLE);
+ return;
+ }
+
+ mSuggestionsOptInPrompt = ((ViewStub) mView.findViewById(R.id.suggestions_opt_in_prompt)).inflate();
+
+ TextView promptText = (TextView) mSuggestionsOptInPrompt.findViewById(R.id.suggestions_prompt_title);
+ promptText.setText(getResources().getString(R.string.suggestions_prompt));
+
+ final View yesButton = mSuggestionsOptInPrompt.findViewById(R.id.suggestions_prompt_yes);
+ final View noButton = mSuggestionsOptInPrompt.findViewById(R.id.suggestions_prompt_no);
+
+ final OnClickListener listener = new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ // Prevent the buttons from being clicked multiple times (bug 816902)
+ yesButton.setOnClickListener(null);
+ noButton.setOnClickListener(null);
+
+ setSuggestionsEnabled(v == yesButton);
+ }
+ };
+
+ yesButton.setOnClickListener(listener);
+ noButton.setOnClickListener(listener);
+
+ // If the prompt gains focus, automatically pass focus to the
+ // yes button in the prompt.
+ final View prompt = mSuggestionsOptInPrompt.findViewById(R.id.prompt);
+ prompt.setOnFocusChangeListener(new View.OnFocusChangeListener() {
+ @Override
+ public void onFocusChange(View v, boolean hasFocus) {
+ if (hasFocus) {
+ yesButton.requestFocus();
+ }
+ }
+ });
+ }
+
+ private void removeSuggestionsOptIn() {
+ if (mSuggestionsOptInPrompt == null) {
+ return;
+ }
+
+ mSuggestionsOptInPrompt.setVisibility(View.GONE);
+ }
+
+ private void setSuggestionsEnabled(final boolean enabled) {
+ // Clicking the yes/no buttons quickly can cause the click events be
+ // queued before the listeners are removed above, so it's possible
+ // setSuggestionsEnabled() can be called twice. mSuggestionsOptInPrompt
+ // can be null if this happens (bug 828480).
+ if (mSuggestionsOptInPrompt == null) {
+ return;
+ }
+
+ // Make suggestions appear immediately after the user opts in
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ SuggestClient client = mSuggestClient;
+ if (client != null) {
+ client.query(mSearchTerm);
+ }
+ }
+ });
+
+ PrefsHelper.setPref("browser.search.suggest.prompted", true);
+ PrefsHelper.setPref("browser.search.suggest.enabled", enabled);
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, (enabled ? "suggestions_optin_yes" : "suggestions_optin_no"));
+
+ TranslateAnimation slideAnimation = new TranslateAnimation(0, mSuggestionsOptInPrompt.getWidth(), 0, 0);
+ slideAnimation.setDuration(ANIMATION_DURATION);
+ slideAnimation.setInterpolator(new AccelerateInterpolator());
+ slideAnimation.setFillAfter(true);
+ final View prompt = mSuggestionsOptInPrompt.findViewById(R.id.prompt);
+
+ TranslateAnimation shrinkAnimation = new TranslateAnimation(0, 0, 0, -1 * mSuggestionsOptInPrompt.getHeight());
+ shrinkAnimation.setDuration(ANIMATION_DURATION);
+ shrinkAnimation.setFillAfter(true);
+ shrinkAnimation.setStartOffset(slideAnimation.getDuration());
+ shrinkAnimation.setAnimationListener(new Animation.AnimationListener() {
+ @Override
+ public void onAnimationStart(Animation a) {
+ // Increase the height of the view so a gap isn't shown during animation
+ mView.getLayoutParams().height = mView.getHeight() +
+ mSuggestionsOptInPrompt.getHeight();
+ mView.requestLayout();
+ }
+
+ @Override
+ public void onAnimationRepeat(Animation a) {}
+
+ @Override
+ public void onAnimationEnd(Animation a) {
+ // Removing the view immediately results in a NPE in
+ // dispatchDraw(), possibly because this callback executes
+ // before drawing is finished. Posting this as a Runnable fixes
+ // the issue.
+ mView.post(new Runnable() {
+ @Override
+ public void run() {
+ mView.removeView(mSuggestionsOptInPrompt);
+ mList.clearAnimation();
+ mSuggestionsOptInPrompt = null;
+
+ // Reset the view height
+ mView.getLayoutParams().height = LayoutParams.MATCH_PARENT;
+
+ // Show search suggestions and update them
+ if (enabled) {
+ mSuggestionsEnabled = enabled;
+ mAnimateSuggestions = true;
+ mAdapter.notifyDataSetChanged();
+ filterSuggestions();
+ }
+ }
+ });
+ }
+ });
+
+ prompt.startAnimation(slideAnimation);
+ mSuggestionsOptInPrompt.startAnimation(shrinkAnimation);
+ mList.startAnimation(shrinkAnimation);
+ }
+
+ private int getPrimaryEngineCount() {
+ return mSearchEngines.size() > 0 ? 1 : 0;
+ }
+
+ private void restartSearchLoader() {
+ SearchLoader.restart(getLoaderManager(), LOADER_ID_SEARCH, mCursorLoaderCallbacks, mSearchTerm);
+ }
+
+ private void initSearchLoader() {
+ SearchLoader.init(getLoaderManager(), LOADER_ID_SEARCH, mCursorLoaderCallbacks, mSearchTerm);
+ }
+
+ public void filter(String searchTerm, AutocompleteHandler handler) {
+ if (TextUtils.isEmpty(searchTerm)) {
+ return;
+ }
+
+ final boolean isNewFilter = !TextUtils.equals(mSearchTerm, searchTerm);
+
+ mSearchTerm = searchTerm;
+ mAutocompleteHandler = handler;
+
+ if (mAdapter != null) {
+ if (isNewFilter) {
+ // The adapter depends on the search term to determine its number
+ // of items. Make it we notify the view about it.
+ mAdapter.notifyDataSetChanged();
+
+ // Restart loaders with the new search term
+ restartSearchLoader();
+ filterSuggestions();
+ } else {
+ // The search term hasn't changed, simply reuse any existing
+ // loader for the current search term. This will ensure autocompletion
+ // is consistently triggered (see bug 933739).
+ initSearchLoader();
+ }
+ }
+ }
+
+ abstract private static class SuggestionAsyncLoader extends AsyncTaskLoader<ArrayList<String>> {
+ protected final String mSearchTerm;
+ private ArrayList<String> mSuggestions;
+
+ public SuggestionAsyncLoader(Context context, String searchTerm) {
+ super(context);
+ mSearchTerm = searchTerm;
+ }
+
+ @Override
+ public void deliverResult(ArrayList<String> suggestions) {
+ mSuggestions = suggestions;
+
+ if (isStarted()) {
+ super.deliverResult(mSuggestions);
+ }
+ }
+
+ @Override
+ protected void onStartLoading() {
+ if (mSuggestions != null) {
+ deliverResult(mSuggestions);
+ }
+
+ if (takeContentChanged() || mSuggestions == null) {
+ forceLoad();
+ }
+ }
+
+ @Override
+ protected void onStopLoading() {
+ cancelLoad();
+ }
+
+ @Override
+ protected void onReset() {
+ super.onReset();
+
+ onStopLoading();
+ mSuggestions = null;
+ }
+ }
+
+ private static class SearchEngineSuggestionAsyncLoader extends SuggestionAsyncLoader {
+ private final SuggestClient mSuggestClient;
+
+ public SearchEngineSuggestionAsyncLoader(Context context, SuggestClient suggestClient, String searchTerm) {
+ super(context, searchTerm);
+ mSuggestClient = suggestClient;
+ }
+
+ @Override
+ public ArrayList<String> loadInBackground() {
+ return mSuggestClient.query(mSearchTerm);
+ }
+ }
+
+ private static class SearchHistorySuggestionAsyncLoader extends SuggestionAsyncLoader {
+ public SearchHistorySuggestionAsyncLoader(Context context, String searchTerm) {
+ super(context, searchTerm);
+ }
+
+ @Override
+ public ArrayList<String> loadInBackground() {
+ final ContentResolver cr = getContext().getContentResolver();
+
+ String[] columns = new String[] { BrowserContract.SearchHistory.QUERY };
+ String actualQuery = BrowserContract.SearchHistory.QUERY + " LIKE ?";
+ String[] queryArgs = new String[] { '%' + mSearchTerm + '%' };
+
+ // For deduplication, the worst case is that all the first NETWORK_SUGGESTION_MAX history suggestions are duplicates
+ // of search engine suggestions, and the there is a duplicate for the search term itself. A duplicate of the
+ // search term can occur if the user has previously searched for the same thing.
+ final int maxSavedSuggestions = NETWORK_SUGGESTION_MAX + 1 + getContext().getResources().getInteger(R.integer.max_saved_suggestions);
+
+ final String sortOrderAndLimit = BrowserContract.SearchHistory.DATE + " DESC LIMIT " + maxSavedSuggestions;
+ final Cursor result = cr.query(BrowserContract.SearchHistory.CONTENT_URI, columns, actualQuery, queryArgs, sortOrderAndLimit);
+
+ if (result == null) {
+ return new ArrayList<>();
+ }
+
+ final ArrayList<String> savedSuggestions = new ArrayList<>();
+ try {
+ if (result.moveToFirst()) {
+ final int searchColumn = result.getColumnIndexOrThrow(BrowserContract.SearchHistory.QUERY);
+ do {
+ final String savedSearch = result.getString(searchColumn);
+ savedSuggestions.add(savedSearch);
+ } while (result.moveToNext());
+ }
+ } finally {
+ result.close();
+ }
+
+ return savedSuggestions;
+ }
+ }
+
+ private class SearchAdapter extends MultiTypeCursorAdapter {
+ private static final int ROW_SEARCH = 0;
+ private static final int ROW_STANDARD = 1;
+ private static final int ROW_SUGGEST = 2;
+
+ public SearchAdapter(Context context) {
+ super(context, null, new int[] { ROW_STANDARD,
+ ROW_SEARCH,
+ ROW_SUGGEST },
+ new int[] { R.layout.home_item_row,
+ R.layout.home_search_item_row,
+ R.layout.home_search_item_row });
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ if (position < getPrimaryEngineCount()) {
+ if (mSuggestionsEnabled && mSearchEngines.get(position).hasSuggestions()) {
+ // Give suggestion views their own type to prevent them from
+ // sharing other recycled search result views. Using other
+ // recycled views for the suggestion row can break animations
+ // (bug 815937).
+
+ return ROW_SUGGEST;
+ } else {
+ return ROW_SEARCH;
+ }
+ }
+
+ return ROW_STANDARD;
+ }
+
+ @Override
+ public boolean isEnabled(int position) {
+ // If we're using a gamepad or keyboard, allow the row to be
+ // focused so it can pass the focus to its child suggestion views.
+ if (!mList.isInTouchMode()) {
+ return true;
+ }
+
+ // If the suggestion row only contains one item (the user-entered
+ // query), allow the entire row to be clickable; clicking the row
+ // has the same effect as clicking the single suggestion. If the
+ // row contains multiple items, clicking the row will do nothing.
+
+ if (position < getPrimaryEngineCount()) {
+ return !mSearchEngines.get(position).hasSuggestions();
+ }
+
+ return true;
+ }
+
+ // Add the search engines to the number of reported results.
+ @Override
+ public int getCount() {
+ final int resultCount = super.getCount();
+
+ // Don't show search engines or suggestions if search field is empty
+ if (TextUtils.isEmpty(mSearchTerm)) {
+ return resultCount;
+ }
+
+ return resultCount + getPrimaryEngineCount();
+ }
+
+ @Override
+ public void bindView(View view, Context context, int position) {
+ final int type = getItemViewType(position);
+
+ if (type == ROW_SEARCH || type == ROW_SUGGEST) {
+ final SearchEngineRow row = (SearchEngineRow) view;
+ row.setOnUrlOpenListener(mUrlOpenListener);
+ row.setOnSearchListener(mSearchListener);
+ row.setOnEditSuggestionListener(mEditSuggestionListener);
+ row.setSearchTerm(mSearchTerm);
+
+ final SearchEngine engine = mSearchEngines.get(position);
+ final boolean haveSuggestions = (engine.hasSuggestions() || !mSearchHistorySuggestions.isEmpty());
+ final boolean animate = (mAnimateSuggestions && haveSuggestions);
+ row.updateSuggestions(mSuggestionsEnabled, engine, mSearchHistorySuggestions, animate);
+ if (animate) {
+ // Only animate suggestions the first time they are shown
+ mAnimateSuggestions = false;
+ }
+ } else {
+ // Account for the search engines
+ position -= getPrimaryEngineCount();
+
+ final Cursor c = getCursor(position);
+ final TwoLinePageRow row = (TwoLinePageRow) view;
+ row.updateFromCursor(c);
+ }
+ }
+ }
+
+ private class CursorLoaderCallbacks implements LoaderCallbacks<Cursor> {
+ @Override
+ public Loader<Cursor> onCreateLoader(int id, Bundle args) {
+ return SearchLoader.createInstance(getActivity(), args);
+ }
+
+ @Override
+ public void onLoadFinished(Loader<Cursor> loader, Cursor c) {
+ if (mAdapter != null) {
+ mAdapter.swapCursor(c);
+
+ // We should handle autocompletion based on the search term
+ // associated with the loader that has just provided
+ // the results.
+ SearchCursorLoader searchLoader = (SearchCursorLoader) loader;
+ handleAutocomplete(searchLoader.getSearchTerm(), c);
+ }
+ }
+
+ @Override
+ public void onLoaderReset(Loader<Cursor> loader) {
+ if (mAdapter != null) {
+ mAdapter.swapCursor(null);
+ }
+ }
+ }
+
+ private class SearchEngineSuggestionLoaderCallbacks implements LoaderCallbacks<ArrayList<String>> {
+ @Override
+ public Loader<ArrayList<String>> onCreateLoader(int id, Bundle args) {
+ // mSuggestClient is set to null in onDestroyView(), so using it
+ // safely here relies on the fact that onCreateLoader() is called
+ // synchronously in restartLoader().
+ return new SearchEngineSuggestionAsyncLoader(getActivity(), mSuggestClient, mSearchTerm);
+ }
+
+ @Override
+ public void onLoadFinished(Loader<ArrayList<String>> loader, ArrayList<String> suggestions) {
+ setSuggestions(suggestions);
+ }
+
+ @Override
+ public void onLoaderReset(Loader<ArrayList<String>> loader) {
+ setSuggestions(new ArrayList<String>());
+ }
+ }
+
+ private class SearchHistorySuggestionLoaderCallbacks implements LoaderCallbacks<ArrayList<String>> {
+ @Override
+ public Loader<ArrayList<String>> onCreateLoader(int id, Bundle args) {
+ // mSuggestClient is set to null in onDestroyView(), so using it
+ // safely here relies on the fact that onCreateLoader() is called
+ // synchronously in restartLoader().
+ return new SearchHistorySuggestionAsyncLoader(getActivity(), mSearchTerm);
+ }
+
+ @Override
+ public void onLoadFinished(Loader<ArrayList<String>> loader, ArrayList<String> suggestions) {
+ setSavedSuggestions(suggestions);
+ }
+
+ @Override
+ public void onLoaderReset(Loader<ArrayList<String>> loader) {
+ setSavedSuggestions(new ArrayList<String>());
+ }
+ }
+
+ private static class ListSelectionListener implements View.OnFocusChangeListener,
+ AdapterView.OnItemSelectedListener {
+ private SearchEngineRow mSelectedEngineRow;
+
+ @Override
+ public void onFocusChange(View v, boolean hasFocus) {
+ if (hasFocus) {
+ View selectedRow = ((ListView) v).getSelectedView();
+ if (selectedRow != null) {
+ selectRow(selectedRow);
+ }
+ } else {
+ deselectRow();
+ }
+ }
+
+ @Override
+ public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
+ deselectRow();
+ selectRow(view);
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView<?> parent) {
+ deselectRow();
+ }
+
+ private void selectRow(View row) {
+ if (row instanceof SearchEngineRow) {
+ mSelectedEngineRow = (SearchEngineRow) row;
+ mSelectedEngineRow.onSelected();
+ }
+ }
+
+ private void deselectRow() {
+ if (mSelectedEngineRow != null) {
+ mSelectedEngineRow.onDeselected();
+ mSelectedEngineRow = null;
+ }
+ }
+ }
+
+ /**
+ * HomeSearchListView is a list view for displaying search engine results on the awesome screen.
+ */
+ public static class HomeSearchListView extends HomeListView {
+ public HomeSearchListView(Context context, AttributeSet attrs) {
+ this(context, attrs, R.attr.homeListViewStyle);
+ }
+
+ public HomeSearchListView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
+ // Dismiss the soft keyboard.
+ requestFocus();
+ }
+
+ return super.onTouchEvent(event);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/ClientsAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/ClientsAdapter.java
new file mode 100644
index 000000000..f288a2745
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/ClientsAdapter.java
@@ -0,0 +1,373 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import android.content.Context;
+import android.support.annotation.UiThread;
+import android.support.v4.util.Pair;
+import android.support.v7.widget.RecyclerView;
+import android.text.format.DateUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.RemoteClient;
+import org.mozilla.gecko.db.RemoteTab;
+
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.GregorianCalendar;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+
+import static org.mozilla.gecko.home.CombinedHistoryItem.ItemType.*;
+
+public class ClientsAdapter extends RecyclerView.Adapter<CombinedHistoryItem> implements CombinedHistoryRecyclerView.AdapterContextMenuBuilder {
+ public static final String LOGTAG = "GeckoClientsAdapter";
+
+ /**
+ * If a device claims to have synced before this date, we will assume it has never synced.
+ */
+ public static final Date EARLIEST_VALID_SYNCED_DATE;
+ static {
+ final Calendar c = GregorianCalendar.getInstance();
+ c.set(2000, Calendar.JANUARY, 1, 0, 0, 0);
+ EARLIEST_VALID_SYNCED_DATE = c.getTime();
+ }
+
+ List<Pair<String, Integer>> adapterList = new LinkedList<>();
+
+ // List of hidden remote clients.
+ // Only accessed from the UI thread.
+ protected final List<RemoteClient> hiddenClients = new ArrayList<>();
+ private Map<String, RemoteClient> visibleClients = new HashMap<>();
+
+ // Maintain group collapsed and hidden state. Only accessed from the UI thread.
+ protected static RemoteTabsExpandableListState sState;
+
+ private final Context context;
+
+ public ClientsAdapter(Context context) {
+ this.context = context;
+
+ // This races when multiple Fragments are created. That's okay: one
+ // will win, and thereafter, all will be okay. If we create and then
+ // drop an instance the shared SharedPreferences backing all the
+ // instances will maintain the state for us. Since everything happens on
+ // the UI thread, this doesn't even need to be volatile.
+ if (sState == null) {
+ sState = new RemoteTabsExpandableListState(GeckoSharedPrefs.forProfile(context));
+ }
+
+ this.setHasStableIds(true);
+ }
+
+ @Override
+ public CombinedHistoryItem onCreateViewHolder(ViewGroup parent, int viewType) {
+ final LayoutInflater inflater = LayoutInflater.from(parent.getContext());
+ final View view;
+
+ final CombinedHistoryItem.ItemType itemType = CombinedHistoryItem.ItemType.viewTypeToItemType(viewType);
+
+ switch (itemType) {
+ case NAVIGATION_BACK:
+ view = inflater.inflate(R.layout.home_combined_back_item, parent, false);
+ return new CombinedHistoryItem.HistoryItem(view);
+
+ case CLIENT:
+ view = inflater.inflate(R.layout.home_remote_tabs_group, parent, false);
+ return new CombinedHistoryItem.ClientItem(view);
+
+ case CHILD:
+ view = inflater.inflate(R.layout.home_item_row, parent, false);
+ return new CombinedHistoryItem.HistoryItem(view);
+
+ case HIDDEN_DEVICES:
+ view = inflater.inflate(R.layout.home_remote_tabs_hidden_devices, parent, false);
+ return new CombinedHistoryItem.BasicItem(view);
+ }
+ return null;
+ }
+
+ @Override
+ public void onBindViewHolder(CombinedHistoryItem holder, final int position) {
+ final CombinedHistoryItem.ItemType itemType = getItemTypeForPosition(position);
+
+ switch (itemType) {
+ case CLIENT:
+ final CombinedHistoryItem.ClientItem clientItem = (CombinedHistoryItem.ClientItem) holder;
+ final String clientGuid = adapterList.get(position).first;
+ final RemoteClient client = visibleClients.get(clientGuid);
+ clientItem.bind(context, client, sState.isClientCollapsed(clientGuid));
+ break;
+
+ case CHILD:
+ final Pair<String, Integer> pair = adapterList.get(position);
+ RemoteTab remoteTab = visibleClients.get(pair.first).tabs.get(pair.second);
+ ((CombinedHistoryItem.HistoryItem) holder).bind(remoteTab);
+ break;
+
+ case HIDDEN_DEVICES:
+ final String hiddenDevicesLabel = context.getResources().getString(R.string.home_remote_tabs_many_hidden_devices, hiddenClients.size());
+ ((TextView) holder.itemView).setText(hiddenDevicesLabel);
+ break;
+ }
+ }
+
+ @Override
+ public int getItemCount () {
+ return adapterList.size();
+ }
+
+ private CombinedHistoryItem.ItemType getItemTypeForPosition(int position) {
+ if (position == 0) {
+ return NAVIGATION_BACK;
+ }
+
+ final Pair<String, Integer> pair = adapterList.get(position);
+ if (pair == null) {
+ return HIDDEN_DEVICES;
+ } else if (pair.second == -1) {
+ return CLIENT;
+ } else {
+ return CHILD;
+ }
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ return CombinedHistoryItem.ItemType.itemTypeToViewType(getItemTypeForPosition(position));
+ }
+
+ @Override
+ public long getItemId(int position) {
+ // RecyclerView.NO_ID is -1, so start our hard-coded IDs at -2.
+ final int NAVIGATION_BACK_ID = -2;
+ final int HIDDEN_DEVICES_ID = -3;
+
+ final String clientGuid;
+ // adapterList is a list of tuples (clientGuid, tabId).
+ final Pair<String, Integer> pair = adapterList.get(position);
+
+ switch (getItemTypeForPosition(position)) {
+ case NAVIGATION_BACK:
+ return NAVIGATION_BACK_ID;
+
+ case HIDDEN_DEVICES:
+ return HIDDEN_DEVICES_ID;
+
+ // For Clients, return hashCode of their GUIDs.
+ case CLIENT:
+ clientGuid = pair.first;
+ return clientGuid.hashCode();
+
+ // For Tabs, return hashCode of their URLs.
+ case CHILD:
+ clientGuid = pair.first;
+ final Integer tabId = pair.second;
+
+ final RemoteClient remoteClient = visibleClients.get(clientGuid);
+ if (remoteClient == null) {
+ return RecyclerView.NO_ID;
+ }
+
+ final RemoteTab remoteTab = remoteClient.tabs.get(tabId);
+ if (remoteTab == null) {
+ return RecyclerView.NO_ID;
+ }
+
+ return remoteTab.url.hashCode();
+
+ default:
+ throw new IllegalStateException("Unexpected Home Panel item type");
+ }
+ }
+
+ public int getClientsCount() {
+ return hiddenClients.size() + visibleClients.size();
+ }
+
+ @UiThread
+ public void setClients(List<RemoteClient> clients) {
+ adapterList.clear();
+ adapterList.add(null);
+
+ hiddenClients.clear();
+ visibleClients.clear();
+
+ for (RemoteClient client : clients) {
+ final String guid = client.guid;
+ if (sState.isClientHidden(guid)) {
+ hiddenClients.add(client);
+ } else {
+ visibleClients.put(guid, client);
+ adapterList.addAll(getVisibleItems(client));
+ }
+ }
+
+ // Add item for unhiding clients.
+ if (!hiddenClients.isEmpty()) {
+ adapterList.add(null);
+ }
+
+ notifyDataSetChanged();
+ }
+
+ private static List<Pair<String, Integer>> getVisibleItems(RemoteClient client) {
+ List<Pair<String, Integer>> list = new LinkedList<>();
+ final String guid = client.guid;
+ list.add(new Pair<>(guid, -1));
+ if (!sState.isClientCollapsed(client.guid)) {
+ for (int i = 0; i < client.tabs.size(); i++) {
+ list.add(new Pair<>(guid, i));
+ }
+ }
+ return list;
+ }
+
+ public List<RemoteClient> getHiddenClients() {
+ return hiddenClients;
+ }
+
+ public void toggleClient(int position) {
+ final Pair<String, Integer> pair = adapterList.get(position);
+ if (pair.second != -1) {
+ return;
+ }
+
+ final String clientGuid = pair.first;
+ final RemoteClient client = visibleClients.get(clientGuid);
+
+ final boolean isCollapsed = sState.isClientCollapsed(clientGuid);
+
+ sState.setClientCollapsed(clientGuid, !isCollapsed);
+ notifyItemChanged(position);
+
+ if (isCollapsed) {
+ for (int i = client.tabs.size() - 1; i > -1; i--) {
+ // Insert child tabs at the index right after the client item that was clicked.
+ adapterList.add(position + 1, new Pair<>(clientGuid, i));
+ }
+ notifyItemRangeInserted(position + 1, client.tabs.size());
+ } else {
+ int i = client.tabs.size();
+ while (i > 0) {
+ adapterList.remove(position + 1);
+ i--;
+ }
+ notifyItemRangeRemoved(position + 1, client.tabs.size());
+ }
+ }
+
+ public void unhideClients(List<RemoteClient> selectedClients) {
+ final int numClients = selectedClients.size();
+ if (numClients == 0) {
+ return;
+ }
+
+ final int insertionIndex = adapterList.size() - 1;
+ int itemCount = numClients;
+
+ for (RemoteClient client : selectedClients) {
+ final String clientGuid = client.guid;
+
+ sState.setClientHidden(clientGuid, false);
+ hiddenClients.remove(client);
+
+ visibleClients.put(clientGuid, client);
+ sState.setClientCollapsed(clientGuid, false);
+ adapterList.addAll(adapterList.size() - 1, getVisibleItems(client));
+
+ itemCount += client.tabs.size();
+ }
+
+ notifyItemRangeInserted(insertionIndex, itemCount);
+
+ final int hiddenDevicesIndex = adapterList.size() - 1;
+ if (hiddenClients.isEmpty()) {
+ // No more hidden clients, remove "unhide" item.
+ adapterList.remove(hiddenDevicesIndex);
+ notifyItemRemoved(hiddenDevicesIndex);
+ } else {
+ // Update "hidden clients" item because number of hidden clients changed.
+ notifyItemChanged(hiddenDevicesIndex);
+ }
+ }
+
+ public void removeItem(int position) {
+ final CombinedHistoryItem.ItemType itemType = getItemTypeForPosition(position);
+ switch (itemType) {
+ case CLIENT:
+ final String clientGuid = adapterList.get(position).first;
+ final RemoteClient client = visibleClients.remove(clientGuid);
+ final boolean hadHiddenClients = !hiddenClients.isEmpty();
+
+ int removeCount = sState.isClientCollapsed(clientGuid) ? 1 : client.tabs.size() + 1;
+ int c = removeCount;
+ while (c > 0) {
+ adapterList.remove(position);
+ c--;
+ }
+ notifyItemRangeRemoved(position, removeCount);
+
+ sState.setClientHidden(clientGuid, true);
+ hiddenClients.add(client);
+
+ if (!hadHiddenClients) {
+ // Add item for unhiding clients;
+ adapterList.add(null);
+ notifyItemInserted(adapterList.size() - 1);
+ } else {
+ // Update "hidden clients" item because number of hidden clients changed.
+ notifyItemChanged(adapterList.size() - 1);
+ }
+ break;
+ }
+ }
+
+ @Override
+ public HomeContextMenuInfo makeContextMenuInfoFromPosition(View view, int position) {
+ final CombinedHistoryItem.ItemType itemType = getItemTypeForPosition(position);
+ HomeContextMenuInfo info;
+ final Pair<String, Integer> pair = adapterList.get(position);
+ switch (itemType) {
+ case CHILD:
+ info = new HomeContextMenuInfo(view, position, -1);
+ return populateChildInfoFromTab(info, visibleClients.get(pair.first).tabs.get(pair.second));
+
+ case CLIENT:
+ info = new CombinedHistoryPanel.RemoteTabsClientContextMenuInfo(view, position, -1, visibleClients.get(pair.first));
+ return info;
+ }
+ return null;
+ }
+
+ protected static HomeContextMenuInfo populateChildInfoFromTab(HomeContextMenuInfo info, RemoteTab tab) {
+ info.url = tab.url;
+ info.title = tab.title;
+ return info;
+ }
+
+ /**
+ * Return a relative "Last synced" time span for the given tab record.
+ *
+ * @param now local time.
+ * @param time to format string for.
+ * @return string describing time span
+ */
+ public static String getLastSyncedString(Context context, long now, long time) {
+ if (new Date(time).before(EARLIEST_VALID_SYNCED_DATE)) {
+ return context.getString(R.string.remote_tabs_never_synced);
+ }
+ final CharSequence relativeTimeSpanString = DateUtils.getRelativeTimeSpanString(time, now, DateUtils.MINUTE_IN_MILLIS);
+ return context.getResources().getString(R.string.remote_tabs_last_synced, relativeTimeSpanString);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryAdapter.java
new file mode 100644
index 000000000..402ed26e7
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryAdapter.java
@@ -0,0 +1,433 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import android.content.res.Resources;
+import android.support.annotation.UiThread;
+import android.support.v7.widget.LinearLayoutManager;
+import android.support.v7.widget.RecyclerView;
+
+import android.database.Cursor;
+import android.util.SparseArray;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.util.ThreadUtils;
+
+public class CombinedHistoryAdapter extends RecyclerView.Adapter<CombinedHistoryItem> implements CombinedHistoryRecyclerView.AdapterContextMenuBuilder {
+ private static final int RECENT_TABS_SMARTFOLDER_INDEX = 0;
+
+ // Array for the time ranges in milliseconds covered by each section.
+ static final HistorySectionsHelper.SectionDateRange[] sectionDateRangeArray = new HistorySectionsHelper.SectionDateRange[SectionHeader.values().length];
+
+ // Semantic names for the time covered by each section
+ public enum SectionHeader {
+ TODAY,
+ YESTERDAY,
+ WEEK,
+ THIS_MONTH,
+ MONTH_AGO,
+ TWO_MONTHS_AGO,
+ THREE_MONTHS_AGO,
+ FOUR_MONTHS_AGO,
+ FIVE_MONTHS_AGO,
+ OLDER_THAN_SIX_MONTHS
+ }
+
+ private HomeFragment.PanelStateChangeListener panelStateChangeListener;
+
+ private Cursor historyCursor;
+ private DevicesUpdateHandler devicesUpdateHandler;
+ private int deviceCount = 0;
+ private RecentTabsUpdateHandler recentTabsUpdateHandler;
+ private int recentTabsCount = 0;
+
+ private LinearLayoutManager linearLayoutManager; // Only used on the UI thread, so no need to be volatile.
+
+ // We use a sparse array to store each section header's position in the panel [more cheaply than a HashMap].
+ private final SparseArray<SectionHeader> sectionHeaders;
+
+ public CombinedHistoryAdapter(Resources resources, int cachedRecentTabsCount) {
+ super();
+ recentTabsCount = cachedRecentTabsCount;
+ sectionHeaders = new SparseArray<>();
+ HistorySectionsHelper.updateRecentSectionOffset(resources, sectionDateRangeArray);
+ this.setHasStableIds(true);
+ }
+
+ public void setPanelStateChangeListener(
+ HomeFragment.PanelStateChangeListener panelStateChangeListener) {
+ this.panelStateChangeListener = panelStateChangeListener;
+ }
+
+ @UiThread
+ public void setLinearLayoutManager(LinearLayoutManager linearLayoutManager) {
+ this.linearLayoutManager = linearLayoutManager;
+ }
+
+ public void setHistory(Cursor history) {
+ historyCursor = history;
+ populateSectionHeaders(historyCursor, sectionHeaders);
+ notifyDataSetChanged();
+ }
+
+ public interface DevicesUpdateHandler {
+ void onDeviceCountUpdated(int count);
+ }
+
+ public DevicesUpdateHandler getDeviceUpdateHandler() {
+ if (devicesUpdateHandler == null) {
+ devicesUpdateHandler = new DevicesUpdateHandler() {
+ @Override
+ public void onDeviceCountUpdated(int count) {
+ deviceCount = count;
+ notifyItemChanged(getSyncedDevicesSmartFolderIndex());
+ }
+ };
+ }
+ return devicesUpdateHandler;
+ }
+
+ public interface RecentTabsUpdateHandler {
+ void onRecentTabsCountUpdated(int count, boolean countReliable);
+ }
+
+ public RecentTabsUpdateHandler getRecentTabsUpdateHandler() {
+ if (recentTabsUpdateHandler != null) {
+ return recentTabsUpdateHandler;
+ }
+
+ recentTabsUpdateHandler = new RecentTabsUpdateHandler() {
+ @Override
+ public void onRecentTabsCountUpdated(final int count, final boolean countReliable) {
+ // Now that other items can move around depending on the visibility of the
+ // Recent Tabs folder, only update the recentTabsCount on the UI thread.
+ ThreadUtils.postToUiThread(new Runnable() {
+ @UiThread
+ @Override
+ public void run() {
+ if (!countReliable && count <= recentTabsCount) {
+ // The final tab count (where countReliable = true) is normally >= than
+ // previous values with countReliable = false. Hence we only want to
+ // update the displayed tab count with a preliminary value if it's larger
+ // than the previous count, so as to avoid the displayed count jumping
+ // downwards and then back up, as well as unnecessary folder animations.
+ return;
+ }
+
+ final boolean prevFolderVisibility = isRecentTabsFolderVisible();
+ recentTabsCount = count;
+ final boolean folderVisible = isRecentTabsFolderVisible();
+
+ if (prevFolderVisibility == folderVisible) {
+ if (prevFolderVisibility) {
+ notifyItemChanged(RECENT_TABS_SMARTFOLDER_INDEX);
+ }
+ return;
+ }
+
+ // If the Recent Tabs smart folder has become hidden/unhidden,
+ // we need to recalculate the history section header positions.
+ populateSectionHeaders(historyCursor, sectionHeaders);
+
+ if (folderVisible) {
+ int scrollPos = -1;
+ if (linearLayoutManager != null) {
+ scrollPos = linearLayoutManager.findFirstCompletelyVisibleItemPosition();
+ }
+
+ notifyItemInserted(RECENT_TABS_SMARTFOLDER_INDEX);
+ // If the list exceeds the display height and we want to show the new
+ // item inserted at position 0, we need to scroll up manually
+ // (see https://code.google.com/p/android/issues/detail?id=174227#c2).
+ // However we only do this if our current scroll position is at the
+ // top of the list.
+ if (linearLayoutManager != null && scrollPos == 0) {
+ linearLayoutManager.scrollToPosition(0);
+ }
+ } else {
+ notifyItemRemoved(RECENT_TABS_SMARTFOLDER_INDEX);
+ }
+
+ if (countReliable && panelStateChangeListener != null) {
+ panelStateChangeListener.setCachedRecentTabsCount(recentTabsCount);
+ }
+ }
+ });
+ }
+ };
+ return recentTabsUpdateHandler;
+ }
+
+ @UiThread
+ private boolean isRecentTabsFolderVisible() {
+ return recentTabsCount > 0;
+ }
+
+ @UiThread
+ // Number of smart folders for determining practical empty state.
+ public int getNumVisibleSmartFolders() {
+ int visibleFolders = 1; // Synced devices folder is always visible.
+
+ if (isRecentTabsFolderVisible()) {
+ visibleFolders += 1;
+ }
+
+ return visibleFolders;
+ }
+
+ @UiThread
+ private int getSyncedDevicesSmartFolderIndex() {
+ return isRecentTabsFolderVisible() ?
+ RECENT_TABS_SMARTFOLDER_INDEX + 1 :
+ RECENT_TABS_SMARTFOLDER_INDEX;
+ }
+
+ @Override
+ public CombinedHistoryItem onCreateViewHolder(ViewGroup viewGroup, int viewType) {
+ final LayoutInflater inflater = LayoutInflater.from(viewGroup.getContext());
+ final View view;
+
+ final CombinedHistoryItem.ItemType itemType = CombinedHistoryItem.ItemType.viewTypeToItemType(viewType);
+
+ switch (itemType) {
+ case RECENT_TABS:
+ case SYNCED_DEVICES:
+ view = inflater.inflate(R.layout.home_smartfolder, viewGroup, false);
+ return new CombinedHistoryItem.SmartFolder(view);
+
+ case SECTION_HEADER:
+ view = inflater.inflate(R.layout.home_header_row, viewGroup, false);
+ return new CombinedHistoryItem.BasicItem(view);
+
+ case HISTORY:
+ view = inflater.inflate(R.layout.home_item_row, viewGroup, false);
+ return new CombinedHistoryItem.HistoryItem(view);
+ default:
+ throw new IllegalArgumentException("Unexpected Home Panel item type");
+ }
+ }
+
+ @Override
+ public void onBindViewHolder(CombinedHistoryItem viewHolder, int position) {
+ final CombinedHistoryItem.ItemType itemType = getItemTypeForPosition(position);
+ final int localPosition = transformAdapterPositionForDataStructure(itemType, position);
+
+ switch (itemType) {
+ case RECENT_TABS:
+ ((CombinedHistoryItem.SmartFolder) viewHolder).bind(R.drawable.icon_recent, R.string.home_closed_tabs_title2, R.string.home_closed_tabs_one, R.string.home_closed_tabs_number, recentTabsCount);
+ break;
+
+ case SYNCED_DEVICES:
+ ((CombinedHistoryItem.SmartFolder) viewHolder).bind(R.drawable.cloud, R.string.home_synced_devices_smartfolder, R.string.home_synced_devices_one, R.string.home_synced_devices_number, deviceCount);
+ break;
+
+ case SECTION_HEADER:
+ ((TextView) viewHolder.itemView).setText(getSectionHeaderTitle(sectionHeaders.get(localPosition)));
+ break;
+
+ case HISTORY:
+ if (historyCursor == null || !historyCursor.moveToPosition(localPosition)) {
+ throw new IllegalStateException("Couldn't move cursor to position " + localPosition);
+ }
+ ((CombinedHistoryItem.HistoryItem) viewHolder).bind(historyCursor);
+ break;
+ }
+ }
+
+ /**
+ * Transform an adapter position to the position for the data structure backing the item type.
+ *
+ * The type is not strictly necessary and could be fetched from <code>getItemTypeForPosition</code>,
+ * but is used for explicitness.
+ *
+ * @param type ItemType of the item
+ * @param position position in the adapter
+ * @return position of the item in the data structure
+ */
+ @UiThread
+ private int transformAdapterPositionForDataStructure(CombinedHistoryItem.ItemType type, int position) {
+ if (type == CombinedHistoryItem.ItemType.SECTION_HEADER) {
+ return position;
+ } else if (type == CombinedHistoryItem.ItemType.HISTORY) {
+ return position - getHeadersBefore(position) - getNumVisibleSmartFolders();
+ } else {
+ return position;
+ }
+ }
+
+ @UiThread
+ private CombinedHistoryItem.ItemType getItemTypeForPosition(int position) {
+ if (position == RECENT_TABS_SMARTFOLDER_INDEX && isRecentTabsFolderVisible()) {
+ return CombinedHistoryItem.ItemType.RECENT_TABS;
+ }
+ if (position == getSyncedDevicesSmartFolderIndex()) {
+ return CombinedHistoryItem.ItemType.SYNCED_DEVICES;
+ }
+ final int sectionPosition = transformAdapterPositionForDataStructure(CombinedHistoryItem.ItemType.SECTION_HEADER, position);
+ if (sectionHeaders.get(sectionPosition) != null) {
+ return CombinedHistoryItem.ItemType.SECTION_HEADER;
+ }
+ return CombinedHistoryItem.ItemType.HISTORY;
+ }
+
+ @UiThread
+ @Override
+ public int getItemViewType(int position) {
+ return CombinedHistoryItem.ItemType.itemTypeToViewType(getItemTypeForPosition(position));
+ }
+
+ @UiThread
+ @Override
+ public int getItemCount() {
+ final int historySize = historyCursor == null ? 0 : historyCursor.getCount();
+ return historySize + sectionHeaders.size() + getNumVisibleSmartFolders();
+ }
+
+ /**
+ * Returns stable ID for each position. Data behind historyCursor is a sorted Combined view.
+ *
+ * @param position view item position for which to generate a stable ID
+ * @return stable ID for given position
+ */
+ @UiThread
+ @Override
+ public long getItemId(int position) {
+ // Two randomly selected large primes used to generate non-clashing IDs.
+ final long PRIME_BOOKMARKS = 32416189867L;
+ final long PRIME_SECTION_HEADERS = 32416187737L;
+
+ // RecyclerView.NO_ID is -1, so let's start from -2 for our hard-coded IDs.
+ final int RECENT_TABS_ID = -2;
+ final int SYNCED_DEVICES_ID = -3;
+
+ switch (getItemTypeForPosition(position)) {
+ case RECENT_TABS:
+ return RECENT_TABS_ID;
+ case SYNCED_DEVICES:
+ return SYNCED_DEVICES_ID;
+ case SECTION_HEADER:
+ // We might have multiple section headers, so we try get unique IDs for them.
+ return position * PRIME_SECTION_HEADERS;
+ case HISTORY:
+ final int historyPosition = transformAdapterPositionForDataStructure(
+ CombinedHistoryItem.ItemType.HISTORY, position);
+ if (!historyCursor.moveToPosition(historyPosition)) {
+ return RecyclerView.NO_ID;
+ }
+
+ final int historyIdCol = historyCursor.getColumnIndexOrThrow(BrowserContract.Combined.HISTORY_ID);
+ final long historyId = historyCursor.getLong(historyIdCol);
+
+ if (historyId != -1) {
+ return historyId;
+ }
+
+ final int bookmarkIdCol = historyCursor.getColumnIndexOrThrow(BrowserContract.Combined.BOOKMARK_ID);
+ final long bookmarkId = historyCursor.getLong(bookmarkIdCol);
+
+ // Avoid clashing with historyId.
+ return bookmarkId * PRIME_BOOKMARKS;
+ default:
+ throw new IllegalStateException("Unexpected Home Panel item type");
+ }
+ }
+
+ /**
+ * Add only the SectionHeaders that have history items within their range to a SparseArray, where the
+ * array index is the position of the header in the history-only (no clients) ordering.
+ * @param c data Cursor
+ * @param sparseArray SparseArray to populate
+ */
+ @UiThread
+ private void populateSectionHeaders(Cursor c, SparseArray<SectionHeader> sparseArray) {
+ ThreadUtils.assertOnUiThread();
+
+ sparseArray.clear();
+
+ if (c == null || !c.moveToFirst()) {
+ return;
+ }
+
+ SectionHeader section = null;
+
+ do {
+ final int historyPosition = c.getPosition();
+ final long visitTime = c.getLong(c.getColumnIndexOrThrow(BrowserContract.History.DATE_LAST_VISITED));
+ final SectionHeader itemSection = getSectionFromTime(visitTime);
+
+ if (section != itemSection) {
+ section = itemSection;
+ sparseArray.append(historyPosition + sparseArray.size() + getNumVisibleSmartFolders(), section);
+ }
+
+ if (section == SectionHeader.OLDER_THAN_SIX_MONTHS) {
+ break;
+ }
+ } while (c.moveToNext());
+ }
+
+ private static String getSectionHeaderTitle(SectionHeader section) {
+ return sectionDateRangeArray[section.ordinal()].displayName;
+ }
+
+ private static SectionHeader getSectionFromTime(long time) {
+ for (int i = 0; i < SectionHeader.OLDER_THAN_SIX_MONTHS.ordinal(); i++) {
+ if (time > sectionDateRangeArray[i].start) {
+ return SectionHeader.values()[i];
+ }
+ }
+
+ return SectionHeader.OLDER_THAN_SIX_MONTHS;
+ }
+
+ /**
+ * Returns the number of section headers before the given history item at the adapter position.
+ * @param position position in the adapter
+ */
+ private int getHeadersBefore(int position) {
+ // Skip the first header case because there will always be a header.
+ for (int i = 1; i < sectionHeaders.size(); i++) {
+ // If the position of the header is greater than the history position,
+ // return the number of headers tested.
+ if (sectionHeaders.keyAt(i) > position) {
+ return i;
+ }
+ }
+ return sectionHeaders.size();
+ }
+
+ @Override
+ public HomeContextMenuInfo makeContextMenuInfoFromPosition(View view, int position) {
+ final CombinedHistoryItem.ItemType itemType = getItemTypeForPosition(position);
+ if (itemType == CombinedHistoryItem.ItemType.HISTORY) {
+ final HomeContextMenuInfo info = new HomeContextMenuInfo(view, position, -1);
+
+ historyCursor.moveToPosition(transformAdapterPositionForDataStructure(CombinedHistoryItem.ItemType.HISTORY, position));
+ return populateHistoryInfoFromCursor(info, historyCursor);
+ }
+ return null;
+ }
+
+ protected static HomeContextMenuInfo populateHistoryInfoFromCursor(HomeContextMenuInfo info, Cursor cursor) {
+ info.url = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.URL));
+ info.title = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.TITLE));
+ info.historyId = cursor.getInt(cursor.getColumnIndexOrThrow(BrowserContract.Combined.HISTORY_ID));
+ info.itemType = HomeContextMenuInfo.RemoveItemType.HISTORY;
+ final int bookmarkIdCol = cursor.getColumnIndexOrThrow(BrowserContract.Combined.BOOKMARK_ID);
+ if (cursor.isNull(bookmarkIdCol)) {
+ // If this is a combined cursor, we may get a history item without a
+ // bookmark, in which case the bookmarks ID column value will be null.
+ info.bookmarkId = -1;
+ } else {
+ info.bookmarkId = cursor.getInt(bookmarkIdCol);
+ }
+ return info;
+ }
+
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryItem.java b/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryItem.java
new file mode 100644
index 000000000..a2c1b72c2
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryItem.java
@@ -0,0 +1,127 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.support.v4.content.ContextCompat;
+import android.support.v7.widget.RecyclerView;
+import android.util.Log;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.TextView;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.RemoteClient;
+import org.mozilla.gecko.db.RemoteTab;
+import org.mozilla.gecko.home.RecentTabsAdapter.ClosedTab;
+
+public abstract class CombinedHistoryItem extends RecyclerView.ViewHolder {
+ private static final String LOGTAG = "CombinedHistoryItem";
+
+ public CombinedHistoryItem(View view) {
+ super(view);
+ }
+
+ public enum ItemType {
+ CLIENT, HIDDEN_DEVICES, SECTION_HEADER, HISTORY, NAVIGATION_BACK, CHILD, SYNCED_DEVICES,
+ RECENT_TABS, CLOSED_TAB;
+
+ public static ItemType viewTypeToItemType(int viewType) {
+ if (viewType >= ItemType.values().length) {
+ Log.e(LOGTAG, "No corresponding ItemType!");
+ }
+ return ItemType.values()[viewType];
+ }
+
+ public static int itemTypeToViewType(ItemType itemType) {
+ return itemType.ordinal();
+ }
+ }
+
+ public static class BasicItem extends CombinedHistoryItem {
+ public BasicItem(View view) {
+ super(view);
+ }
+ }
+
+ public static class SmartFolder extends CombinedHistoryItem {
+ final Context context;
+ final ImageView icon;
+ final TextView title;
+ final TextView subtext;
+
+ public SmartFolder(View view) {
+ super(view);
+ context = view.getContext();
+
+ icon = (ImageView) view.findViewById(R.id.device_type);
+ title = (TextView) view.findViewById(R.id.title);
+ subtext = (TextView) view.findViewById(R.id.subtext);
+ }
+
+ public void bind(int drawableRes, int titleRes, int singleDeviceRes, int multiDeviceRes, int numDevices) {
+ icon.setImageResource(drawableRes);
+ title.setText(titleRes);
+ final String subtitle = numDevices == 1 ? context.getString(singleDeviceRes) : context.getString(multiDeviceRes, numDevices);
+ subtext.setText(subtitle);
+ }
+ }
+
+ public static class HistoryItem extends CombinedHistoryItem {
+ public HistoryItem(View view) {
+ super(view);
+ }
+
+ public void bind(Cursor historyCursor) {
+ final TwoLinePageRow pageRow = (TwoLinePageRow) this.itemView;
+ pageRow.setShowIcons(true);
+ pageRow.updateFromCursor(historyCursor);
+ }
+
+ public void bind(RemoteTab remoteTab) {
+ final TwoLinePageRow childPageRow = (TwoLinePageRow) this.itemView;
+ childPageRow.setShowIcons(true);
+ childPageRow.update(remoteTab.title, remoteTab.url);
+ }
+
+ public void bind(ClosedTab closedTab) {
+ final TwoLinePageRow childPageRow = (TwoLinePageRow) this.itemView;
+ childPageRow.setShowIcons(false);
+ childPageRow.update(closedTab.title, closedTab.url);
+ }
+ }
+
+ public static class ClientItem extends CombinedHistoryItem {
+ final TextView nameView;
+ final ImageView deviceTypeView;
+ final TextView lastModifiedView;
+ final ImageView deviceExpanded;
+
+ public ClientItem(View view) {
+ super(view);
+ nameView = (TextView) view.findViewById(R.id.client);
+ deviceTypeView = (ImageView) view.findViewById(R.id.device_type);
+ lastModifiedView = (TextView) view.findViewById(R.id.last_synced);
+ deviceExpanded = (ImageView) view.findViewById(R.id.device_expanded);
+ }
+
+ public void bind(Context context, RemoteClient client, boolean isCollapsed) {
+ this.nameView.setText(client.name);
+ final long now = System.currentTimeMillis();
+ this.lastModifiedView.setText(ClientsAdapter.getLastSyncedString(context, now, client.lastModified));
+
+ if (client.isDesktop()) {
+ deviceTypeView.setImageResource(isCollapsed ? R.drawable.sync_desktop_inactive : R.drawable.sync_desktop);
+ } else {
+ deviceTypeView.setImageResource(isCollapsed ? R.drawable.sync_mobile_inactive : R.drawable.sync_mobile);
+ }
+
+ nameView.setTextColor(ContextCompat.getColor(context, isCollapsed ? R.color.tabs_tray_icon_grey : R.color.placeholder_active_grey));
+ if (client.tabs.size() > 0) {
+ deviceExpanded.setImageResource(isCollapsed ? R.drawable.home_group_collapsed : R.drawable.arrow_down);
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryPanel.java b/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryPanel.java
new file mode 100644
index 000000000..c9afecd63
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryPanel.java
@@ -0,0 +1,697 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import android.accounts.Account;
+import android.app.AlertDialog;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.database.Cursor;
+import android.os.Bundle;
+import android.support.annotation.UiThread;
+import android.support.v4.app.LoaderManager;
+import android.support.v4.content.Loader;
+import android.support.v4.widget.SwipeRefreshLayout;
+import android.support.v7.widget.DefaultItemAnimator;
+import android.support.v7.widget.LinearLayoutManager;
+import android.support.v7.widget.RecyclerView;
+import android.text.SpannableStringBuilder;
+import android.text.TextPaint;
+import android.text.method.LinkMovementMethod;
+import android.text.style.ClickableSpan;
+import android.text.style.UnderlineSpan;
+import android.util.Log;
+import android.view.ContextMenu;
+import android.view.LayoutInflater;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.ImageView;
+import android.widget.TextView;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoApp;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.RemoteClientsDialogFragment;
+import org.mozilla.gecko.fxa.FirefoxAccounts;
+import org.mozilla.gecko.fxa.FxAccountConstants;
+import org.mozilla.gecko.fxa.SyncStatusListener;
+import org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener.PanelLevel;
+import org.mozilla.gecko.restrictions.Restrictions;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.RemoteClient;
+import org.mozilla.gecko.restrictions.Restrictable;
+import org.mozilla.gecko.widget.HistoryDividerItemDecoration;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import static org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener.PanelLevel.PARENT;
+import static org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener.PanelLevel.CHILD_SYNC;
+import static org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener.PanelLevel.CHILD_RECENT_TABS;
+
+public class CombinedHistoryPanel extends HomeFragment implements RemoteClientsDialogFragment.RemoteClientsListener {
+ private static final String LOGTAG = "GeckoCombinedHistoryPnl";
+
+ private static final String[] STAGES_TO_SYNC_ON_REFRESH = new String[] { "clients", "tabs" };
+ private final int LOADER_ID_HISTORY = 0;
+ private final int LOADER_ID_REMOTE = 1;
+
+ // String placeholders to mark formatting.
+ private final static String FORMAT_S1 = "%1$s";
+ private final static String FORMAT_S2 = "%2$s";
+
+ private CombinedHistoryRecyclerView mRecyclerView;
+ private CombinedHistoryAdapter mHistoryAdapter;
+ private ClientsAdapter mClientsAdapter;
+ private RecentTabsAdapter mRecentTabsAdapter;
+ private CursorLoaderCallbacks mCursorLoaderCallbacks;
+
+ private Bundle mSavedRestoreBundle;
+
+ private PanelLevel mPanelLevel;
+ private Button mPanelFooterButton;
+
+ private PanelStateUpdateHandler mPanelStateUpdateHandler;
+
+ // Child refresh layout view.
+ protected SwipeRefreshLayout mRefreshLayout;
+
+ // Sync listener that stops refreshing when a sync is completed.
+ protected RemoteTabsSyncListener mSyncStatusListener;
+
+ // Reference to the View to display when there are no results.
+ private View mHistoryEmptyView;
+ private View mClientsEmptyView;
+ private View mRecentTabsEmptyView;
+
+ public interface OnPanelLevelChangeListener {
+ enum PanelLevel {
+ PARENT, CHILD_SYNC, CHILD_RECENT_TABS
+ }
+
+ /**
+ * Propagates level changes.
+ * @param level
+ * @return true if level changed, false otherwise.
+ */
+ boolean changeLevel(PanelLevel level);
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstance) {
+ super.onCreate(savedInstance);
+
+ int cachedRecentTabsCount = 0;
+ if (mPanelStateChangeListener != null ) {
+ cachedRecentTabsCount = mPanelStateChangeListener.getCachedRecentTabsCount();
+ }
+ mHistoryAdapter = new CombinedHistoryAdapter(getResources(), cachedRecentTabsCount);
+ if (mPanelStateChangeListener != null) {
+ mHistoryAdapter.setPanelStateChangeListener(mPanelStateChangeListener);
+ }
+
+ mClientsAdapter = new ClientsAdapter(getContext());
+ // The RecentTabsAdapter doesn't use a cursor and therefore can't use the CursorLoader's
+ // onLoadFinished() callback for updating the panel state when the closed tab count changes.
+ // Instead, we provide it with independent callbacks as necessary.
+ mRecentTabsAdapter = new RecentTabsAdapter(getContext(),
+ mHistoryAdapter.getRecentTabsUpdateHandler(), getPanelStateUpdateHandler());
+
+ mSyncStatusListener = new RemoteTabsSyncListener();
+ FirefoxAccounts.addSyncStatusListener(mSyncStatusListener);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ return inflater.inflate(R.layout.home_combined_history_panel, container, false);
+ }
+
+ @UiThread
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ mRecyclerView = (CombinedHistoryRecyclerView) view.findViewById(R.id.combined_recycler_view);
+ setUpRecyclerView();
+
+ mRefreshLayout = (SwipeRefreshLayout) view.findViewById(R.id.refresh_layout);
+ setUpRefreshLayout();
+
+ mClientsEmptyView = view.findViewById(R.id.home_clients_empty_view);
+ mHistoryEmptyView = view.findViewById(R.id.home_history_empty_view);
+ mRecentTabsEmptyView = view.findViewById(R.id.home_recent_tabs_empty_view);
+ setUpEmptyViews();
+
+ mPanelFooterButton = (Button) view.findViewById(R.id.history_panel_footer_button);
+ mPanelFooterButton.setText(R.string.home_clear_history_button);
+ mPanelFooterButton.setOnClickListener(new OnFooterButtonClickListener());
+
+ mRecentTabsAdapter.startListeningForClosedTabs();
+ mRecentTabsAdapter.startListeningForHistorySanitize();
+
+ if (mSavedRestoreBundle != null) {
+ setPanelStateFromBundle(mSavedRestoreBundle);
+ mSavedRestoreBundle = null;
+ }
+ }
+
+ @UiThread
+ private void setUpRecyclerView() {
+ if (mPanelLevel == null) {
+ mPanelLevel = PARENT;
+ }
+
+ mRecyclerView.setAdapter(mPanelLevel == PARENT ? mHistoryAdapter :
+ mPanelLevel == CHILD_SYNC ? mClientsAdapter : mRecentTabsAdapter);
+
+ final RecyclerView.ItemAnimator animator = new DefaultItemAnimator();
+ animator.setAddDuration(100);
+ animator.setChangeDuration(100);
+ animator.setMoveDuration(100);
+ animator.setRemoveDuration(100);
+ mRecyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
+ mHistoryAdapter.setLinearLayoutManager((LinearLayoutManager) mRecyclerView.getLayoutManager());
+ mRecyclerView.setItemAnimator(animator);
+ mRecyclerView.addItemDecoration(new HistoryDividerItemDecoration(getContext()));
+ mRecyclerView.setOnHistoryClickedListener(mUrlOpenListener);
+ mRecyclerView.setOnPanelLevelChangeListener(new OnLevelChangeListener());
+ mRecyclerView.setHiddenClientsDialogBuilder(new HiddenClientsHelper());
+ mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
+ @Override
+ public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
+ super.onScrolled(recyclerView, dx, dy);
+ final LinearLayoutManager llm = (LinearLayoutManager) recyclerView.getLayoutManager();
+ if ((mPanelLevel == PARENT) && (llm.findLastCompletelyVisibleItemPosition() == HistoryCursorLoader.HISTORY_LIMIT)) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.LIST, "history_scroll_max");
+ }
+
+ }
+ });
+ registerForContextMenu(mRecyclerView);
+ }
+
+ private void setUpRefreshLayout() {
+ mRefreshLayout.setColorSchemeResources(R.color.fennec_ui_orange, R.color.action_orange);
+ mRefreshLayout.setOnRefreshListener(new RemoteTabsRefreshListener());
+ mRefreshLayout.setEnabled(false);
+ }
+
+ private void setUpEmptyViews() {
+ // Set up history empty view.
+ final ImageView historyIcon = (ImageView) mHistoryEmptyView.findViewById(R.id.home_empty_image);
+ historyIcon.setVisibility(View.GONE);
+
+ final TextView historyText = (TextView) mHistoryEmptyView.findViewById(R.id.home_empty_text);
+ historyText.setText(R.string.home_most_recent_empty);
+
+ final TextView historyHint = (TextView) mHistoryEmptyView.findViewById(R.id.home_empty_hint);
+
+ if (!Restrictions.isAllowed(getActivity(), Restrictable.PRIVATE_BROWSING)) {
+ historyHint.setVisibility(View.GONE);
+ } else {
+ final String hintText = getResources().getString(R.string.home_most_recent_emptyhint);
+ final SpannableStringBuilder hintBuilder = formatHintText(hintText);
+ if (hintBuilder != null) {
+ historyHint.setText(hintBuilder);
+ historyHint.setMovementMethod(LinkMovementMethod.getInstance());
+ historyHint.setVisibility(View.VISIBLE);
+ }
+ }
+
+ // Set up Clients empty view.
+ final Button syncSetupButton = (Button) mClientsEmptyView.findViewById(R.id.sync_setup_button);
+ syncSetupButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, "history_syncsetup");
+ // This Activity will redirect to the correct Activity as needed.
+ final Intent intent = new Intent(FxAccountConstants.ACTION_FXA_GET_STARTED);
+ startActivity(intent);
+ }
+ });
+
+ // Set up Recent Tabs empty view.
+ final ImageView recentTabsIcon = (ImageView) mRecentTabsEmptyView.findViewById(R.id.home_empty_image);
+ recentTabsIcon.setImageResource(R.drawable.icon_remote_tabs_empty);
+
+ final TextView recentTabsText = (TextView) mRecentTabsEmptyView.findViewById(R.id.home_empty_text);
+ recentTabsText.setText(R.string.home_last_tabs_empty);
+ }
+
+ @Override
+ public void setPanelStateChangeListener(
+ PanelStateChangeListener panelStateChangeListener) {
+ super.setPanelStateChangeListener(panelStateChangeListener);
+ if (mHistoryAdapter != null) {
+ mHistoryAdapter.setPanelStateChangeListener(panelStateChangeListener);
+ }
+ }
+
+ @Override
+ public void restoreData(Bundle data) {
+ if (mRecyclerView != null) {
+ setPanelStateFromBundle(data);
+ } else {
+ mSavedRestoreBundle = data;
+ }
+ }
+
+ private void setPanelStateFromBundle(Bundle data) {
+ if (data != null && data.getBoolean("goToRecentTabs", false) && mPanelLevel != CHILD_RECENT_TABS) {
+ mPanelLevel = CHILD_RECENT_TABS;
+ mRecyclerView.swapAdapter(mRecentTabsAdapter, true);
+ updateEmptyView(CHILD_RECENT_TABS);
+ updateButtonFromLevel();
+ }
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+ mCursorLoaderCallbacks = new CursorLoaderCallbacks();
+ }
+
+ @Override
+ protected void load() {
+ getLoaderManager().initLoader(LOADER_ID_HISTORY, null, mCursorLoaderCallbacks);
+ getLoaderManager().initLoader(LOADER_ID_REMOTE, null, mCursorLoaderCallbacks);
+ }
+
+ private static class RemoteTabsCursorLoader extends SimpleCursorLoader {
+ private final GeckoProfile mProfile;
+
+ public RemoteTabsCursorLoader(Context context) {
+ super(context);
+ mProfile = GeckoProfile.get(context);
+ }
+
+ @Override
+ public Cursor loadCursor() {
+ return BrowserDB.from(mProfile).getTabsAccessor().getRemoteTabsCursor(getContext());
+ }
+ }
+
+ private static class HistoryCursorLoader extends SimpleCursorLoader {
+ // Max number of history results
+ public static final int HISTORY_LIMIT = 100;
+ private final BrowserDB mDB;
+
+ public HistoryCursorLoader(Context context) {
+ super(context);
+ mDB = BrowserDB.from(context);
+ }
+
+ @Override
+ public Cursor loadCursor() {
+ final ContentResolver cr = getContext().getContentResolver();
+ return mDB.getRecentHistory(cr, HISTORY_LIMIT);
+ }
+ }
+
+ private class CursorLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> {
+ private BrowserDB mDB; // Pseudo-final: set in onCreateLoader.
+
+ @Override
+ public Loader<Cursor> onCreateLoader(int id, Bundle args) {
+ if (mDB == null) {
+ mDB = BrowserDB.from(getActivity());
+ }
+
+ switch (id) {
+ case LOADER_ID_HISTORY:
+ return new HistoryCursorLoader(getContext());
+ case LOADER_ID_REMOTE:
+ return new RemoteTabsCursorLoader(getContext());
+ default:
+ Log.e(LOGTAG, "Unknown loader id!");
+ return null;
+ }
+ }
+
+ @Override
+ public void onLoadFinished(Loader<Cursor> loader, Cursor c) {
+ final int loaderId = loader.getId();
+ switch (loaderId) {
+ case LOADER_ID_HISTORY:
+ mHistoryAdapter.setHistory(c);
+ updateEmptyView(PARENT);
+ break;
+
+ case LOADER_ID_REMOTE:
+ final List<RemoteClient> clients = mDB.getTabsAccessor().getClientsFromCursor(c);
+ mHistoryAdapter.getDeviceUpdateHandler().onDeviceCountUpdated(clients.size());
+ mClientsAdapter.setClients(clients);
+ updateEmptyView(CHILD_SYNC);
+ break;
+ }
+
+ updateButtonFromLevel();
+ }
+
+ @Override
+ public void onLoaderReset(Loader<Cursor> loader) {
+ mClientsAdapter.setClients(Collections.<RemoteClient>emptyList());
+ mHistoryAdapter.setHistory(null);
+ }
+ }
+
+ public interface PanelStateUpdateHandler {
+ void onPanelStateUpdated(PanelLevel level);
+ }
+
+ public PanelStateUpdateHandler getPanelStateUpdateHandler() {
+ if (mPanelStateUpdateHandler == null) {
+ mPanelStateUpdateHandler = new PanelStateUpdateHandler() {
+ @Override
+ public void onPanelStateUpdated(PanelLevel level) {
+ updateEmptyView(level);
+ updateButtonFromLevel();
+ }
+ };
+ }
+ return mPanelStateUpdateHandler;
+ }
+
+ protected class OnLevelChangeListener implements OnPanelLevelChangeListener {
+ @Override
+ public boolean changeLevel(PanelLevel level) {
+ if (level == mPanelLevel) {
+ return false;
+ }
+
+ mPanelLevel = level;
+ switch (level) {
+ case PARENT:
+ mRecyclerView.swapAdapter(mHistoryAdapter, true);
+ mRefreshLayout.setEnabled(false);
+ break;
+ case CHILD_SYNC:
+ mRecyclerView.swapAdapter(mClientsAdapter, true);
+ mRefreshLayout.setEnabled(mClientsAdapter.getClientsCount() > 0);
+ break;
+ case CHILD_RECENT_TABS:
+ mRecyclerView.swapAdapter(mRecentTabsAdapter, true);
+ break;
+ }
+
+ updateEmptyView(level);
+ updateButtonFromLevel();
+ return true;
+ }
+ }
+
+ private void updateButtonFromLevel() {
+ switch (mPanelLevel) {
+ case PARENT:
+ final boolean historyRestricted = !Restrictions.isAllowed(getActivity(), Restrictable.CLEAR_HISTORY);
+ if (historyRestricted || mHistoryAdapter.getItemCount() == mHistoryAdapter.getNumVisibleSmartFolders()) {
+ mPanelFooterButton.setVisibility(View.GONE);
+ } else {
+ mPanelFooterButton.setText(R.string.home_clear_history_button);
+ mPanelFooterButton.setVisibility(View.VISIBLE);
+ }
+ break;
+ case CHILD_RECENT_TABS:
+ if (mRecentTabsAdapter.getClosedTabsCount() > 1) {
+ mPanelFooterButton.setText(R.string.home_restore_all);
+ mPanelFooterButton.setVisibility(View.VISIBLE);
+ } else {
+ mPanelFooterButton.setVisibility(View.GONE);
+ }
+ break;
+ case CHILD_SYNC:
+ mPanelFooterButton.setVisibility(View.GONE);
+ break;
+ }
+ }
+
+ private class OnFooterButtonClickListener implements View.OnClickListener {
+ @Override
+ public void onClick(View view) {
+ switch (mPanelLevel) {
+ case PARENT:
+ final AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(getActivity());
+ dialogBuilder.setMessage(R.string.home_clear_history_confirm);
+ dialogBuilder.setNegativeButton(R.string.button_cancel, new AlertDialog.OnClickListener() {
+ @Override
+ public void onClick(final DialogInterface dialog, final int which) {
+ dialog.dismiss();
+ }
+ });
+
+ dialogBuilder.setPositiveButton(R.string.button_ok, new AlertDialog.OnClickListener() {
+ @Override
+ public void onClick(final DialogInterface dialog, final int which) {
+ dialog.dismiss();
+
+ // Send message to Java to clear history.
+ final JSONObject json = new JSONObject();
+ try {
+ json.put("history", true);
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "JSON error", e);
+ }
+
+ GeckoAppShell.notifyObservers("Sanitize:ClearData", json.toString());
+ Telemetry.sendUIEvent(TelemetryContract.Event.SANITIZE, TelemetryContract.Method.BUTTON, "history");
+ }
+ });
+
+ dialogBuilder.show();
+ break;
+ case CHILD_RECENT_TABS:
+ final String telemetryExtra = mRecentTabsAdapter.restoreAllTabs();
+ if (telemetryExtra != null) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.BUTTON, telemetryExtra);
+ }
+ break;
+ }
+ }
+ }
+
+ private void updateEmptyView(PanelLevel level) {
+ boolean showEmptyHistoryView = (mPanelLevel == PARENT && mHistoryEmptyView.isShown());
+ boolean showEmptyClientsView = (mPanelLevel == CHILD_SYNC && mClientsEmptyView.isShown());
+ boolean showEmptyRecentTabsView = (mPanelLevel == CHILD_RECENT_TABS && mRecentTabsEmptyView.isShown());
+
+ if (mPanelLevel == level) {
+ switch (mPanelLevel) {
+ case PARENT:
+ showEmptyHistoryView = mHistoryAdapter.getItemCount() == mHistoryAdapter.getNumVisibleSmartFolders();
+ break;
+
+ case CHILD_SYNC:
+ showEmptyClientsView = mClientsAdapter.getItemCount() == 1;
+ break;
+
+ case CHILD_RECENT_TABS:
+ showEmptyRecentTabsView = mRecentTabsAdapter.getClosedTabsCount() == 0;
+ break;
+ }
+ }
+
+ final boolean showEmptyView = showEmptyClientsView || showEmptyHistoryView || showEmptyRecentTabsView;
+ mRecyclerView.setOverScrollMode(showEmptyView ? View.OVER_SCROLL_NEVER : View.OVER_SCROLL_IF_CONTENT_SCROLLS);
+
+ mHistoryEmptyView.setVisibility(showEmptyHistoryView ? View.VISIBLE : View.GONE);
+ mClientsEmptyView.setVisibility(showEmptyClientsView ? View.VISIBLE : View.GONE);
+ mRecentTabsEmptyView.setVisibility(showEmptyRecentTabsView ? View.VISIBLE : View.GONE);
+ }
+
+ /**
+ * Make Span that is clickable, and underlined
+ * between the string markers <code>FORMAT_S1</code> and
+ * <code>FORMAT_S2</code>.
+ *
+ * @param text String to format
+ * @return formatted SpannableStringBuilder, or null if there
+ * is not any text to format.
+ */
+ private SpannableStringBuilder formatHintText(String text) {
+ // Set formatting as marked by string placeholders.
+ final int underlineStart = text.indexOf(FORMAT_S1);
+ final int underlineEnd = text.indexOf(FORMAT_S2);
+
+ // Check that there is text to be formatted.
+ if (underlineStart >= underlineEnd) {
+ return null;
+ }
+
+ final SpannableStringBuilder ssb = new SpannableStringBuilder(text);
+
+ // Set clickable text.
+ final ClickableSpan clickableSpan = new ClickableSpan() {
+ @Override
+ public void onClick(View widget) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, "hint_private_browsing");
+ try {
+ final JSONObject json = new JSONObject();
+ json.put("type", "Menu:Open");
+ GeckoApp.getEventDispatcher().dispatchEvent(json, null);
+ EventDispatcher.getInstance().dispatchEvent(json, null);
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error forming JSON for Private Browsing contextual hint", e);
+ }
+ }
+ };
+
+ ssb.setSpan(clickableSpan, 0, text.length(), 0);
+
+ // Remove underlining set by ClickableSpan.
+ final UnderlineSpan noUnderlineSpan = new UnderlineSpan() {
+ @Override
+ public void updateDrawState(TextPaint textPaint) {
+ textPaint.setUnderlineText(false);
+ }
+ };
+
+ ssb.setSpan(noUnderlineSpan, 0, text.length(), 0);
+
+ // Add underlining for "Private Browsing".
+ ssb.setSpan(new UnderlineSpan(), underlineStart, underlineEnd, 0);
+
+ ssb.delete(underlineEnd, underlineEnd + FORMAT_S2.length());
+ ssb.delete(underlineStart, underlineStart + FORMAT_S1.length());
+
+ return ssb;
+ }
+
+ @Override
+ public void onCreateContextMenu(ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo) {
+ if (!(menuInfo instanceof RemoteTabsClientContextMenuInfo)) {
+ // Long pressed item was not a RemoteTabsGroup item. Superclass
+ // can handle this.
+ super.onCreateContextMenu(menu, view, menuInfo);
+ return;
+ }
+
+ // Long pressed item was a remote client; provide the appropriate menu.
+ final MenuInflater inflater = new MenuInflater(view.getContext());
+ inflater.inflate(R.menu.home_remote_tabs_client_contextmenu, menu);
+
+ final RemoteTabsClientContextMenuInfo info = (RemoteTabsClientContextMenuInfo) menuInfo;
+ menu.setHeaderTitle(info.client.name);
+ }
+
+ @Override
+ public boolean onContextItemSelected(MenuItem item) {
+ if (super.onContextItemSelected(item)) {
+ // HomeFragment was able to handle to selected item.
+ return true;
+ }
+
+ final ContextMenu.ContextMenuInfo menuInfo = item.getMenuInfo();
+ if (!(menuInfo instanceof RemoteTabsClientContextMenuInfo)) {
+ return false;
+ }
+
+ final RemoteTabsClientContextMenuInfo info = (RemoteTabsClientContextMenuInfo) menuInfo;
+
+ final int itemId = item.getItemId();
+ if (itemId == R.id.home_remote_tabs_hide_client) {
+ mClientsAdapter.removeItem(info.position);
+ return true;
+ }
+
+ return false;
+ }
+
+ interface DialogBuilder<E> {
+ void createAndShowDialog(List<E> items);
+ }
+
+ protected class HiddenClientsHelper implements DialogBuilder<RemoteClient> {
+ @Override
+ public void createAndShowDialog(List<RemoteClient> clientsList) {
+ final RemoteClientsDialogFragment dialog = RemoteClientsDialogFragment.newInstance(
+ getResources().getString(R.string.home_remote_tabs_hidden_devices_title),
+ getResources().getString(R.string.home_remote_tabs_unhide_selected_devices),
+ RemoteClientsDialogFragment.ChoiceMode.MULTIPLE, new ArrayList<>(clientsList));
+ dialog.setTargetFragment(CombinedHistoryPanel.this, 0);
+ dialog.show(getActivity().getSupportFragmentManager(), "show-clients");
+ }
+ }
+
+ @Override
+ public void onClients(List<RemoteClient> clients) {
+ mClientsAdapter.unhideClients(clients);
+ }
+
+ /**
+ * Stores information regarding the creation of the context menu for a remote client.
+ */
+ protected static class RemoteTabsClientContextMenuInfo extends HomeContextMenuInfo {
+ protected final RemoteClient client;
+
+ public RemoteTabsClientContextMenuInfo(View targetView, int position, long id, RemoteClient client) {
+ super(targetView, position, id);
+ this.client = client;
+ }
+ }
+
+ protected class RemoteTabsRefreshListener implements SwipeRefreshLayout.OnRefreshListener {
+ @Override
+ public void onRefresh() {
+ if (FirefoxAccounts.firefoxAccountsExist(getActivity())) {
+ final Account account = FirefoxAccounts.getFirefoxAccount(getActivity());
+ FirefoxAccounts.requestImmediateSync(account, STAGES_TO_SYNC_ON_REFRESH, null);
+ } else {
+ Log.wtf(LOGTAG, "No Firefox Account found; this should never happen. Ignoring.");
+ mRefreshLayout.setRefreshing(false);
+ }
+ }
+ }
+
+ protected class RemoteTabsSyncListener implements SyncStatusListener {
+ @Override
+ public Context getContext() {
+ return getActivity();
+ }
+
+ @Override
+ public Account getAccount() {
+ return FirefoxAccounts.getFirefoxAccount(getContext());
+ }
+
+ @Override
+ public void onSyncStarted() {
+ }
+
+ @Override
+ public void onSyncFinished() {
+ mRefreshLayout.setRefreshing(false);
+ }
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+
+ mRecentTabsAdapter.stopListeningForClosedTabs();
+ mRecentTabsAdapter.stopListeningForHistorySanitize();
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+
+ if (mSyncStatusListener != null) {
+ FirefoxAccounts.removeSyncStatusListener(mSyncStatusListener);
+ mSyncStatusListener = null;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryRecyclerView.java b/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryRecyclerView.java
new file mode 100644
index 000000000..e813e4c44
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryRecyclerView.java
@@ -0,0 +1,145 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import android.content.Context;
+import android.support.v7.widget.LinearLayoutManager;
+import android.support.v7.widget.RecyclerView;
+import android.util.AttributeSet;
+import android.view.KeyEvent;
+import android.view.View;
+import org.mozilla.gecko.db.RemoteClient;
+import org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.widget.RecyclerViewClickSupport;
+
+import java.util.EnumSet;
+
+import static org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener.PanelLevel.CHILD_RECENT_TABS;
+import static org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener.PanelLevel.CHILD_SYNC;
+import static org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener.PanelLevel.PARENT;
+
+public class CombinedHistoryRecyclerView extends RecyclerView
+ implements RecyclerViewClickSupport.OnItemClickListener, RecyclerViewClickSupport.OnItemLongClickListener {
+ public static String LOGTAG = "CombinedHistoryRecycView";
+
+ protected interface AdapterContextMenuBuilder {
+ HomeContextMenuInfo makeContextMenuInfoFromPosition(View view, int position);
+ }
+
+ protected HomePager.OnUrlOpenListener mOnUrlOpenListener;
+ protected OnPanelLevelChangeListener mOnPanelLevelChangeListener;
+ protected CombinedHistoryPanel.DialogBuilder<RemoteClient> mDialogBuilder;
+ protected HomeContextMenuInfo mContextMenuInfo;
+
+ public CombinedHistoryRecyclerView(Context context) {
+ super(context);
+ init(context);
+ }
+
+ public CombinedHistoryRecyclerView(Context context, AttributeSet attributeSet) {
+ super(context, attributeSet);
+ init(context);
+ }
+
+ public CombinedHistoryRecyclerView(Context context, AttributeSet attributeSet, int defStyle) {
+ super(context, attributeSet, defStyle);
+ init(context);
+ }
+
+ private void init(Context context) {
+ LinearLayoutManager layoutManager = new LinearLayoutManager(context);
+ layoutManager.setOrientation(LinearLayoutManager.VERTICAL);
+ setLayoutManager(layoutManager);
+
+ RecyclerViewClickSupport.addTo(this)
+ .setOnItemClickListener(this)
+ .setOnItemLongClickListener(this);
+
+ setOnKeyListener(new View.OnKeyListener() {
+ @Override
+ public boolean onKey(View v, int keyCode, KeyEvent event) {
+ final int action = event.getAction();
+
+ // If the user hit the BACK key, try to move to the parent folder.
+ if (action == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) {
+ return mOnPanelLevelChangeListener.changeLevel(PARENT);
+ }
+ return false;
+ }
+ });
+ }
+
+ public void setOnHistoryClickedListener(HomePager.OnUrlOpenListener listener) {
+ this.mOnUrlOpenListener = listener;
+ }
+
+ public void setOnPanelLevelChangeListener(OnPanelLevelChangeListener listener) {
+ this.mOnPanelLevelChangeListener = listener;
+ }
+
+ public void setHiddenClientsDialogBuilder(CombinedHistoryPanel.DialogBuilder<RemoteClient> builder) {
+ mDialogBuilder = builder;
+ }
+
+ @Override
+ public void onItemClicked(RecyclerView recyclerView, int position, View v) {
+ final int viewType = getAdapter().getItemViewType(position);
+ final CombinedHistoryItem.ItemType itemType = CombinedHistoryItem.ItemType.viewTypeToItemType(viewType);
+ final String telemetryExtra;
+
+ switch (itemType) {
+ case RECENT_TABS:
+ mOnPanelLevelChangeListener.changeLevel(CHILD_RECENT_TABS);
+ break;
+
+ case SYNCED_DEVICES:
+ mOnPanelLevelChangeListener.changeLevel(CHILD_SYNC);
+ break;
+
+ case CLIENT:
+ ((ClientsAdapter) getAdapter()).toggleClient(position);
+ break;
+
+ case HIDDEN_DEVICES:
+ if (mDialogBuilder != null) {
+ mDialogBuilder.createAndShowDialog(((ClientsAdapter) getAdapter()).getHiddenClients());
+ }
+ break;
+
+ case NAVIGATION_BACK:
+ mOnPanelLevelChangeListener.changeLevel(PARENT);
+ break;
+
+ case CHILD:
+ case HISTORY:
+ if (mOnUrlOpenListener != null) {
+ final TwoLinePageRow historyItem = (TwoLinePageRow) v;
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.LIST_ITEM, "history");
+ mOnUrlOpenListener.onUrlOpen(historyItem.getUrl(), EnumSet.of(HomePager.OnUrlOpenListener.Flags.ALLOW_SWITCH_TO_TAB));
+ }
+ break;
+
+ case CLOSED_TAB:
+ telemetryExtra = ((RecentTabsAdapter) getAdapter()).restoreTabFromPosition(position);
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.LIST_ITEM, telemetryExtra);
+ break;
+ }
+ }
+
+ @Override
+ public boolean onItemLongClicked(RecyclerView recyclerView, int position, View v) {
+ mContextMenuInfo = ((AdapterContextMenuBuilder) getAdapter()).makeContextMenuInfoFromPosition(v, position);
+ return showContextMenuForChild(this);
+ }
+
+ @Override
+ public HomeContextMenuInfo getContextMenuInfo() {
+ return mContextMenuInfo;
+ }
+
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/DynamicPanel.java b/mobile/android/base/java/org/mozilla/gecko/home/DynamicPanel.java
new file mode 100644
index 000000000..d2c136219
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/DynamicPanel.java
@@ -0,0 +1,393 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserContract.HomeItems;
+import org.mozilla.gecko.home.HomeConfig.PanelConfig;
+import org.mozilla.gecko.home.PanelLayout.ContextMenuRegistry;
+import org.mozilla.gecko.home.PanelLayout.DatasetHandler;
+import org.mozilla.gecko.home.PanelLayout.DatasetRequest;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.util.UIAsyncTask;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.support.v4.app.LoaderManager;
+import android.support.v4.content.Loader;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+
+/**
+ * Fragment that displays dynamic content specified by a {@code PanelConfig}.
+ * The {@code DynamicPanel} UI is built based on the given {@code LayoutType}
+ * and its associated list of {@code ViewConfig}.
+ *
+ * {@code DynamicPanel} manages all necessary Loaders to load panel datasets
+ * from their respective content providers. Each panel dataset has its own
+ * associated Loader. This is enforced by defining the Loader IDs based on
+ * their associated dataset IDs.
+ *
+ * The {@code PanelLayout} can make load and reset requests on datasets via
+ * the provided {@code DatasetHandler}. This way it doesn't need to know the
+ * details of how datasets are loaded and reset. Each time a dataset is
+ * requested, {@code DynamicPanel} restarts a Loader with the respective ID (see
+ * {@code PanelDatasetHandler}).
+ *
+ * See {@code PanelLayout} for more details on how {@code DynamicPanel}
+ * receives dataset requests and delivers them back to the {@code PanelLayout}.
+ */
+public class DynamicPanel extends HomeFragment {
+ private static final String LOGTAG = "GeckoDynamicPanel";
+
+ // Dataset ID to be used by the loader
+ private static final String DATASET_REQUEST = "dataset_request";
+
+ // Max number of items to display in the panel
+ private static final int RESULT_LIMIT = 100;
+
+ // The main view for this fragment. This contains the PanelLayout and PanelAuthLayout.
+ private FrameLayout mView;
+
+ // The panel layout associated with this panel
+ private PanelLayout mPanelLayout;
+
+ // The layout used to show authentication UI for this panel
+ private PanelAuthLayout mPanelAuthLayout;
+
+ // Cache used to keep track of whether or not the user has been authenticated.
+ private PanelAuthCache mPanelAuthCache;
+
+ // Hold a reference to the UiAsyncTask we use to check the state of the
+ // PanelAuthCache, so that we can cancel it if necessary.
+ private UIAsyncTask.WithoutParams<Boolean> mAuthStateTask;
+
+ // The configuration associated with this panel
+ private PanelConfig mPanelConfig;
+
+ // Callbacks used for the loader
+ private PanelLoaderCallbacks mLoaderCallbacks;
+
+ // The current UI mode in the fragment
+ private UIMode mUIMode;
+
+ /*
+ * Different UI modes to display depending on the authentication state.
+ *
+ * PANEL: Layout to display panel data.
+ * AUTH: Authentication UI.
+ */
+ private enum UIMode {
+ PANEL,
+ AUTH
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ final Bundle args = getArguments();
+ if (args != null) {
+ mPanelConfig = (PanelConfig) args.getParcelable(HomePager.PANEL_CONFIG_ARG);
+ }
+
+ if (mPanelConfig == null) {
+ throw new IllegalStateException("Can't create a DynamicPanel without a PanelConfig");
+ }
+
+ mPanelAuthCache = new PanelAuthCache(getActivity());
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ mView = new FrameLayout(getActivity());
+ return mView;
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ // Restore whatever the UI mode the fragment had before
+ // a device rotation.
+ if (mUIMode != null) {
+ setUIMode(mUIMode);
+ }
+
+ mPanelAuthCache.setOnChangeListener(new PanelAuthChangeListener());
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ mView = null;
+ mPanelLayout = null;
+ mPanelAuthLayout = null;
+
+ mPanelAuthCache.setOnChangeListener(null);
+
+ if (mAuthStateTask != null) {
+ mAuthStateTask.cancel();
+ mAuthStateTask = null;
+ }
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+
+ // Create callbacks before the initial loader is started.
+ mLoaderCallbacks = new PanelLoaderCallbacks();
+ loadIfVisible();
+ }
+
+ @Override
+ protected void load() {
+ Log.d(LOGTAG, "Loading layout");
+
+ if (requiresAuth()) {
+ mAuthStateTask = new UIAsyncTask.WithoutParams<Boolean>(ThreadUtils.getBackgroundHandler()) {
+ @Override
+ public synchronized Boolean doInBackground() {
+ return mPanelAuthCache.isAuthenticated(mPanelConfig.getId());
+ }
+
+ @Override
+ public void onPostExecute(Boolean isAuthenticated) {
+ mAuthStateTask = null;
+ setUIMode(isAuthenticated ? UIMode.PANEL : UIMode.AUTH);
+ }
+ };
+ mAuthStateTask.execute();
+ } else {
+ setUIMode(UIMode.PANEL);
+ }
+ }
+
+ /**
+ * @return true if this panel requires authentication.
+ */
+ private boolean requiresAuth() {
+ return mPanelConfig.getAuthConfig() != null;
+ }
+
+ /**
+ * Lazily creates layout for panel data.
+ */
+ private void createPanelLayout() {
+ final ContextMenuRegistry contextMenuRegistry = new ContextMenuRegistry() {
+ @Override
+ public void register(View view) {
+ registerForContextMenu(view);
+ }
+ };
+
+ switch (mPanelConfig.getLayoutType()) {
+ case FRAME:
+ final PanelDatasetHandler datasetHandler = new PanelDatasetHandler();
+ mPanelLayout = new FramePanelLayout(getActivity(), mPanelConfig, datasetHandler,
+ mUrlOpenListener, contextMenuRegistry);
+ break;
+
+ default:
+ throw new IllegalStateException("Unrecognized layout type in DynamicPanel");
+ }
+
+ Log.d(LOGTAG, "Created layout of type: " + mPanelConfig.getLayoutType());
+ mView.addView(mPanelLayout);
+ }
+
+ /**
+ * Lazily creates layout for authentication UI.
+ */
+ private void createPanelAuthLayout() {
+ mPanelAuthLayout = new PanelAuthLayout(getActivity(), mPanelConfig);
+ mView.addView(mPanelAuthLayout, 0);
+ }
+
+ private void setUIMode(UIMode mode) {
+ switch (mode) {
+ case PANEL:
+ if (mPanelAuthLayout != null) {
+ mPanelAuthLayout.setVisibility(View.GONE);
+ }
+ if (mPanelLayout == null) {
+ createPanelLayout();
+ }
+ mPanelLayout.setVisibility(View.VISIBLE);
+
+ // Only trigger a reload if the UI mode has changed
+ // (e.g. auth cache changes) and the fragment is allowed
+ // to load its contents. Any loaders associated with the
+ // panel layout will be automatically re-bound after a
+ // device rotation, no need to explicitly load it again.
+ if (mUIMode != mode && canLoad()) {
+ mPanelLayout.load();
+ }
+ break;
+
+ case AUTH:
+ if (mPanelLayout != null) {
+ mPanelLayout.setVisibility(View.GONE);
+ }
+ if (mPanelAuthLayout == null) {
+ createPanelAuthLayout();
+ }
+ mPanelAuthLayout.setVisibility(View.VISIBLE);
+ break;
+
+ default:
+ throw new IllegalStateException("Unrecognized UIMode in DynamicPanel");
+ }
+
+ mUIMode = mode;
+ }
+
+ /**
+ * Used by the PanelLayout to make load and reset requests to
+ * the holding fragment.
+ */
+ private class PanelDatasetHandler implements DatasetHandler {
+ @Override
+ public void requestDataset(DatasetRequest request) {
+ Log.d(LOGTAG, "Requesting request: " + request);
+
+ final Bundle bundle = new Bundle();
+ bundle.putParcelable(DATASET_REQUEST, request);
+
+ getLoaderManager().restartLoader(request.getViewIndex(),
+ bundle, mLoaderCallbacks);
+ }
+
+ @Override
+ public void resetDataset(int viewIndex) {
+ Log.d(LOGTAG, "Resetting dataset: " + viewIndex);
+
+ final LoaderManager lm = getLoaderManager();
+
+ // Release any resources associated with the dataset if
+ // it's currently loaded in memory.
+ final Loader<?> datasetLoader = lm.getLoader(viewIndex);
+ if (datasetLoader != null) {
+ datasetLoader.reset();
+ }
+ }
+ }
+
+ /**
+ * Cursor loader for the panel datasets.
+ */
+ private static class PanelDatasetLoader extends SimpleCursorLoader {
+ private DatasetRequest mRequest;
+
+ public PanelDatasetLoader(Context context, DatasetRequest request) {
+ super(context);
+ mRequest = request;
+ }
+
+ public DatasetRequest getRequest() {
+ return mRequest;
+ }
+
+ @Override
+ public void onContentChanged() {
+ // Ensure the refresh request doesn't affect the view's filter
+ // stack (i.e. use DATASET_LOAD type) but keep the current
+ // dataset ID and filter.
+ final DatasetRequest newRequest =
+ new DatasetRequest(mRequest.getViewIndex(),
+ DatasetRequest.Type.DATASET_LOAD,
+ mRequest.getDatasetId(),
+ mRequest.getFilterDetail());
+
+ mRequest = newRequest;
+ super.onContentChanged();
+ }
+
+ @Override
+ public Cursor loadCursor() {
+ final ContentResolver cr = getContext().getContentResolver();
+
+ final String selection;
+ final String[] selectionArgs;
+
+ // Null represents the root filter
+ if (mRequest.getFilter() == null) {
+ selection = HomeItems.FILTER + " IS NULL";
+ selectionArgs = null;
+ } else {
+ selection = HomeItems.FILTER + " = ?";
+ selectionArgs = new String[] { mRequest.getFilter() };
+ }
+
+ final Uri queryUri = HomeItems.CONTENT_URI.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_DATASET_ID,
+ mRequest.getDatasetId())
+ .appendQueryParameter(BrowserContract.PARAM_LIMIT,
+ String.valueOf(RESULT_LIMIT))
+ .build();
+
+ // XXX: You can use HomeItems.CONTENT_FAKE_URI for development
+ // to pull items from fake_home_items.json.
+ return cr.query(queryUri, null, selection, selectionArgs, null);
+ }
+ }
+
+ /**
+ * LoaderCallbacks implementation that interacts with the LoaderManager.
+ */
+ private class PanelLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> {
+ @Override
+ public Loader<Cursor> onCreateLoader(int id, Bundle args) {
+ final DatasetRequest request = (DatasetRequest) args.getParcelable(DATASET_REQUEST);
+
+ Log.d(LOGTAG, "Creating loader for request: " + request);
+ return new PanelDatasetLoader(getActivity(), request);
+ }
+
+ @Override
+ public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) {
+ final DatasetRequest request = getRequestFromLoader(loader);
+ Log.d(LOGTAG, "Finished loader for request: " + request);
+
+ if (mPanelLayout != null) {
+ mPanelLayout.deliverDataset(request, cursor);
+ }
+ }
+
+ @Override
+ public void onLoaderReset(Loader<Cursor> loader) {
+ final DatasetRequest request = getRequestFromLoader(loader);
+ Log.d(LOGTAG, "Resetting loader for request: " + request);
+
+ if (mPanelLayout != null) {
+ mPanelLayout.releaseDataset(request.getViewIndex());
+ }
+ }
+
+ private DatasetRequest getRequestFromLoader(Loader<Cursor> loader) {
+ final PanelDatasetLoader datasetLoader = (PanelDatasetLoader) loader;
+ return datasetLoader.getRequest();
+ }
+ }
+
+ private class PanelAuthChangeListener implements PanelAuthCache.OnChangeListener {
+ @Override
+ public void onChange(String panelId, boolean isAuthenticated) {
+ if (!mPanelConfig.getId().equals(panelId)) {
+ return;
+ }
+
+ setUIMode(isAuthenticated ? UIMode.PANEL : UIMode.AUTH);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/FramePanelLayout.java b/mobile/android/base/java/org/mozilla/gecko/home/FramePanelLayout.java
new file mode 100644
index 000000000..7168c1576
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/FramePanelLayout.java
@@ -0,0 +1,52 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
+import org.mozilla.gecko.home.HomeConfig.PanelConfig;
+import org.mozilla.gecko.home.HomeConfig.ViewConfig;
+
+import android.content.Context;
+import android.util.Log;
+import android.view.View;
+
+class FramePanelLayout extends PanelLayout {
+ private static final String LOGTAG = "GeckoFramePanelLayout";
+
+ private final View mChildView;
+ private final ViewConfig mChildConfig;
+
+ public FramePanelLayout(Context context, PanelConfig panelConfig, DatasetHandler datasetHandler,
+ OnUrlOpenListener urlOpenListener, ContextMenuRegistry contextMenuRegistry) {
+ super(context, panelConfig, datasetHandler, urlOpenListener, contextMenuRegistry);
+
+ // This layout can only hold one view so we simply
+ // take the first defined view from PanelConfig.
+ mChildConfig = panelConfig.getViewAt(0);
+ if (mChildConfig == null) {
+ throw new IllegalStateException("FramePanelLayout requires a view in PanelConfig");
+ }
+
+ mChildView = createPanelView(mChildConfig);
+ addView(mChildView);
+ }
+
+ @Override
+ public void load() {
+ Log.d(LOGTAG, "Loading");
+
+ if (mChildView instanceof DatasetBacked) {
+ final FilterDetail filter = new FilterDetail(mChildConfig.getFilter(), null);
+
+ final DatasetRequest request = new DatasetRequest(mChildConfig.getIndex(),
+ mChildConfig.getDatasetId(),
+ filter);
+
+ Log.d(LOGTAG, "Requesting child request: " + request);
+ requestDataset(request);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HistorySectionsHelper.java b/mobile/android/base/java/org/mozilla/gecko/home/HistorySectionsHelper.java
new file mode 100644
index 000000000..7a49559f6
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/HistorySectionsHelper.java
@@ -0,0 +1,80 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import android.content.res.Resources;
+
+import org.mozilla.gecko.home.CombinedHistoryAdapter.SectionHeader;
+import org.mozilla.gecko.R;
+
+import java.util.Calendar;
+import java.util.Locale;
+
+
+public class HistorySectionsHelper {
+
+ // Constants for different time sections.
+ private static final long MS_PER_DAY = 86400000;
+ private static final long MS_PER_WEEK = MS_PER_DAY * 7;
+
+ public static class SectionDateRange {
+ public final long start;
+ public final long end;
+ public final String displayName;
+
+ private SectionDateRange(long start, long end, String displayName) {
+ this.start = start;
+ this.end = end;
+ this.displayName = displayName;
+ }
+ }
+
+ /**
+ * Updates the time range in milliseconds covered by each section header and sets the title.
+ * @param res Resources for fetching string names
+ * @param sectionsArray Array of section bookkeeping objects
+ */
+ public static void updateRecentSectionOffset(final Resources res, SectionDateRange[] sectionsArray) {
+ final long now = System.currentTimeMillis();
+ final Calendar cal = Calendar.getInstance();
+
+ // Update calendar to this day.
+ cal.set(Calendar.HOUR_OF_DAY, 0);
+ cal.set(Calendar.MINUTE, 0);
+ cal.set(Calendar.SECOND, 0);
+ cal.set(Calendar.MILLISECOND, 1);
+ final long currentDayMS = cal.getTimeInMillis();
+
+ // Calculate the start and end time for each section header and set its display text.
+ sectionsArray[SectionHeader.TODAY.ordinal()] =
+ new SectionDateRange(currentDayMS, now, res.getString(R.string.history_today_section));
+
+ sectionsArray[SectionHeader.YESTERDAY.ordinal()] =
+ new SectionDateRange(currentDayMS - MS_PER_DAY, currentDayMS, res.getString(R.string.history_yesterday_section));
+
+ sectionsArray[SectionHeader.WEEK.ordinal()] =
+ new SectionDateRange(currentDayMS - MS_PER_WEEK, now, res.getString(R.string.history_week_section));
+
+ // Update the calendar to beginning of next month to avoid problems calculating the last day of this month.
+ cal.add(Calendar.MONTH, 1);
+ cal.set(Calendar.DAY_OF_MONTH, cal.getMinimum(Calendar.DAY_OF_MONTH));
+
+ // Iterate over the remaining history sections, moving backwards in time.
+ for (int i = SectionHeader.THIS_MONTH.ordinal(); i < SectionHeader.OLDER_THAN_SIX_MONTHS.ordinal(); i++) {
+ final long end = cal.getTimeInMillis();
+
+ cal.add(Calendar.MONTH, -1);
+ final long start = cal.getTimeInMillis();
+
+ final String displayName = cal.getDisplayName(Calendar.MONTH, Calendar.LONG, Locale.getDefault());
+
+ sectionsArray[i] = new SectionDateRange(start, end, displayName);
+ }
+
+ sectionsArray[SectionHeader.OLDER_THAN_SIX_MONTHS.ordinal()] =
+ new SectionDateRange(0L, cal.getTimeInMillis(), res.getString(R.string.history_older_section));
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomeAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/HomeAdapter.java
new file mode 100644
index 000000000..98d1ae6d8
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/HomeAdapter.java
@@ -0,0 +1,224 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import org.mozilla.gecko.activitystream.ActivityStream;
+import org.mozilla.gecko.home.HomeConfig.PanelConfig;
+import org.mozilla.gecko.home.HomeConfig.PanelType;
+import org.mozilla.gecko.home.activitystream.ActivityStreamHomeFragment;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.support.v4.app.Fragment;
+import android.support.v4.app.FragmentManager;
+import android.support.v4.app.FragmentStatePagerAdapter;
+import android.view.ViewGroup;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class HomeAdapter extends FragmentStatePagerAdapter {
+
+ private final Context mContext;
+ private final ArrayList<PanelInfo> mPanelInfos;
+ private final Map<String, HomeFragment> mPanels;
+ private final Map<String, Bundle> mRestoreBundles;
+
+ private boolean mCanLoadHint;
+
+ private OnAddPanelListener mAddPanelListener;
+
+ private HomeFragment.PanelStateChangeListener mPanelStateChangeListener = null;
+
+ public interface OnAddPanelListener {
+ void onAddPanel(String title);
+ }
+
+ public void setPanelStateChangeListener(HomeFragment.PanelStateChangeListener listener) {
+ mPanelStateChangeListener = listener;
+
+ for (Fragment fragment : mPanels.values()) {
+ ((HomeFragment) fragment).setPanelStateChangeListener(listener);
+ }
+ }
+
+ public HomeAdapter(Context context, FragmentManager fm) {
+ super(fm);
+
+ mContext = context;
+ mCanLoadHint = HomeFragment.DEFAULT_CAN_LOAD_HINT;
+
+ mPanelInfos = new ArrayList<>();
+ mPanels = new HashMap<>();
+ mRestoreBundles = new HashMap<>();
+ }
+
+ @Override
+ public int getCount() {
+ return mPanelInfos.size();
+ }
+
+ @Override
+ public Fragment getItem(int position) {
+ PanelInfo info = mPanelInfos.get(position);
+ return Fragment.instantiate(mContext, info.getClassName(mContext), info.getArgs());
+ }
+
+ @Override
+ public CharSequence getPageTitle(int position) {
+ if (mPanelInfos.size() > 0) {
+ PanelInfo info = mPanelInfos.get(position);
+ return info.getTitle().toUpperCase();
+ }
+
+ return null;
+ }
+
+ @Override
+ public Object instantiateItem(ViewGroup container, int position) {
+ final HomeFragment fragment = (HomeFragment) super.instantiateItem(container, position);
+ fragment.setPanelStateChangeListener(mPanelStateChangeListener);
+
+ final String id = mPanelInfos.get(position).getId();
+ mPanels.put(id, fragment);
+
+ if (mRestoreBundles.containsKey(id)) {
+ fragment.restoreData(mRestoreBundles.get(id));
+ mRestoreBundles.remove(id);
+ }
+
+ return fragment;
+ }
+
+ public void setRestoreData(int position, Bundle data) {
+ final String id = mPanelInfos.get(position).getId();
+ final HomeFragment fragment = mPanels.get(id);
+
+ // We have no guarantees as to whether our desired fragment is instantiated yet: therefore
+ // we might need to either pass data to the fragment, or store it for later.
+ if (fragment != null) {
+ fragment.restoreData(data);
+ } else {
+ mRestoreBundles.put(id, data);
+ }
+
+ }
+
+ @Override
+ public void destroyItem(ViewGroup container, int position, Object object) {
+ final String id = mPanelInfos.get(position).getId();
+
+ super.destroyItem(container, position, object);
+ mPanels.remove(id);
+ }
+
+ public void setOnAddPanelListener(OnAddPanelListener listener) {
+ mAddPanelListener = listener;
+ }
+
+ public int getItemPosition(String panelId) {
+ for (int i = 0; i < mPanelInfos.size(); i++) {
+ final String id = mPanelInfos.get(i).getId();
+ if (id.equals(panelId)) {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
+ public String getPanelIdAtPosition(int position) {
+ // getPanelIdAtPosition() might be called before HomeAdapter
+ // has got its initial list of PanelConfigs. Just bail.
+ if (mPanelInfos.isEmpty()) {
+ return null;
+ }
+
+ return mPanelInfos.get(position).getId();
+ }
+
+ private void addPanel(PanelInfo info) {
+ mPanelInfos.add(info);
+
+ if (mAddPanelListener != null) {
+ mAddPanelListener.onAddPanel(info.getTitle());
+ }
+ }
+
+ public void update(List<PanelConfig> panelConfigs) {
+ mPanels.clear();
+ mPanelInfos.clear();
+
+ if (panelConfigs != null) {
+ for (PanelConfig panelConfig : panelConfigs) {
+ final PanelInfo info = new PanelInfo(panelConfig);
+ addPanel(info);
+ }
+ }
+
+ notifyDataSetChanged();
+ }
+
+ public boolean getCanLoadHint() {
+ return mCanLoadHint;
+ }
+
+ public void setCanLoadHint(boolean canLoadHint) {
+ // We cache the last hint value so that we can use it when
+ // creating new panels. See PanelInfo.getArgs().
+ mCanLoadHint = canLoadHint;
+
+ // Enable/disable loading on all existing panels
+ for (Fragment panelFragment : mPanels.values()) {
+ final HomeFragment panel = (HomeFragment) panelFragment;
+ panel.setCanLoadHint(canLoadHint);
+ }
+ }
+
+ private final class PanelInfo {
+ private final PanelConfig mPanelConfig;
+
+ PanelInfo(PanelConfig panelConfig) {
+ mPanelConfig = panelConfig;
+ }
+
+ public String getId() {
+ return mPanelConfig.getId();
+ }
+
+ public String getTitle() {
+ return mPanelConfig.getTitle();
+ }
+
+ public String getClassName(Context context) {
+ final PanelType type = mPanelConfig.getType();
+
+ // Override top_sites with ActivityStream panel when enabled
+ // PanelType.toString() returns the panel id
+ if (type.toString() == "top_sites" &&
+ ActivityStream.isEnabled(context) &&
+ ActivityStream.isHomePanel()) {
+ return ActivityStreamHomeFragment.class.getName();
+ }
+ return type.getPanelClass().getName();
+ }
+
+ public Bundle getArgs() {
+ final Bundle args = new Bundle();
+
+ args.putBoolean(HomePager.CAN_LOAD_ARG, mCanLoadHint);
+
+ // Only DynamicPanels need the PanelConfig argument
+ if (mPanelConfig.isDynamic()) {
+ args.putParcelable(HomePager.PANEL_CONFIG_ARG, mPanelConfig);
+ }
+
+ return args;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomeBanner.java b/mobile/android/base/java/org/mozilla/gecko/home/HomeBanner.java
new file mode 100644
index 000000000..10f5db39e
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/HomeBanner.java
@@ -0,0 +1,315 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import org.json.JSONObject;
+import org.mozilla.gecko.GeckoApp;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.animation.PropertyAnimator;
+import org.mozilla.gecko.animation.PropertyAnimator.Property;
+import org.mozilla.gecko.animation.ViewHelper;
+import org.mozilla.gecko.util.ResourceDrawableUtils;
+import org.mozilla.gecko.util.GeckoEventListener;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.widget.EllipsisTextView;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.text.Html;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.ImageButton;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+
+public class HomeBanner extends LinearLayout
+ implements GeckoEventListener {
+ private static final String LOGTAG = "GeckoHomeBanner";
+
+ // Used for tracking scroll length
+ private float mTouchY = -1;
+
+ // Used to detect for upwards scroll to push banner all the way up
+ private boolean mSnapBannerToTop;
+
+ // Tracks whether or not the banner should be shown on the current panel.
+ private boolean mActive;
+
+ // The user is currently swiping between HomePager pages
+ private boolean mScrollingPages;
+
+ // Tracks whether the user swiped the banner down, preventing us from autoshowing when the user
+ // switches back to the default page.
+ private boolean mUserSwipedDown;
+
+ // We must use this custom TextView to address an issue on 2.3 and lower where ellipsized text
+ // will not wrap more than 2 lines.
+ private final EllipsisTextView mTextView;
+ private final ImageView mIconView;
+
+ // The height of the banner view.
+ private final float mHeight;
+
+ // Listener that gets called when the banner is dismissed from the close button.
+ private OnDismissListener mOnDismissListener;
+
+ public interface OnDismissListener {
+ public void onDismiss();
+ }
+
+ public HomeBanner(Context context) {
+ this(context, null);
+ }
+
+ public HomeBanner(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ LayoutInflater.from(context).inflate(R.layout.home_banner_content, this);
+
+ mTextView = (EllipsisTextView) findViewById(R.id.text);
+ mIconView = (ImageView) findViewById(R.id.icon);
+
+ mHeight = getResources().getDimensionPixelSize(R.dimen.home_banner_height);
+
+ // Disable the banner until a message is set.
+ setEnabled(false);
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+
+ // Tapping on the close button will ensure that the banner is never
+ // showed again on this session.
+ final ImageButton closeButton = (ImageButton) findViewById(R.id.close);
+
+ // The drawable should have 50% opacity.
+ closeButton.getDrawable().setAlpha(127);
+
+ closeButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ HomeBanner.this.dismiss();
+
+ // Send the current message id back to JS.
+ GeckoAppShell.notifyObservers("HomeBanner:Dismiss", (String) getTag());
+ }
+ });
+
+ setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ HomeBanner.this.dismiss();
+
+ // Send the current message id back to JS.
+ GeckoAppShell.notifyObservers("HomeBanner:Click", (String) getTag());
+ }
+ });
+
+ GeckoApp.getEventDispatcher().registerGeckoThreadListener(this, "HomeBanner:Data");
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+
+ GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this, "HomeBanner:Data");
+ }
+
+ public void setScrollingPages(boolean scrollingPages) {
+ mScrollingPages = scrollingPages;
+ }
+
+ public void setOnDismissListener(OnDismissListener listener) {
+ mOnDismissListener = listener;
+ }
+
+ /**
+ * Hides and disables the banner.
+ */
+ private void dismiss() {
+ setVisibility(View.GONE);
+ setEnabled(false);
+
+ if (mOnDismissListener != null) {
+ mOnDismissListener.onDismiss();
+ }
+ }
+
+ /**
+ * Sends a message to gecko to request a new banner message. UI is updated in handleMessage.
+ */
+ public void update() {
+ GeckoAppShell.notifyObservers("HomeBanner:Get", null);
+ }
+
+ @Override
+ public void handleMessage(String event, JSONObject message) {
+ final String id = message.optString("id");
+ final String text = message.optString("text");
+ final String iconURI = message.optString("iconURI");
+
+ // Don't update the banner if the message doesn't have valid id and text.
+ if (TextUtils.isEmpty(id) || TextUtils.isEmpty(text)) {
+ return;
+ }
+
+ // Update the banner message on the UI thread.
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ // Store the current message id to pass back to JS in the view's OnClickListener.
+ setTag(id);
+ mTextView.setOriginalText(Html.fromHtml(text));
+
+ ResourceDrawableUtils.getDrawable(getContext(), iconURI, new ResourceDrawableUtils.BitmapLoader() {
+ @Override
+ public void onBitmapFound(final Drawable d) {
+ // Hide the image view if we don't have an icon to show.
+ if (d == null) {
+ mIconView.setVisibility(View.GONE);
+ } else {
+ mIconView.setImageDrawable(d);
+ mIconView.setVisibility(View.VISIBLE);
+ }
+ }
+ });
+
+ GeckoAppShell.notifyObservers("HomeBanner:Shown", id);
+
+ // Enable the banner after a message is set.
+ setEnabled(true);
+
+ // Animate the banner if it is currently active.
+ if (mActive) {
+ animateUp();
+ }
+ }
+ });
+ }
+
+ public void setActive(boolean active) {
+ // No need to animate if not changing
+ if (mActive == active) {
+ return;
+ }
+
+ mActive = active;
+
+ // Don't animate if the banner isn't enabled.
+ if (!isEnabled()) {
+ return;
+ }
+
+ if (active) {
+ animateUp();
+ } else {
+ animateDown();
+ }
+ }
+
+ private void ensureVisible() {
+ // The banner visibility is set to GONE after it is animated off screen,
+ // so we need to make it visible again.
+ if (getVisibility() == View.GONE) {
+ // Translate the banner off screen before setting it to VISIBLE.
+ ViewHelper.setTranslationY(this, mHeight);
+ setVisibility(View.VISIBLE);
+ }
+ }
+
+ private void animateUp() {
+ // Don't try to animate if the user swiped the banner down previously to hide it.
+ if (mUserSwipedDown) {
+ return;
+ }
+
+ ensureVisible();
+
+ final PropertyAnimator animator = new PropertyAnimator(100);
+ animator.attach(this, Property.TRANSLATION_Y, 0);
+ animator.start();
+ }
+
+ private void animateDown() {
+ if (ViewHelper.getTranslationY(this) == mHeight) {
+ // Hide the banner to avoid intercepting clicks on pre-honeycomb devices.
+ setVisibility(View.GONE);
+ return;
+ }
+
+ final PropertyAnimator animator = new PropertyAnimator(100);
+ animator.attach(this, Property.TRANSLATION_Y, mHeight);
+ animator.addPropertyAnimationListener(new PropertyAnimator.PropertyAnimationListener() {
+ @Override
+ public void onPropertyAnimationStart() {
+ }
+
+ @Override
+ public void onPropertyAnimationEnd() {
+ // Hide the banner to avoid intercepting clicks on pre-honeycomb devices.
+ setVisibility(View.GONE);
+ }
+ });
+ animator.start();
+ }
+
+ public void handleHomeTouch(MotionEvent event) {
+ if (!mActive || !isEnabled() || mScrollingPages) {
+ return;
+ }
+
+ ensureVisible();
+
+ switch (event.getActionMasked()) {
+ case MotionEvent.ACTION_DOWN: {
+ // Track the beginning of the touch
+ mTouchY = event.getRawY();
+ break;
+ }
+
+ case MotionEvent.ACTION_MOVE: {
+ final float curY = event.getRawY();
+ final float delta = mTouchY - curY;
+ mSnapBannerToTop = delta <= 0.0f;
+
+ float newTranslationY = ViewHelper.getTranslationY(this) + delta;
+
+ // Clamp the values to be between 0 and height.
+ if (newTranslationY < 0.0f) {
+ newTranslationY = 0.0f;
+ } else if (newTranslationY > mHeight) {
+ newTranslationY = mHeight;
+ }
+
+ // Don't change this value if it wasn't a significant movement
+ if (delta >= 10 || delta <= -10) {
+ mUserSwipedDown = (newTranslationY == mHeight);
+ }
+
+ ViewHelper.setTranslationY(this, newTranslationY);
+ mTouchY = curY;
+ break;
+ }
+
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_CANCEL: {
+ mTouchY = -1;
+ if (mSnapBannerToTop) {
+ animateUp();
+ } else {
+ animateDown();
+ mUserSwipedDown = true;
+ }
+ break;
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomeConfig.java b/mobile/android/base/java/org/mozilla/gecko/home/HomeConfig.java
new file mode 100644
index 000000000..08e79be3a
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/HomeConfig.java
@@ -0,0 +1,1694 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.content.Context;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.text.TextUtils;
+import android.util.Pair;
+
+public final class HomeConfig {
+ public static final String PREF_KEY_BOOKMARKS_PANEL_ENABLED = "bookmarksPanelEnabled";
+ public static final String PREF_KEY_HISTORY_PANEL_ENABLED = "combinedHistoryPanelEnabled";
+
+ /**
+ * Used to determine what type of HomeFragment subclass to use when creating
+ * a given panel. With the exception of DYNAMIC, all of these types correspond
+ * to a default set of built-in panels. The DYNAMIC panel type is used by
+ * third-party services to create panels with varying types of content.
+ */
+ @RobocopTarget
+ public static enum PanelType implements Parcelable {
+ TOP_SITES("top_sites", TopSitesPanel.class),
+ BOOKMARKS("bookmarks", BookmarksPanel.class),
+ COMBINED_HISTORY("combined_history", CombinedHistoryPanel.class),
+ DYNAMIC("dynamic", DynamicPanel.class),
+ // Deprecated panels that should no longer exist but are kept around for
+ // migration code. Class references have been replaced with new version of the panel.
+ DEPRECATED_REMOTE_TABS("remote_tabs", CombinedHistoryPanel.class),
+ DEPRECATED_HISTORY("history", CombinedHistoryPanel.class),
+ DEPRECATED_READING_LIST("reading_list", BookmarksPanel.class),
+ DEPRECATED_RECENT_TABS("recent_tabs", CombinedHistoryPanel.class);
+
+ private final String mId;
+ private final Class<?> mPanelClass;
+
+ PanelType(String id, Class<?> panelClass) {
+ mId = id;
+ mPanelClass = panelClass;
+ }
+
+ public static PanelType fromId(String id) {
+ if (id == null) {
+ throw new IllegalArgumentException("Could not convert null String to PanelType");
+ }
+
+ for (PanelType panelType : PanelType.values()) {
+ if (TextUtils.equals(panelType.mId, id.toLowerCase())) {
+ return panelType;
+ }
+ }
+
+ throw new IllegalArgumentException("Could not convert String id to PanelType");
+ }
+
+ @Override
+ public String toString() {
+ return mId;
+ }
+
+ public Class<?> getPanelClass() {
+ return mPanelClass;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(ordinal());
+ }
+
+ public static final Creator<PanelType> CREATOR = new Creator<PanelType>() {
+ @Override
+ public PanelType createFromParcel(final Parcel source) {
+ return PanelType.values()[source.readInt()];
+ }
+
+ @Override
+ public PanelType[] newArray(final int size) {
+ return new PanelType[size];
+ }
+ };
+ }
+
+ public static class PanelConfig implements Parcelable {
+ private final PanelType mType;
+ private final String mTitle;
+ private final String mId;
+ private final LayoutType mLayoutType;
+ private final List<ViewConfig> mViews;
+ private final AuthConfig mAuthConfig;
+ private final EnumSet<Flags> mFlags;
+ private final int mPosition;
+
+ static final String JSON_KEY_TYPE = "type";
+ static final String JSON_KEY_TITLE = "title";
+ static final String JSON_KEY_ID = "id";
+ static final String JSON_KEY_LAYOUT = "layout";
+ static final String JSON_KEY_VIEWS = "views";
+ static final String JSON_KEY_AUTH_CONFIG = "authConfig";
+ static final String JSON_KEY_DEFAULT = "default";
+ static final String JSON_KEY_DISABLED = "disabled";
+ static final String JSON_KEY_POSITION = "position";
+
+ public enum Flags {
+ DEFAULT_PANEL,
+ DISABLED_PANEL
+ }
+
+ public PanelConfig(JSONObject json) throws JSONException, IllegalArgumentException {
+ final String panelType = json.optString(JSON_KEY_TYPE, null);
+ if (TextUtils.isEmpty(panelType)) {
+ mType = PanelType.DYNAMIC;
+ } else {
+ mType = PanelType.fromId(panelType);
+ }
+
+ mTitle = json.getString(JSON_KEY_TITLE);
+ mId = json.getString(JSON_KEY_ID);
+
+ final String layoutTypeId = json.optString(JSON_KEY_LAYOUT, null);
+ if (layoutTypeId != null) {
+ mLayoutType = LayoutType.fromId(layoutTypeId);
+ } else {
+ mLayoutType = null;
+ }
+
+ final JSONArray jsonViews = json.optJSONArray(JSON_KEY_VIEWS);
+ if (jsonViews != null) {
+ mViews = new ArrayList<ViewConfig>();
+
+ final int viewCount = jsonViews.length();
+ for (int i = 0; i < viewCount; i++) {
+ final JSONObject jsonViewConfig = (JSONObject) jsonViews.get(i);
+ final ViewConfig viewConfig = new ViewConfig(i, jsonViewConfig);
+ mViews.add(viewConfig);
+ }
+ } else {
+ mViews = null;
+ }
+
+ final JSONObject jsonAuthConfig = json.optJSONObject(JSON_KEY_AUTH_CONFIG);
+ if (jsonAuthConfig != null) {
+ mAuthConfig = new AuthConfig(jsonAuthConfig);
+ } else {
+ mAuthConfig = null;
+ }
+
+ mFlags = EnumSet.noneOf(Flags.class);
+
+ if (json.optBoolean(JSON_KEY_DEFAULT, false)) {
+ mFlags.add(Flags.DEFAULT_PANEL);
+ }
+
+ if (json.optBoolean(JSON_KEY_DISABLED, false)) {
+ mFlags.add(Flags.DISABLED_PANEL);
+ }
+
+ mPosition = json.optInt(JSON_KEY_POSITION, -1);
+
+ validate();
+ }
+
+ @SuppressWarnings("unchecked")
+ public PanelConfig(Parcel in) {
+ mType = (PanelType) in.readParcelable(getClass().getClassLoader());
+ mTitle = in.readString();
+ mId = in.readString();
+ mLayoutType = (LayoutType) in.readParcelable(getClass().getClassLoader());
+
+ mViews = new ArrayList<ViewConfig>();
+ in.readTypedList(mViews, ViewConfig.CREATOR);
+
+ mAuthConfig = (AuthConfig) in.readParcelable(getClass().getClassLoader());
+
+ mFlags = (EnumSet<Flags>) in.readSerializable();
+ mPosition = in.readInt();
+
+ validate();
+ }
+
+ public PanelConfig(PanelConfig panelConfig) {
+ mType = panelConfig.mType;
+ mTitle = panelConfig.mTitle;
+ mId = panelConfig.mId;
+ mLayoutType = panelConfig.mLayoutType;
+
+ mViews = new ArrayList<ViewConfig>();
+ List<ViewConfig> viewConfigs = panelConfig.mViews;
+ if (viewConfigs != null) {
+ for (ViewConfig viewConfig : viewConfigs) {
+ mViews.add(new ViewConfig(viewConfig));
+ }
+ }
+
+ mAuthConfig = panelConfig.mAuthConfig;
+ mFlags = panelConfig.mFlags.clone();
+ mPosition = panelConfig.mPosition;
+
+ validate();
+ }
+
+ public PanelConfig(PanelType type, String title, String id) {
+ this(type, title, id, EnumSet.noneOf(Flags.class));
+ }
+
+ public PanelConfig(PanelType type, String title, String id, EnumSet<Flags> flags) {
+ this(type, title, id, null, null, null, flags, -1);
+ }
+
+ public PanelConfig(PanelType type, String title, String id, LayoutType layoutType,
+ List<ViewConfig> views, AuthConfig authConfig, EnumSet<Flags> flags, int position) {
+ mType = type;
+ mTitle = title;
+ mId = id;
+ mLayoutType = layoutType;
+ mViews = views;
+ mAuthConfig = authConfig;
+ mFlags = flags;
+ mPosition = position;
+
+ validate();
+ }
+
+ private void validate() {
+ if (mType == null) {
+ throw new IllegalArgumentException("Can't create PanelConfig with null type");
+ }
+
+ if (TextUtils.isEmpty(mTitle)) {
+ throw new IllegalArgumentException("Can't create PanelConfig with empty title");
+ }
+
+ if (TextUtils.isEmpty(mId)) {
+ throw new IllegalArgumentException("Can't create PanelConfig with empty id");
+ }
+
+ if (mLayoutType == null && mType == PanelType.DYNAMIC) {
+ throw new IllegalArgumentException("Can't create a dynamic PanelConfig with null layout type");
+ }
+
+ if ((mViews == null || mViews.size() == 0) && mType == PanelType.DYNAMIC) {
+ throw new IllegalArgumentException("Can't create a dynamic PanelConfig with no views");
+ }
+
+ if (mFlags == null) {
+ throw new IllegalArgumentException("Can't create PanelConfig with null flags");
+ }
+ }
+
+ public PanelType getType() {
+ return mType;
+ }
+
+ public String getTitle() {
+ return mTitle;
+ }
+
+ public String getId() {
+ return mId;
+ }
+
+ public LayoutType getLayoutType() {
+ return mLayoutType;
+ }
+
+ public int getViewCount() {
+ return (mViews != null ? mViews.size() : 0);
+ }
+
+ public ViewConfig getViewAt(int index) {
+ return (mViews != null ? mViews.get(index) : null);
+ }
+
+ public EnumSet<Flags> getFlags() {
+ return mFlags.clone();
+ }
+
+ public boolean isDynamic() {
+ return (mType == PanelType.DYNAMIC);
+ }
+
+ public boolean isDefault() {
+ return mFlags.contains(Flags.DEFAULT_PANEL);
+ }
+
+ private void setIsDefault(boolean isDefault) {
+ if (isDefault) {
+ mFlags.add(Flags.DEFAULT_PANEL);
+ } else {
+ mFlags.remove(Flags.DEFAULT_PANEL);
+ }
+ }
+
+ public boolean isDisabled() {
+ return mFlags.contains(Flags.DISABLED_PANEL);
+ }
+
+ private void setIsDisabled(boolean isDisabled) {
+ if (isDisabled) {
+ mFlags.add(Flags.DISABLED_PANEL);
+ } else {
+ mFlags.remove(Flags.DISABLED_PANEL);
+ }
+ }
+
+ public AuthConfig getAuthConfig() {
+ return mAuthConfig;
+ }
+
+ public int getPosition() {
+ return mPosition;
+ }
+
+ public JSONObject toJSON() throws JSONException {
+ final JSONObject json = new JSONObject();
+
+ json.put(JSON_KEY_TYPE, mType.toString());
+ json.put(JSON_KEY_TITLE, mTitle);
+ json.put(JSON_KEY_ID, mId);
+
+ if (mLayoutType != null) {
+ json.put(JSON_KEY_LAYOUT, mLayoutType.toString());
+ }
+
+ if (mViews != null) {
+ final JSONArray jsonViews = new JSONArray();
+
+ final int viewCount = mViews.size();
+ for (int i = 0; i < viewCount; i++) {
+ final ViewConfig viewConfig = mViews.get(i);
+ final JSONObject jsonViewConfig = viewConfig.toJSON();
+ jsonViews.put(jsonViewConfig);
+ }
+
+ json.put(JSON_KEY_VIEWS, jsonViews);
+ }
+
+ if (mAuthConfig != null) {
+ json.put(JSON_KEY_AUTH_CONFIG, mAuthConfig.toJSON());
+ }
+
+ if (mFlags.contains(Flags.DEFAULT_PANEL)) {
+ json.put(JSON_KEY_DEFAULT, true);
+ }
+
+ if (mFlags.contains(Flags.DISABLED_PANEL)) {
+ json.put(JSON_KEY_DISABLED, true);
+ }
+
+ json.put(JSON_KEY_POSITION, mPosition);
+
+ return json;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == null) {
+ return false;
+ }
+
+ if (this == o) {
+ return true;
+ }
+
+ if (!(o instanceof PanelConfig)) {
+ return false;
+ }
+
+ final PanelConfig other = (PanelConfig) o;
+ return mId.equals(other.mId);
+ }
+
+ @Override
+ public int hashCode() {
+ return super.hashCode();
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeParcelable(mType, 0);
+ dest.writeString(mTitle);
+ dest.writeString(mId);
+ dest.writeParcelable(mLayoutType, 0);
+ dest.writeTypedList(mViews);
+ dest.writeParcelable(mAuthConfig, 0);
+ dest.writeSerializable(mFlags);
+ dest.writeInt(mPosition);
+ }
+
+ public static final Creator<PanelConfig> CREATOR = new Creator<PanelConfig>() {
+ @Override
+ public PanelConfig createFromParcel(final Parcel in) {
+ return new PanelConfig(in);
+ }
+
+ @Override
+ public PanelConfig[] newArray(final int size) {
+ return new PanelConfig[size];
+ }
+ };
+ }
+
+ public static enum LayoutType implements Parcelable {
+ FRAME("frame");
+
+ private final String mId;
+
+ LayoutType(String id) {
+ mId = id;
+ }
+
+ public static LayoutType fromId(String id) {
+ if (id == null) {
+ throw new IllegalArgumentException("Could not convert null String to LayoutType");
+ }
+
+ for (LayoutType layoutType : LayoutType.values()) {
+ if (TextUtils.equals(layoutType.mId, id.toLowerCase())) {
+ return layoutType;
+ }
+ }
+
+ throw new IllegalArgumentException("Could not convert String id to LayoutType");
+ }
+
+ @Override
+ public String toString() {
+ return mId;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(ordinal());
+ }
+
+ public static final Creator<LayoutType> CREATOR = new Creator<LayoutType>() {
+ @Override
+ public LayoutType createFromParcel(final Parcel source) {
+ return LayoutType.values()[source.readInt()];
+ }
+
+ @Override
+ public LayoutType[] newArray(final int size) {
+ return new LayoutType[size];
+ }
+ };
+ }
+
+ public static enum ViewType implements Parcelable {
+ LIST("list"),
+ GRID("grid");
+
+ private final String mId;
+
+ ViewType(String id) {
+ mId = id;
+ }
+
+ public static ViewType fromId(String id) {
+ if (id == null) {
+ throw new IllegalArgumentException("Could not convert null String to ViewType");
+ }
+
+ for (ViewType viewType : ViewType.values()) {
+ if (TextUtils.equals(viewType.mId, id.toLowerCase())) {
+ return viewType;
+ }
+ }
+
+ throw new IllegalArgumentException("Could not convert String id to ViewType");
+ }
+
+ @Override
+ public String toString() {
+ return mId;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(ordinal());
+ }
+
+ public static final Creator<ViewType> CREATOR = new Creator<ViewType>() {
+ @Override
+ public ViewType createFromParcel(final Parcel source) {
+ return ViewType.values()[source.readInt()];
+ }
+
+ @Override
+ public ViewType[] newArray(final int size) {
+ return new ViewType[size];
+ }
+ };
+ }
+
+ public static enum ItemType implements Parcelable {
+ ARTICLE("article"),
+ IMAGE("image"),
+ ICON("icon");
+
+ private final String mId;
+
+ ItemType(String id) {
+ mId = id;
+ }
+
+ public static ItemType fromId(String id) {
+ if (id == null) {
+ throw new IllegalArgumentException("Could not convert null String to ItemType");
+ }
+
+ for (ItemType itemType : ItemType.values()) {
+ if (TextUtils.equals(itemType.mId, id.toLowerCase())) {
+ return itemType;
+ }
+ }
+
+ throw new IllegalArgumentException("Could not convert String id to ItemType");
+ }
+
+ @Override
+ public String toString() {
+ return mId;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(ordinal());
+ }
+
+ public static final Creator<ItemType> CREATOR = new Creator<ItemType>() {
+ @Override
+ public ItemType createFromParcel(final Parcel source) {
+ return ItemType.values()[source.readInt()];
+ }
+
+ @Override
+ public ItemType[] newArray(final int size) {
+ return new ItemType[size];
+ }
+ };
+ }
+
+ public static enum ItemHandler implements Parcelable {
+ BROWSER("browser"),
+ INTENT("intent");
+
+ private final String mId;
+
+ ItemHandler(String id) {
+ mId = id;
+ }
+
+ public static ItemHandler fromId(String id) {
+ if (id == null) {
+ throw new IllegalArgumentException("Could not convert null String to ItemHandler");
+ }
+
+ for (ItemHandler itemHandler : ItemHandler.values()) {
+ if (TextUtils.equals(itemHandler.mId, id.toLowerCase())) {
+ return itemHandler;
+ }
+ }
+
+ throw new IllegalArgumentException("Could not convert String id to ItemHandler");
+ }
+
+ @Override
+ public String toString() {
+ return mId;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(ordinal());
+ }
+
+ public static final Creator<ItemHandler> CREATOR = new Creator<ItemHandler>() {
+ @Override
+ public ItemHandler createFromParcel(final Parcel source) {
+ return ItemHandler.values()[source.readInt()];
+ }
+
+ @Override
+ public ItemHandler[] newArray(final int size) {
+ return new ItemHandler[size];
+ }
+ };
+ }
+
+ public static class ViewConfig implements Parcelable {
+ private final int mIndex;
+ private final ViewType mType;
+ private final String mDatasetId;
+ private final ItemType mItemType;
+ private final ItemHandler mItemHandler;
+ private final String mBackImageUrl;
+ private final String mFilter;
+ private final EmptyViewConfig mEmptyViewConfig;
+ private final HeaderConfig mHeaderConfig;
+ private final EnumSet<Flags> mFlags;
+
+ static final String JSON_KEY_TYPE = "type";
+ static final String JSON_KEY_DATASET = "dataset";
+ static final String JSON_KEY_ITEM_TYPE = "itemType";
+ static final String JSON_KEY_ITEM_HANDLER = "itemHandler";
+ static final String JSON_KEY_BACK_IMAGE_URL = "backImageUrl";
+ static final String JSON_KEY_FILTER = "filter";
+ static final String JSON_KEY_EMPTY = "empty";
+ static final String JSON_KEY_HEADER = "header";
+ static final String JSON_KEY_REFRESH_ENABLED = "refreshEnabled";
+
+ public enum Flags {
+ REFRESH_ENABLED
+ }
+
+ public ViewConfig(int index, JSONObject json) throws JSONException, IllegalArgumentException {
+ mIndex = index;
+ mType = ViewType.fromId(json.getString(JSON_KEY_TYPE));
+ mDatasetId = json.getString(JSON_KEY_DATASET);
+ mItemType = ItemType.fromId(json.getString(JSON_KEY_ITEM_TYPE));
+ mItemHandler = ItemHandler.fromId(json.getString(JSON_KEY_ITEM_HANDLER));
+ mBackImageUrl = json.optString(JSON_KEY_BACK_IMAGE_URL, null);
+ mFilter = json.optString(JSON_KEY_FILTER, null);
+
+ final JSONObject jsonEmptyViewConfig = json.optJSONObject(JSON_KEY_EMPTY);
+ if (jsonEmptyViewConfig != null) {
+ mEmptyViewConfig = new EmptyViewConfig(jsonEmptyViewConfig);
+ } else {
+ mEmptyViewConfig = null;
+ }
+
+ final JSONObject jsonHeaderConfig = json.optJSONObject(JSON_KEY_HEADER);
+ mHeaderConfig = jsonHeaderConfig != null ? new HeaderConfig(jsonHeaderConfig) : null;
+
+ mFlags = EnumSet.noneOf(Flags.class);
+ if (json.optBoolean(JSON_KEY_REFRESH_ENABLED, false)) {
+ mFlags.add(Flags.REFRESH_ENABLED);
+ }
+
+ validate();
+ }
+
+ @SuppressWarnings("unchecked")
+ public ViewConfig(Parcel in) {
+ mIndex = in.readInt();
+ mType = (ViewType) in.readParcelable(getClass().getClassLoader());
+ mDatasetId = in.readString();
+ mItemType = (ItemType) in.readParcelable(getClass().getClassLoader());
+ mItemHandler = (ItemHandler) in.readParcelable(getClass().getClassLoader());
+ mBackImageUrl = in.readString();
+ mFilter = in.readString();
+ mEmptyViewConfig = (EmptyViewConfig) in.readParcelable(getClass().getClassLoader());
+ mHeaderConfig = (HeaderConfig) in.readParcelable(getClass().getClassLoader());
+ mFlags = (EnumSet<Flags>) in.readSerializable();
+
+ validate();
+ }
+
+ public ViewConfig(ViewConfig viewConfig) {
+ mIndex = viewConfig.mIndex;
+ mType = viewConfig.mType;
+ mDatasetId = viewConfig.mDatasetId;
+ mItemType = viewConfig.mItemType;
+ mItemHandler = viewConfig.mItemHandler;
+ mBackImageUrl = viewConfig.mBackImageUrl;
+ mFilter = viewConfig.mFilter;
+ mEmptyViewConfig = viewConfig.mEmptyViewConfig;
+ mHeaderConfig = viewConfig.mHeaderConfig;
+ mFlags = viewConfig.mFlags.clone();
+
+ validate();
+ }
+
+ private void validate() {
+ if (mType == null) {
+ throw new IllegalArgumentException("Can't create ViewConfig with null type");
+ }
+
+ if (TextUtils.isEmpty(mDatasetId)) {
+ throw new IllegalArgumentException("Can't create ViewConfig with empty dataset ID");
+ }
+
+ if (mItemType == null) {
+ throw new IllegalArgumentException("Can't create ViewConfig with null item type");
+ }
+
+ if (mItemHandler == null) {
+ throw new IllegalArgumentException("Can't create ViewConfig with null item handler");
+ }
+
+ if (mFlags == null) {
+ throw new IllegalArgumentException("Can't create ViewConfig with null flags");
+ }
+ }
+
+ public int getIndex() {
+ return mIndex;
+ }
+
+ public ViewType getType() {
+ return mType;
+ }
+
+ public String getDatasetId() {
+ return mDatasetId;
+ }
+
+ public ItemType getItemType() {
+ return mItemType;
+ }
+
+ public ItemHandler getItemHandler() {
+ return mItemHandler;
+ }
+
+ public String getBackImageUrl() {
+ return mBackImageUrl;
+ }
+
+ public String getFilter() {
+ return mFilter;
+ }
+
+ public EmptyViewConfig getEmptyViewConfig() {
+ return mEmptyViewConfig;
+ }
+
+ public HeaderConfig getHeaderConfig() {
+ return mHeaderConfig;
+ }
+
+ public boolean hasHeaderConfig() {
+ return mHeaderConfig != null;
+ }
+
+ public boolean isRefreshEnabled() {
+ return mFlags.contains(Flags.REFRESH_ENABLED);
+ }
+
+ public JSONObject toJSON() throws JSONException {
+ final JSONObject json = new JSONObject();
+
+ json.put(JSON_KEY_TYPE, mType.toString());
+ json.put(JSON_KEY_DATASET, mDatasetId);
+ json.put(JSON_KEY_ITEM_TYPE, mItemType.toString());
+ json.put(JSON_KEY_ITEM_HANDLER, mItemHandler.toString());
+
+ if (!TextUtils.isEmpty(mBackImageUrl)) {
+ json.put(JSON_KEY_BACK_IMAGE_URL, mBackImageUrl);
+ }
+
+ if (!TextUtils.isEmpty(mFilter)) {
+ json.put(JSON_KEY_FILTER, mFilter);
+ }
+
+ if (mEmptyViewConfig != null) {
+ json.put(JSON_KEY_EMPTY, mEmptyViewConfig.toJSON());
+ }
+
+ if (mHeaderConfig != null) {
+ json.put(JSON_KEY_HEADER, mHeaderConfig.toJSON());
+ }
+
+ if (mFlags.contains(Flags.REFRESH_ENABLED)) {
+ json.put(JSON_KEY_REFRESH_ENABLED, true);
+ }
+
+ return json;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(mIndex);
+ dest.writeParcelable(mType, 0);
+ dest.writeString(mDatasetId);
+ dest.writeParcelable(mItemType, 0);
+ dest.writeParcelable(mItemHandler, 0);
+ dest.writeString(mBackImageUrl);
+ dest.writeString(mFilter);
+ dest.writeParcelable(mEmptyViewConfig, 0);
+ dest.writeParcelable(mHeaderConfig, 0);
+ dest.writeSerializable(mFlags);
+ }
+
+ public static final Creator<ViewConfig> CREATOR = new Creator<ViewConfig>() {
+ @Override
+ public ViewConfig createFromParcel(final Parcel in) {
+ return new ViewConfig(in);
+ }
+
+ @Override
+ public ViewConfig[] newArray(final int size) {
+ return new ViewConfig[size];
+ }
+ };
+ }
+
+ public static class EmptyViewConfig implements Parcelable {
+ private final String mText;
+ private final String mImageUrl;
+
+ static final String JSON_KEY_TEXT = "text";
+ static final String JSON_KEY_IMAGE_URL = "imageUrl";
+
+ public EmptyViewConfig(JSONObject json) throws JSONException, IllegalArgumentException {
+ mText = json.optString(JSON_KEY_TEXT, null);
+ mImageUrl = json.optString(JSON_KEY_IMAGE_URL, null);
+ }
+
+ public EmptyViewConfig(Parcel in) {
+ mText = in.readString();
+ mImageUrl = in.readString();
+ }
+
+ public EmptyViewConfig(EmptyViewConfig emptyViewConfig) {
+ mText = emptyViewConfig.mText;
+ mImageUrl = emptyViewConfig.mImageUrl;
+ }
+
+ public EmptyViewConfig(String text, String imageUrl) {
+ mText = text;
+ mImageUrl = imageUrl;
+ }
+
+ public String getText() {
+ return mText;
+ }
+
+ public String getImageUrl() {
+ return mImageUrl;
+ }
+
+ public JSONObject toJSON() throws JSONException {
+ final JSONObject json = new JSONObject();
+
+ json.put(JSON_KEY_TEXT, mText);
+ json.put(JSON_KEY_IMAGE_URL, mImageUrl);
+
+ return json;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeString(mText);
+ dest.writeString(mImageUrl);
+ }
+
+ public static final Creator<EmptyViewConfig> CREATOR = new Creator<EmptyViewConfig>() {
+ @Override
+ public EmptyViewConfig createFromParcel(final Parcel in) {
+ return new EmptyViewConfig(in);
+ }
+
+ @Override
+ public EmptyViewConfig[] newArray(final int size) {
+ return new EmptyViewConfig[size];
+ }
+ };
+ }
+
+ public static class HeaderConfig implements Parcelable {
+ static final String JSON_KEY_IMAGE_URL = "image_url";
+ static final String JSON_KEY_URL = "url";
+
+ private final String mImageUrl;
+ private final String mUrl;
+
+ public HeaderConfig(JSONObject json) {
+ mImageUrl = json.optString(JSON_KEY_IMAGE_URL);
+ mUrl = json.optString(JSON_KEY_URL);
+ }
+
+ public HeaderConfig(Parcel in) {
+ mImageUrl = in.readString();
+ mUrl = in.readString();
+ }
+
+ public String getImageUrl() {
+ return mImageUrl;
+ }
+
+ public String getUrl() {
+ return mUrl;
+ }
+
+ public JSONObject toJSON() throws JSONException {
+ JSONObject json = new JSONObject();
+
+ json.put(JSON_KEY_IMAGE_URL, mImageUrl);
+ json.put(JSON_KEY_URL, mUrl);
+
+ return json;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeString(mImageUrl);
+ dest.writeString(mUrl);
+ }
+
+ public static final Creator<HeaderConfig> CREATOR = new Creator<HeaderConfig>() {
+ @Override
+ public HeaderConfig createFromParcel(Parcel source) {
+ return new HeaderConfig(source);
+ }
+
+ @Override
+ public HeaderConfig[] newArray(int size) {
+ return new HeaderConfig[size];
+ }
+ };
+ }
+
+ public static class AuthConfig implements Parcelable {
+ private final String mMessageText;
+ private final String mButtonText;
+ private final String mImageUrl;
+
+ static final String JSON_KEY_MESSAGE_TEXT = "messageText";
+ static final String JSON_KEY_BUTTON_TEXT = "buttonText";
+ static final String JSON_KEY_IMAGE_URL = "imageUrl";
+
+ public AuthConfig(JSONObject json) throws JSONException, IllegalArgumentException {
+ mMessageText = json.optString(JSON_KEY_MESSAGE_TEXT);
+ mButtonText = json.optString(JSON_KEY_BUTTON_TEXT);
+ mImageUrl = json.optString(JSON_KEY_IMAGE_URL, null);
+ }
+
+ public AuthConfig(Parcel in) {
+ mMessageText = in.readString();
+ mButtonText = in.readString();
+ mImageUrl = in.readString();
+
+ validate();
+ }
+
+ public AuthConfig(AuthConfig authConfig) {
+ mMessageText = authConfig.mMessageText;
+ mButtonText = authConfig.mButtonText;
+ mImageUrl = authConfig.mImageUrl;
+
+ validate();
+ }
+
+ public AuthConfig(String messageText, String buttonText, String imageUrl) {
+ mMessageText = messageText;
+ mButtonText = buttonText;
+ mImageUrl = imageUrl;
+
+ validate();
+ }
+
+ private void validate() {
+ if (mMessageText == null) {
+ throw new IllegalArgumentException("Can't create AuthConfig with null message text");
+ }
+
+ if (mButtonText == null) {
+ throw new IllegalArgumentException("Can't create AuthConfig with null button text");
+ }
+ }
+
+ public String getMessageText() {
+ return mMessageText;
+ }
+
+ public String getButtonText() {
+ return mButtonText;
+ }
+
+ public String getImageUrl() {
+ return mImageUrl;
+ }
+
+ public JSONObject toJSON() throws JSONException {
+ final JSONObject json = new JSONObject();
+
+ json.put(JSON_KEY_MESSAGE_TEXT, mMessageText);
+ json.put(JSON_KEY_BUTTON_TEXT, mButtonText);
+ json.put(JSON_KEY_IMAGE_URL, mImageUrl);
+
+ return json;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeString(mMessageText);
+ dest.writeString(mButtonText);
+ dest.writeString(mImageUrl);
+ }
+
+ public static final Creator<AuthConfig> CREATOR = new Creator<AuthConfig>() {
+ @Override
+ public AuthConfig createFromParcel(final Parcel in) {
+ return new AuthConfig(in);
+ }
+
+ @Override
+ public AuthConfig[] newArray(final int size) {
+ return new AuthConfig[size];
+ }
+ };
+ }
+ /**
+ * Immutable representation of the current state of {@code HomeConfig}.
+ * This is what HomeConfig returns from a load() call and takes as
+ * input to save a new state.
+ *
+ * Users of {@code State} should use an {@code Iterator} to iterate
+ * through the contained {@code PanelConfig} instances.
+ *
+ * {@code State} is immutable i.e. you can't add, remove, or update
+ * contained elements directly. You have to use an {@code Editor} to
+ * change the state, which can be created through the {@code edit()}
+ * method.
+ */
+ public static class State implements Iterable<PanelConfig> {
+ private HomeConfig mHomeConfig;
+ private final List<PanelConfig> mPanelConfigs;
+ private final boolean mIsDefault;
+
+ State(List<PanelConfig> panelConfigs, boolean isDefault) {
+ this(null, panelConfigs, isDefault);
+ }
+
+ private State(HomeConfig homeConfig, List<PanelConfig> panelConfigs, boolean isDefault) {
+ mHomeConfig = homeConfig;
+ mPanelConfigs = Collections.unmodifiableList(panelConfigs);
+ mIsDefault = isDefault;
+ }
+
+ private void setHomeConfig(HomeConfig homeConfig) {
+ if (mHomeConfig != null) {
+ throw new IllegalStateException("Can't set HomeConfig more than once");
+ }
+
+ mHomeConfig = homeConfig;
+ }
+
+ @Override
+ public Iterator<PanelConfig> iterator() {
+ return mPanelConfigs.iterator();
+ }
+
+ /**
+ * Returns whether this {@code State} instance represents the default
+ * {@code HomeConfig} configuration or not.
+ */
+ public boolean isDefault() {
+ return mIsDefault;
+ }
+
+ /**
+ * Creates an {@code Editor} for this state.
+ */
+ public Editor edit() {
+ return new Editor(mHomeConfig, this);
+ }
+ }
+
+ /**
+ * {@code Editor} allows you to make changes to a {@code State}. You
+ * can create {@code Editor} by calling {@code edit()} on the target
+ * {@code State} instance.
+ *
+ * {@code Editor} works on a copy of the {@code State} that originated
+ * it. This means that adding, removing, or updating panels in an
+ * {@code Editor} will never change the {@code State} which you
+ * created the {@code Editor} from. Calling {@code commit()} or
+ * {@code apply()} will cause the new {@code State} instance to be
+ * created and saved using the {@code HomeConfig} instance that
+ * created the source {@code State}.
+ *
+ * {@code Editor} is *not* thread-safe. You can only make calls on it
+ * from the thread where it was originally created. It will throw an
+ * exception if you don't follow this invariant.
+ */
+ public static class Editor implements Iterable<PanelConfig> {
+ private final HomeConfig mHomeConfig;
+ private final Map<String, PanelConfig> mConfigMap;
+ private final List<String> mConfigOrder;
+ private final Thread mOriginalThread;
+
+ // Each Pair represents parameters to a GeckoAppShell.notifyObservers call;
+ // the first String is the observer topic and the second string is the notification data.
+ private List<Pair<String, String>> mNotificationQueue;
+ private PanelConfig mDefaultPanel;
+ private int mEnabledCount;
+
+ private boolean mHasChanged;
+ private final boolean mIsFromDefault;
+
+ private Editor(HomeConfig homeConfig, State configState) {
+ mHomeConfig = homeConfig;
+ mOriginalThread = Thread.currentThread();
+ mConfigMap = new HashMap<String, PanelConfig>();
+ mConfigOrder = new LinkedList<String>();
+ mNotificationQueue = new ArrayList<>();
+
+ mIsFromDefault = configState.isDefault();
+
+ initFromState(configState);
+ }
+
+ /**
+ * Initialize the initial state of the editor from the given
+ * {@sode State}. A HashMap is used to represent the list of
+ * panels as it provides fast access, and a LinkedList is used to
+ * keep track of order. We keep a reference to the default panel
+ * and the number of enabled panels to avoid iterating through the
+ * map every time we need those.
+ *
+ * @param configState The source State to load the editor from.
+ */
+ private void initFromState(State configState) {
+ for (PanelConfig panelConfig : configState) {
+ final PanelConfig panelCopy = new PanelConfig(panelConfig);
+
+ if (!panelCopy.isDisabled()) {
+ mEnabledCount++;
+ }
+
+ if (panelCopy.isDefault()) {
+ if (mDefaultPanel == null) {
+ mDefaultPanel = panelCopy;
+ } else {
+ throw new IllegalStateException("Multiple default panels in HomeConfig state");
+ }
+ }
+
+ final String panelId = panelConfig.getId();
+ mConfigOrder.add(panelId);
+ mConfigMap.put(panelId, panelCopy);
+ }
+
+ // We should always have a defined default panel if there's
+ // at least one enabled panel around.
+ if (mEnabledCount > 0 && mDefaultPanel == null) {
+ throw new IllegalStateException("Default panel in HomeConfig state is undefined");
+ }
+ }
+
+ private PanelConfig getPanelOrThrow(String panelId) {
+ final PanelConfig panelConfig = mConfigMap.get(panelId);
+ if (panelConfig == null) {
+ throw new IllegalStateException("Tried to access non-existing panel: " + panelId);
+ }
+
+ return panelConfig;
+ }
+
+ private boolean isCurrentDefaultPanel(PanelConfig panelConfig) {
+ if (mDefaultPanel == null) {
+ return false;
+ }
+
+ return mDefaultPanel.equals(panelConfig);
+ }
+
+ private void findNewDefault() {
+ // Pick the first panel that is neither disabled nor currently
+ // set as default.
+ for (PanelConfig panelConfig : makeOrderedCopy(false)) {
+ if (!panelConfig.isDefault() && !panelConfig.isDisabled()) {
+ setDefault(panelConfig.getId());
+ return;
+ }
+ }
+
+ mDefaultPanel = null;
+ }
+
+ /**
+ * Makes an ordered list of PanelConfigs that can be references
+ * or deep copied objects.
+ *
+ * @param deepCopy true to make deep-copied objects
+ * @return ordered List of PanelConfigs
+ */
+ private List<PanelConfig> makeOrderedCopy(boolean deepCopy) {
+ final List<PanelConfig> copiedList = new ArrayList<PanelConfig>(mConfigOrder.size());
+ for (String panelId : mConfigOrder) {
+ PanelConfig panelConfig = mConfigMap.get(panelId);
+ if (deepCopy) {
+ panelConfig = new PanelConfig(panelConfig);
+ }
+ copiedList.add(panelConfig);
+ }
+
+ return copiedList;
+ }
+
+ private void setPanelIsDisabled(PanelConfig panelConfig, boolean disabled) {
+ if (panelConfig.isDisabled() == disabled) {
+ return;
+ }
+
+ panelConfig.setIsDisabled(disabled);
+ mEnabledCount += (disabled ? -1 : 1);
+ }
+
+ /**
+ * Gets the ID of the current default panel.
+ */
+ public String getDefaultPanelId() {
+ ThreadUtils.assertOnThread(mOriginalThread);
+
+ if (mDefaultPanel == null) {
+ return null;
+ }
+
+ return mDefaultPanel.getId();
+ }
+
+ /**
+ * Set a new default panel.
+ *
+ * @param panelId the ID of the new default panel.
+ */
+ public void setDefault(String panelId) {
+ ThreadUtils.assertOnThread(mOriginalThread);
+
+ final PanelConfig panelConfig = getPanelOrThrow(panelId);
+ if (isCurrentDefaultPanel(panelConfig)) {
+ return;
+ }
+
+ if (mDefaultPanel != null) {
+ mDefaultPanel.setIsDefault(false);
+ }
+
+ panelConfig.setIsDefault(true);
+ setPanelIsDisabled(panelConfig, false);
+
+ mDefaultPanel = panelConfig;
+ mHasChanged = true;
+ }
+
+ /**
+ * Toggles disabled state for a panel.
+ *
+ * @param panelId the ID of the target panel.
+ * @param disabled true to disable the panel.
+ */
+ public void setDisabled(String panelId, boolean disabled) {
+ ThreadUtils.assertOnThread(mOriginalThread);
+
+ final PanelConfig panelConfig = getPanelOrThrow(panelId);
+ if (panelConfig.isDisabled() == disabled) {
+ return;
+ }
+
+ setPanelIsDisabled(panelConfig, disabled);
+
+ if (disabled) {
+ if (isCurrentDefaultPanel(panelConfig)) {
+ panelConfig.setIsDefault(false);
+ findNewDefault();
+ }
+ } else if (mEnabledCount == 1) {
+ setDefault(panelId);
+ }
+
+ mHasChanged = true;
+ }
+
+ /**
+ * Adds a new {@code PanelConfig}. It will do nothing if the
+ * {@code Editor} already contains a panel with the same ID.
+ *
+ * @param panelConfig the {@code PanelConfig} instance to be added.
+ * @return true if the item has been added.
+ */
+ public boolean install(PanelConfig panelConfig) {
+ ThreadUtils.assertOnThread(mOriginalThread);
+
+ if (panelConfig == null) {
+ throw new IllegalStateException("Can't install a null panel");
+ }
+
+ if (!panelConfig.isDynamic()) {
+ throw new IllegalStateException("Can't install a built-in panel: " + panelConfig.getId());
+ }
+
+ if (panelConfig.isDisabled()) {
+ throw new IllegalStateException("Can't install a disabled panel: " + panelConfig.getId());
+ }
+
+ boolean installed = false;
+
+ final String id = panelConfig.getId();
+ if (!mConfigMap.containsKey(id)) {
+ mConfigMap.put(id, panelConfig);
+
+ final int position = panelConfig.getPosition();
+ if (position < 0 || position >= mConfigOrder.size()) {
+ mConfigOrder.add(id);
+ } else {
+ mConfigOrder.add(position, id);
+ }
+
+ mEnabledCount++;
+ if (mEnabledCount == 1 || panelConfig.isDefault()) {
+ setDefault(panelConfig.getId());
+ }
+
+ installed = true;
+
+ // Add an event to the queue if a new panel is successfully installed.
+ mNotificationQueue.add(new Pair<String, String>(
+ "HomePanels:Installed", panelConfig.getId()));
+ }
+
+ mHasChanged = true;
+ return installed;
+ }
+
+ /**
+ * Removes an existing panel.
+ *
+ * @return true if the item has been removed.
+ */
+ public boolean uninstall(String panelId) {
+ ThreadUtils.assertOnThread(mOriginalThread);
+
+ final PanelConfig panelConfig = mConfigMap.get(panelId);
+ if (panelConfig == null) {
+ return false;
+ }
+
+ if (!panelConfig.isDynamic()) {
+ throw new IllegalStateException("Can't uninstall a built-in panel: " + panelConfig.getId());
+ }
+
+ mConfigMap.remove(panelId);
+ mConfigOrder.remove(panelId);
+
+ if (!panelConfig.isDisabled()) {
+ mEnabledCount--;
+ }
+
+ if (isCurrentDefaultPanel(panelConfig)) {
+ findNewDefault();
+ }
+
+ // Add an event to the queue if a panel is successfully uninstalled.
+ mNotificationQueue.add(new Pair<String, String>("HomePanels:Uninstalled", panelId));
+
+ mHasChanged = true;
+ return true;
+ }
+
+ /**
+ * Moves panel associated with panelId to the specified position.
+ *
+ * @param panelId Id of panel
+ * @param destIndex Destination position
+ * @return true if move succeeded
+ */
+ public boolean moveTo(String panelId, int destIndex) {
+ ThreadUtils.assertOnThread(mOriginalThread);
+
+ if (!mConfigOrder.contains(panelId)) {
+ return false;
+ }
+
+ mConfigOrder.remove(panelId);
+ mConfigOrder.add(destIndex, panelId);
+ mHasChanged = true;
+
+ return true;
+ }
+
+ /**
+ * Replaces an existing panel with a new {@code PanelConfig} instance.
+ *
+ * @return true if the item has been updated.
+ */
+ public boolean update(PanelConfig panelConfig) {
+ ThreadUtils.assertOnThread(mOriginalThread);
+
+ if (panelConfig == null) {
+ throw new IllegalStateException("Can't update a null panel");
+ }
+
+ boolean updated = false;
+
+ final String id = panelConfig.getId();
+ if (mConfigMap.containsKey(id)) {
+ final PanelConfig oldPanelConfig = mConfigMap.put(id, panelConfig);
+
+ // The disabled and default states can't never be
+ // changed by an update operation.
+ panelConfig.setIsDefault(oldPanelConfig.isDefault());
+ panelConfig.setIsDisabled(oldPanelConfig.isDisabled());
+
+ updated = true;
+ }
+
+ mHasChanged = true;
+ return updated;
+ }
+
+ /**
+ * Saves the current {@code Editor} state asynchronously in the
+ * background thread.
+ *
+ * @return the resulting {@code State} instance.
+ */
+ public State apply() {
+ ThreadUtils.assertOnThread(mOriginalThread);
+
+ // We're about to save the current state in the background thread
+ // so we should use a deep copy of the PanelConfig instances to
+ // avoid saving corrupted state.
+ final State newConfigState =
+ new State(mHomeConfig, makeOrderedCopy(true), isDefault());
+
+ // Copy the event queue to a new list, so that we only modify mNotificationQueue on
+ // the original thread where it was created.
+ final List<Pair<String, String>> copiedQueue = mNotificationQueue;
+ mNotificationQueue = new ArrayList<>();
+
+ ThreadUtils.getBackgroundHandler().post(new Runnable() {
+ @Override
+ public void run() {
+ mHomeConfig.save(newConfigState);
+
+ // Send pending events after the new config is saved.
+ sendNotificationsToGecko(copiedQueue);
+ }
+ });
+
+ return newConfigState;
+ }
+
+ /**
+ * Saves the current {@code Editor} state synchronously in the
+ * current thread.
+ *
+ * @return the resulting {@code State} instance.
+ */
+ public State commit() {
+ ThreadUtils.assertOnThread(mOriginalThread);
+
+ final State newConfigState =
+ new State(mHomeConfig, makeOrderedCopy(false), isDefault());
+
+ // This is a synchronous blocking operation, hence no
+ // need to deep copy the current PanelConfig instances.
+ mHomeConfig.save(newConfigState);
+
+ // Send pending events after the new config is saved.
+ sendNotificationsToGecko(mNotificationQueue);
+ mNotificationQueue.clear();
+
+ return newConfigState;
+ }
+
+ /**
+ * Returns whether the {@code Editor} represents the default
+ * {@code HomeConfig} configuration without any unsaved changes.
+ */
+ public boolean isDefault() {
+ ThreadUtils.assertOnThread(mOriginalThread);
+
+ return (!mHasChanged && mIsFromDefault);
+ }
+
+ public boolean isEmpty() {
+ return mConfigMap.isEmpty();
+ }
+
+ private void sendNotificationsToGecko(List<Pair<String, String>> notifications) {
+ for (Pair<String, String> p : notifications) {
+ GeckoAppShell.notifyObservers(p.first, p.second);
+ }
+ }
+
+ private class EditorIterator implements Iterator<PanelConfig> {
+ private final Iterator<String> mOrderIterator;
+
+ public EditorIterator() {
+ mOrderIterator = mConfigOrder.iterator();
+ }
+
+ @Override
+ public boolean hasNext() {
+ return mOrderIterator.hasNext();
+ }
+
+ @Override
+ public PanelConfig next() {
+ final String panelId = mOrderIterator.next();
+ return mConfigMap.get(panelId);
+ }
+
+ @Override
+ public void remove() {
+ throw new UnsupportedOperationException("Can't 'remove' from on Editor iterator.");
+ }
+ }
+
+ @Override
+ public Iterator<PanelConfig> iterator() {
+ ThreadUtils.assertOnThread(mOriginalThread);
+
+ return new EditorIterator();
+ }
+ }
+
+ public interface OnReloadListener {
+ public void onReload();
+ }
+
+ public interface HomeConfigBackend {
+ public State load();
+ public void save(State configState);
+ public String getLocale();
+ public void setOnReloadListener(OnReloadListener listener);
+ }
+
+ // UUIDs used to create PanelConfigs for default built-in panels. These are
+ // public because they can be used in "about:home?panel=UUID" query strings
+ // to open specific panels without querying the active Home Panel
+ // configuration. Because they don't consider the active configuration, it
+ // is only sensible to do this for built-in panels (and not for dynamic
+ // panels).
+ private static final String TOP_SITES_PANEL_ID = "4becc86b-41eb-429a-a042-88fe8b5a094e";
+ private static final String BOOKMARKS_PANEL_ID = "7f6d419a-cd6c-4e34-b26f-f68b1b551907";
+ private static final String HISTORY_PANEL_ID = "f134bf20-11f7-4867-ab8b-e8e705d7fbe8";
+ private static final String COMBINED_HISTORY_PANEL_ID = "4d716ce2-e063-486d-9e7c-b190d7b04dc6";
+ private static final String RECENT_TABS_PANEL_ID = "5c2601a5-eedc-4477-b297-ce4cef52adf8";
+ private static final String REMOTE_TABS_PANEL_ID = "72429afd-8d8b-43d8-9189-14b779c563d0";
+ private static final String DEPRECATED_READING_LIST_PANEL_ID = "20f4549a-64ad-4c32-93e4-1dcef792733b";
+
+ private final HomeConfigBackend mBackend;
+
+ public HomeConfig(HomeConfigBackend backend) {
+ mBackend = backend;
+ }
+
+ public State load() {
+ final State configState = mBackend.load();
+ configState.setHomeConfig(this);
+
+ return configState;
+ }
+
+ public String getLocale() {
+ return mBackend.getLocale();
+ }
+
+ public void save(State configState) {
+ mBackend.save(configState);
+ }
+
+ public void setOnReloadListener(OnReloadListener listener) {
+ mBackend.setOnReloadListener(listener);
+ }
+
+ public static PanelConfig createBuiltinPanelConfig(Context context, PanelType panelType) {
+ return createBuiltinPanelConfig(context, panelType, EnumSet.noneOf(PanelConfig.Flags.class));
+ }
+
+ public static int getTitleResourceIdForBuiltinPanelType(PanelType panelType) {
+ switch (panelType) {
+ case TOP_SITES:
+ return R.string.home_top_sites_title;
+
+ case BOOKMARKS:
+ case DEPRECATED_READING_LIST:
+ return R.string.bookmarks_title;
+
+ case DEPRECATED_HISTORY:
+ case DEPRECATED_REMOTE_TABS:
+ case DEPRECATED_RECENT_TABS:
+ case COMBINED_HISTORY:
+ return R.string.home_history_title;
+
+ default:
+ throw new IllegalArgumentException("Only for built-in panel types: " + panelType);
+ }
+ }
+
+ public static String getIdForBuiltinPanelType(PanelType panelType) {
+ switch (panelType) {
+ case TOP_SITES:
+ return TOP_SITES_PANEL_ID;
+
+ case BOOKMARKS:
+ return BOOKMARKS_PANEL_ID;
+
+ case DEPRECATED_HISTORY:
+ return HISTORY_PANEL_ID;
+
+ case COMBINED_HISTORY:
+ return COMBINED_HISTORY_PANEL_ID;
+
+ case DEPRECATED_REMOTE_TABS:
+ return REMOTE_TABS_PANEL_ID;
+
+ case DEPRECATED_READING_LIST:
+ return DEPRECATED_READING_LIST_PANEL_ID;
+
+ case DEPRECATED_RECENT_TABS:
+ return RECENT_TABS_PANEL_ID;
+
+ default:
+ throw new IllegalArgumentException("Only for built-in panel types: " + panelType);
+ }
+ }
+
+ public static PanelConfig createBuiltinPanelConfig(Context context, PanelType panelType, EnumSet<PanelConfig.Flags> flags) {
+ final int titleId = getTitleResourceIdForBuiltinPanelType(panelType);
+ final String id = getIdForBuiltinPanelType(panelType);
+
+ return new PanelConfig(panelType, context.getString(titleId), id, flags);
+ }
+
+ public static HomeConfig getDefault(Context context) {
+ return new HomeConfig(new HomeConfigPrefsBackend(context));
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomeConfigLoader.java b/mobile/android/base/java/org/mozilla/gecko/home/HomeConfigLoader.java
new file mode 100644
index 000000000..914d0fdd1
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/HomeConfigLoader.java
@@ -0,0 +1,83 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import org.mozilla.gecko.home.HomeConfig.OnReloadListener;
+
+import android.content.Context;
+import android.support.v4.content.AsyncTaskLoader;
+
+public class HomeConfigLoader extends AsyncTaskLoader<HomeConfig.State> {
+ private final HomeConfig mConfig;
+ private HomeConfig.State mConfigState;
+
+ private final Context mContext;
+
+ public HomeConfigLoader(Context context, HomeConfig homeConfig) {
+ super(context);
+ mContext = context;
+ mConfig = homeConfig;
+ }
+
+ @Override
+ public HomeConfig.State loadInBackground() {
+ return mConfig.load();
+ }
+
+ @Override
+ public void deliverResult(HomeConfig.State configState) {
+ if (isReset()) {
+ mConfigState = null;
+ return;
+ }
+
+ mConfigState = configState;
+ mConfig.setOnReloadListener(new ForceReloadListener());
+
+ if (isStarted()) {
+ super.deliverResult(configState);
+ }
+ }
+
+ @Override
+ protected void onStartLoading() {
+ if (mConfigState != null) {
+ deliverResult(mConfigState);
+ }
+
+ if (takeContentChanged() || mConfigState == null) {
+ forceLoad();
+ }
+ }
+
+ @Override
+ protected void onStopLoading() {
+ cancelLoad();
+ }
+
+ @Override
+ public void onCanceled(HomeConfig.State configState) {
+ mConfigState = null;
+ }
+
+ @Override
+ protected void onReset() {
+ super.onReset();
+
+ // Ensure the loader is stopped.
+ onStopLoading();
+
+ mConfigState = null;
+ mConfig.setOnReloadListener(null);
+ }
+
+ private class ForceReloadListener implements OnReloadListener {
+ @Override
+ public void onReload() {
+ onContentChanged();
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomeConfigPrefsBackend.java b/mobile/android/base/java/org/mozilla/gecko/home/HomeConfigPrefsBackend.java
new file mode 100644
index 000000000..a2d80788c
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/HomeConfigPrefsBackend.java
@@ -0,0 +1,663 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import static org.mozilla.gecko.home.HomeConfig.createBuiltinPanelConfig;
+
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.Locale;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.home.HomeConfig.HomeConfigBackend;
+import org.mozilla.gecko.home.HomeConfig.OnReloadListener;
+import org.mozilla.gecko.home.HomeConfig.PanelConfig;
+import org.mozilla.gecko.home.HomeConfig.PanelType;
+import org.mozilla.gecko.home.HomeConfig.State;
+import org.mozilla.gecko.util.HardwareUtils;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
+import android.support.annotation.CheckResult;
+import android.support.annotation.VisibleForTesting;
+import android.support.v4.content.LocalBroadcastManager;
+import android.text.TextUtils;
+import android.util.Log;
+
+public class HomeConfigPrefsBackend implements HomeConfigBackend {
+ private static final String LOGTAG = "GeckoHomeConfigBackend";
+
+ // Increment this to trigger a migration.
+ @VisibleForTesting
+ static final int VERSION = 8;
+
+ // This key was originally used to store only an array of panel configs.
+ public static final String PREFS_CONFIG_KEY_OLD = "home_panels";
+
+ // This key is now used to store a version number with the array of panel configs.
+ public static final String PREFS_CONFIG_KEY = "home_panels_with_version";
+
+ // Keys used with JSON object stored in prefs.
+ private static final String JSON_KEY_PANELS = "panels";
+ private static final String JSON_KEY_VERSION = "version";
+
+ private static final String PREFS_LOCALE_KEY = "home_locale";
+
+ private static final String RELOAD_BROADCAST = "HomeConfigPrefsBackend:Reload";
+
+ private final Context mContext;
+ private ReloadBroadcastReceiver mReloadBroadcastReceiver;
+ private OnReloadListener mReloadListener;
+
+ private static boolean sMigrationDone;
+
+ public HomeConfigPrefsBackend(Context context) {
+ mContext = context;
+ }
+
+ private SharedPreferences getSharedPreferences() {
+ return GeckoSharedPrefs.forProfile(mContext);
+ }
+
+ private State loadDefaultConfig() {
+ final ArrayList<PanelConfig> panelConfigs = new ArrayList<PanelConfig>();
+
+ panelConfigs.add(createBuiltinPanelConfig(mContext, PanelType.TOP_SITES,
+ EnumSet.of(PanelConfig.Flags.DEFAULT_PANEL)));
+
+ panelConfigs.add(createBuiltinPanelConfig(mContext, PanelType.BOOKMARKS));
+ panelConfigs.add(createBuiltinPanelConfig(mContext, PanelType.COMBINED_HISTORY));
+
+
+ return new State(panelConfigs, true);
+ }
+
+ /**
+ * Iterate through the panels to check if they are all disabled.
+ */
+ private static boolean allPanelsAreDisabled(JSONArray jsonPanels) throws JSONException {
+ final int count = jsonPanels.length();
+ for (int i = 0; i < count; i++) {
+ final JSONObject jsonPanelConfig = jsonPanels.getJSONObject(i);
+
+ if (!jsonPanelConfig.optBoolean(PanelConfig.JSON_KEY_DISABLED, false)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ protected enum Position {
+ NONE, // Not present.
+ FRONT, // At the front of the list of panels.
+ BACK, // At the back of the list of panels.
+ }
+
+ /**
+ * Create and insert a built-in panel configuration.
+ *
+ * @param context Android context.
+ * @param jsonPanels array of JSON panels to update in place.
+ * @param panelType to add.
+ * @param positionOnPhones where to place the new panel on phones.
+ * @param positionOnTablets where to place the new panel on tablets.
+ * @throws JSONException
+ */
+ protected static void addBuiltinPanelConfig(Context context, JSONArray jsonPanels,
+ PanelType panelType, Position positionOnPhones, Position positionOnTablets) throws JSONException {
+ // Add the new panel.
+ final JSONObject jsonPanelConfig =
+ createBuiltinPanelConfig(context, panelType).toJSON();
+
+ // If any panel is enabled, then we should make the new panel enabled.
+ jsonPanelConfig.put(PanelConfig.JSON_KEY_DISABLED,
+ allPanelsAreDisabled(jsonPanels));
+
+ final boolean isTablet = HardwareUtils.isTablet();
+ final boolean isPhone = !isTablet;
+
+ // Maybe add the new panel to the front of the array.
+ if ((isPhone && positionOnPhones == Position.FRONT) ||
+ (isTablet && positionOnTablets == Position.FRONT)) {
+ // This is an inefficient way to stretch [a, b, c] to [a, a, b, c].
+ for (int i = jsonPanels.length(); i >= 1; i--) {
+ jsonPanels.put(i, jsonPanels.get(i - 1));
+ }
+ // And this inserts [d, a, b, c].
+ jsonPanels.put(0, jsonPanelConfig);
+ }
+
+ // Maybe add the new panel to the back of the array.
+ if ((isPhone && positionOnPhones == Position.BACK) ||
+ (isTablet && positionOnTablets == Position.BACK)) {
+ jsonPanels.put(jsonPanelConfig);
+ }
+ }
+
+ /**
+ * Updates the panels to combine the History and Sync panels into the (Combined) History panel.
+ *
+ * Tries to replace the History panel with the Combined History panel if visible, or falls back to
+ * replacing the Sync panel if it's visible. That way, we minimize panel reordering during a migration.
+ * @param context Android context
+ * @param jsonPanels array of original JSON panels
+ * @return new array of updated JSON panels
+ * @throws JSONException
+ */
+ private static JSONArray combineHistoryAndSyncPanels(Context context, JSONArray jsonPanels) throws JSONException {
+ EnumSet<PanelConfig.Flags> historyFlags = null;
+ EnumSet<PanelConfig.Flags> syncFlags = null;
+
+ int historyIndex = -1;
+ int syncIndex = -1;
+
+ // Determine state and location of History and Sync panels.
+ for (int i = 0; i < jsonPanels.length(); i++) {
+ JSONObject panelObj = jsonPanels.getJSONObject(i);
+ final PanelConfig panelConfig = new PanelConfig(panelObj);
+ final PanelType type = panelConfig.getType();
+ if (type == PanelType.DEPRECATED_HISTORY) {
+ historyIndex = i;
+ historyFlags = panelConfig.getFlags();
+ } else if (type == PanelType.DEPRECATED_REMOTE_TABS) {
+ syncIndex = i;
+ syncFlags = panelConfig.getFlags();
+ } else if (type == PanelType.COMBINED_HISTORY) {
+ // Partial landing of bug 1220928 combined the History and Sync panels of users who didn't
+ // have home panel customizations (including new users), thus they don't this migration.
+ return jsonPanels;
+ }
+ }
+
+ if (historyIndex == -1 || syncIndex == -1) {
+ throw new IllegalArgumentException("Expected both History and Sync panels to be present prior to Combined History.");
+ }
+
+ PanelConfig newPanel;
+ int replaceIndex;
+ int removeIndex;
+
+ if (historyFlags.contains(PanelConfig.Flags.DISABLED_PANEL) && !syncFlags.contains(PanelConfig.Flags.DISABLED_PANEL)) {
+ // Replace the Sync panel if it's visible and the History panel is disabled.
+ replaceIndex = syncIndex;
+ removeIndex = historyIndex;
+ newPanel = createBuiltinPanelConfig(context, PanelType.COMBINED_HISTORY, syncFlags);
+ } else {
+ // Otherwise, just replace the History panel.
+ replaceIndex = historyIndex;
+ removeIndex = syncIndex;
+ newPanel = createBuiltinPanelConfig(context, PanelType.COMBINED_HISTORY, historyFlags);
+ }
+
+ // Copy the array with updated panel and removed panel.
+ final JSONArray newArray = new JSONArray();
+ for (int i = 0; i < jsonPanels.length(); i++) {
+ if (i == replaceIndex) {
+ newArray.put(newPanel.toJSON());
+ } else if (i == removeIndex) {
+ continue;
+ } else {
+ newArray.put(jsonPanels.get(i));
+ }
+ }
+
+ return newArray;
+ }
+
+ /**
+ * Iterate over all homepanels to verify that there is at least one default panel. If there is
+ * no default panel, set History as the default panel. (This is only relevant for two botched
+ * migrations where the history panel should have been made the default panel, but wasn't.)
+ */
+ private static void ensureDefaultPanelForV5orV8(Context context, JSONArray jsonPanels) throws JSONException {
+ int historyIndex = -1;
+
+ // If all panels are disabled, there is no default panel - this is the only valid state
+ // that has no default. We can use this flag to track whether any visible panels have been
+ // found.
+ boolean enabledPanelsFound = false;
+
+ for (int i = 0; i < jsonPanels.length(); i++) {
+ final PanelConfig panelConfig = new PanelConfig(jsonPanels.getJSONObject(i));
+ if (panelConfig.isDefault()) {
+ return;
+ }
+
+ if (!panelConfig.isDisabled()) {
+ enabledPanelsFound = true;
+ }
+
+ if (panelConfig.getType() == PanelType.COMBINED_HISTORY) {
+ historyIndex = i;
+ }
+ }
+
+ if (!enabledPanelsFound) {
+ // No panels are enabled, hence there can be no default (see noEnabledPanelsFound declaration
+ // for more information).
+ return;
+ }
+
+ // Make the History panel default. We can't modify existing PanelConfigs, so make a new one.
+ final PanelConfig historyPanelConfig = createBuiltinPanelConfig(context, PanelType.COMBINED_HISTORY, EnumSet.of(PanelConfig.Flags.DEFAULT_PANEL));
+ jsonPanels.put(historyIndex, historyPanelConfig.toJSON());
+ }
+
+ /**
+ * Removes a panel from the home panel config.
+ * If the removed panel was set as the default home panel, we provide a replacement for it.
+ *
+ * @param context Android context
+ * @param jsonPanels array of original JSON panels
+ * @param panelToRemove The home panel to be removed.
+ * @param replacementPanel The panel which will replace it if the removed panel
+ * was the default home panel.
+ * @param alwaysUnhide If true, the replacement panel will always be unhidden,
+ * otherwise only if we turn it into the new default panel.
+ * @return new array of updated JSON panels
+ * @throws JSONException
+ */
+ private static JSONArray removePanel(Context context, JSONArray jsonPanels,
+ PanelType panelToRemove, PanelType replacementPanel, boolean alwaysUnhide) throws JSONException {
+ boolean wasDefault = false;
+ boolean wasDisabled = false;
+ int replacementPanelIndex = -1;
+ boolean replacementWasDefault = false;
+
+ // JSONArrary doesn't provide remove() for API < 19, therefore we need to manually copy all
+ // the items we don't want deleted into a new array.
+ final JSONArray newJSONPanels = new JSONArray();
+
+ for (int i = 0; i < jsonPanels.length(); i++) {
+ final JSONObject panelJSON = jsonPanels.getJSONObject(i);
+ final PanelConfig panelConfig = new PanelConfig(panelJSON);
+
+ if (panelConfig.getType() == panelToRemove) {
+ // If this panel was the default we'll need to assign a new default:
+ wasDefault = panelConfig.isDefault();
+ wasDisabled = panelConfig.isDisabled();
+ } else {
+ if (panelConfig.getType() == replacementPanel) {
+ replacementPanelIndex = newJSONPanels.length();
+ if (panelConfig.isDefault()) {
+ replacementWasDefault = true;
+ }
+ }
+
+ newJSONPanels.put(panelJSON);
+ }
+ }
+
+ // Unless alwaysUnhide is true, we make the replacement panel visible only if it is going
+ // to be the new default panel, since a hidden default panel doesn't make sense.
+ // This is to allow preserving the behaviour of the original reading list migration function.
+ if ((wasDefault || alwaysUnhide) && !wasDisabled) {
+ final JSONObject replacementPanelConfig;
+ if (wasDefault) {
+ // If the removed panel was the default, the replacement has to be made the new default
+ replacementPanelConfig = createBuiltinPanelConfig(context, replacementPanel, EnumSet.of(PanelConfig.Flags.DEFAULT_PANEL)).toJSON();
+ } else {
+ final EnumSet<HomeConfig.PanelConfig.Flags> flags;
+ if (replacementWasDefault) {
+ // However if the replacement panel was already default, we need to preserve it's default status
+ // (By rewriting the PanelConfig, we lose all existing flags, so we need to make sure desired
+ // flags are retained - in this case there's only DEFAULT_PANEL, which is mutually
+ // exclusive with the DISABLE_PANEL case).
+ flags = EnumSet.of(PanelConfig.Flags.DEFAULT_PANEL);
+ } else {
+ flags = EnumSet.noneOf(PanelConfig.Flags.class);
+ }
+
+ // The panel is visible since we don't set Flags.DISABLED_PANEL.
+ replacementPanelConfig = createBuiltinPanelConfig(context, replacementPanel, flags).toJSON();
+ }
+
+ if (replacementPanelIndex != -1) {
+ newJSONPanels.put(replacementPanelIndex, replacementPanelConfig);
+ } else {
+ newJSONPanels.put(replacementPanelConfig);
+ }
+ }
+
+ return newJSONPanels;
+ }
+
+ /**
+ * Checks to see if the reading list panel already exists.
+ *
+ * @param jsonPanels JSONArray array representing the curent set of panel configs.
+ *
+ * @return boolean Whether or not the reading list panel exists.
+ */
+ private static boolean readingListPanelExists(JSONArray jsonPanels) {
+ final int count = jsonPanels.length();
+ for (int i = 0; i < count; i++) {
+ try {
+ final JSONObject jsonPanelConfig = jsonPanels.getJSONObject(i);
+ final PanelConfig panelConfig = new PanelConfig(jsonPanelConfig);
+ if (panelConfig.getType() == PanelType.DEPRECATED_READING_LIST) {
+ return true;
+ }
+ } catch (Exception e) {
+ // It's okay to ignore this exception, since an invalid reading list
+ // panel config is equivalent to no reading list panel.
+ Log.e(LOGTAG, "Exception loading PanelConfig from JSON", e);
+ }
+ }
+ return false;
+ }
+
+ @CheckResult
+ static synchronized JSONArray migratePrefsFromVersionToVersion(final Context context, final int currentVersion, final int newVersion,
+ final JSONArray jsonPanelsIn, final SharedPreferences.Editor prefsEditor) throws JSONException {
+
+ JSONArray jsonPanels = jsonPanelsIn;
+
+ for (int v = currentVersion + 1; v <= newVersion; v++) {
+ Log.d(LOGTAG, "Migrating to version = " + v);
+
+ switch (v) {
+ case 1:
+ // Add "Recent Tabs" panel.
+ addBuiltinPanelConfig(context, jsonPanels,
+ PanelType.DEPRECATED_RECENT_TABS, Position.FRONT, Position.BACK);
+
+ // Remove the old pref key.
+ prefsEditor.remove(PREFS_CONFIG_KEY_OLD);
+ break;
+
+ case 2:
+ // Add "Remote Tabs"/"Synced Tabs" panel.
+ addBuiltinPanelConfig(context, jsonPanels,
+ PanelType.DEPRECATED_REMOTE_TABS, Position.FRONT, Position.BACK);
+ break;
+
+ case 3:
+ // Add the "Reading List" panel if it does not exist. At one time,
+ // the Reading List panel was shown only to devices that were not
+ // considered "low memory". Now, we expose the panel to all devices.
+ // This migration should only occur for "low memory" devices.
+ // Note: This will not agree with the default configuration, which
+ // has DEPRECATED_REMOTE_TABS after DEPRECATED_READING_LIST on some devices.
+ if (!readingListPanelExists(jsonPanels)) {
+ addBuiltinPanelConfig(context, jsonPanels,
+ PanelType.DEPRECATED_READING_LIST, Position.BACK, Position.BACK);
+ }
+ break;
+
+ case 4:
+ // Combine the History and Sync panels. In order to minimize an unexpected reordering
+ // of panels, we try to replace the History panel if it's visible, and fall back to
+ // the Sync panel if that's visible.
+ jsonPanels = combineHistoryAndSyncPanels(context, jsonPanels);
+ break;
+
+ case 5:
+ // This is the fix for bug 1264136 where we lost track of the default panel during some migrations.
+ ensureDefaultPanelForV5orV8(context, jsonPanels);
+ break;
+
+ case 6:
+ jsonPanels = removePanel(context, jsonPanels,
+ PanelType.DEPRECATED_READING_LIST, PanelType.BOOKMARKS, false);
+ break;
+
+ case 7:
+ jsonPanels = removePanel(context, jsonPanels,
+ PanelType.DEPRECATED_RECENT_TABS, PanelType.COMBINED_HISTORY, true);
+ break;
+
+ case 8:
+ // Similar to "case 5" above, this time 1304777 - once again we lost track
+ // of the history panel
+ ensureDefaultPanelForV5orV8(context, jsonPanels);
+ break;
+ }
+ }
+
+ return jsonPanels;
+ }
+
+ /**
+ * Migrates JSON config data storage.
+ *
+ * @param context Context used to get shared preferences and create built-in panel.
+ * @param jsonString String currently stored in preferences.
+ *
+ * @return JSONArray array representing new set of panel configs.
+ */
+ private static synchronized JSONArray maybePerformMigration(Context context, String jsonString) throws JSONException {
+ // If the migration is already done, we're at the current version.
+ if (sMigrationDone) {
+ final JSONObject json = new JSONObject(jsonString);
+ return json.getJSONArray(JSON_KEY_PANELS);
+ }
+
+ // Make sure we only do this version check once.
+ sMigrationDone = true;
+
+ JSONArray jsonPanels;
+ final int version;
+
+ final SharedPreferences prefs = GeckoSharedPrefs.forProfile(context);
+ if (prefs.contains(PREFS_CONFIG_KEY_OLD)) {
+ // Our original implementation did not contain versioning, so this is implicitly version 0.
+ jsonPanels = new JSONArray(jsonString);
+ version = 0;
+ } else {
+ final JSONObject json = new JSONObject(jsonString);
+ jsonPanels = json.getJSONArray(JSON_KEY_PANELS);
+ version = json.getInt(JSON_KEY_VERSION);
+ }
+
+ if (version == VERSION) {
+ return jsonPanels;
+ }
+
+ Log.d(LOGTAG, "Performing migration");
+
+ final SharedPreferences.Editor prefsEditor = prefs.edit();
+
+ jsonPanels = migratePrefsFromVersionToVersion(context, version, VERSION, jsonPanels, prefsEditor);
+
+ // Save the new panel config and the new version number.
+ final JSONObject newJson = new JSONObject();
+ newJson.put(JSON_KEY_PANELS, jsonPanels);
+ newJson.put(JSON_KEY_VERSION, VERSION);
+
+ prefsEditor.putString(PREFS_CONFIG_KEY, newJson.toString());
+ prefsEditor.apply();
+
+ return jsonPanels;
+ }
+
+ private State loadConfigFromString(String jsonString) {
+ final JSONArray jsonPanelConfigs;
+ try {
+ jsonPanelConfigs = maybePerformMigration(mContext, jsonString);
+ updatePrefsFromConfig(jsonPanelConfigs);
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error loading the list of home panels from JSON prefs", e);
+
+ // Fallback to default config
+ return loadDefaultConfig();
+ }
+
+ final ArrayList<PanelConfig> panelConfigs = new ArrayList<PanelConfig>();
+
+ final int count = jsonPanelConfigs.length();
+ for (int i = 0; i < count; i++) {
+ try {
+ final JSONObject jsonPanelConfig = jsonPanelConfigs.getJSONObject(i);
+ final PanelConfig panelConfig = new PanelConfig(jsonPanelConfig);
+ panelConfigs.add(panelConfig);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Exception loading PanelConfig from JSON", e);
+ }
+ }
+
+ return new State(panelConfigs, false);
+ }
+
+ @Override
+ public State load() {
+ final SharedPreferences prefs = getSharedPreferences();
+
+ final String key = (prefs.contains(PREFS_CONFIG_KEY_OLD) ? PREFS_CONFIG_KEY_OLD : PREFS_CONFIG_KEY);
+ final String jsonString = prefs.getString(key, null);
+
+ final State configState;
+ if (TextUtils.isEmpty(jsonString)) {
+ configState = loadDefaultConfig();
+ } else {
+ configState = loadConfigFromString(jsonString);
+ }
+
+ return configState;
+ }
+
+ @Override
+ public void save(State configState) {
+ final SharedPreferences prefs = getSharedPreferences();
+ final SharedPreferences.Editor editor = prefs.edit();
+
+ // No need to save the state to disk if it represents the default
+ // HomeConfig configuration. Simply force all existing HomeConfigLoader
+ // instances to refresh their contents.
+ if (!configState.isDefault()) {
+ final JSONArray jsonPanelConfigs = new JSONArray();
+
+ for (PanelConfig panelConfig : configState) {
+ try {
+ final JSONObject jsonPanelConfig = panelConfig.toJSON();
+ jsonPanelConfigs.put(jsonPanelConfig);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Exception converting PanelConfig to JSON", e);
+ }
+ }
+
+ try {
+ final JSONObject json = new JSONObject();
+ json.put(JSON_KEY_PANELS, jsonPanelConfigs);
+ json.put(JSON_KEY_VERSION, VERSION);
+
+ editor.putString(PREFS_CONFIG_KEY, json.toString());
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Exception saving PanelConfig state", e);
+ }
+ }
+
+ editor.putString(PREFS_LOCALE_KEY, Locale.getDefault().toString());
+ editor.apply();
+
+ // Trigger reload listeners on all live backend instances
+ sendReloadBroadcast();
+ }
+
+ @Override
+ public String getLocale() {
+ final SharedPreferences prefs = getSharedPreferences();
+
+ String locale = prefs.getString(PREFS_LOCALE_KEY, null);
+ if (locale == null) {
+ // Initialize config with the current locale
+ final String currentLocale = Locale.getDefault().toString();
+
+ final SharedPreferences.Editor editor = prefs.edit();
+ editor.putString(PREFS_LOCALE_KEY, currentLocale);
+ editor.apply();
+
+ // If the user has saved HomeConfig before, return null this
+ // one time to trigger a refresh and ensure we use the
+ // correct locale for the saved state. For more context,
+ // see HomePanelsManager.onLocaleReady().
+ if (!prefs.contains(PREFS_CONFIG_KEY)) {
+ locale = currentLocale;
+ }
+ }
+
+ return locale;
+ }
+
+ @Override
+ public void setOnReloadListener(OnReloadListener listener) {
+ if (mReloadListener != null) {
+ unregisterReloadReceiver();
+ mReloadBroadcastReceiver = null;
+ }
+
+ mReloadListener = listener;
+
+ if (mReloadListener != null) {
+ mReloadBroadcastReceiver = new ReloadBroadcastReceiver();
+ registerReloadReceiver();
+ }
+ }
+
+ /**
+ * Update prefs that depend on home panels state.
+ *
+ * This includes the prefs that keep track of whether bookmarks or history are enabled, which are
+ * used to control the visibility of the corresponding menu items.
+ */
+ private void updatePrefsFromConfig(JSONArray panelsArray) {
+ final SharedPreferences prefs = GeckoSharedPrefs.forProfile(mContext);
+ if (!prefs.contains(HomeConfig.PREF_KEY_BOOKMARKS_PANEL_ENABLED)
+ || !prefs.contains(HomeConfig.PREF_KEY_HISTORY_PANEL_ENABLED)) {
+
+ final String bookmarkType = PanelType.BOOKMARKS.toString();
+ final String historyType = PanelType.COMBINED_HISTORY.toString();
+ try {
+ for (int i = 0; i < panelsArray.length(); i++) {
+ final JSONObject panelObj = panelsArray.getJSONObject(i);
+ final String panelType = panelObj.optString(PanelConfig.JSON_KEY_TYPE, null);
+ if (panelType == null) {
+ break;
+ }
+ final boolean isDisabled = panelObj.optBoolean(PanelConfig.JSON_KEY_DISABLED, false);
+ if (bookmarkType.equals(panelType)) {
+ prefs.edit().putBoolean(HomeConfig.PREF_KEY_BOOKMARKS_PANEL_ENABLED, !isDisabled).apply();
+ } else if (historyType.equals(panelType)) {
+ prefs.edit().putBoolean(HomeConfig.PREF_KEY_HISTORY_PANEL_ENABLED, !isDisabled).apply();
+ }
+ }
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error fetching panel from config to update prefs");
+ }
+ }
+ }
+
+
+ private void sendReloadBroadcast() {
+ final LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(mContext);
+ final Intent reloadIntent = new Intent(RELOAD_BROADCAST);
+ lbm.sendBroadcast(reloadIntent);
+ }
+
+ private void registerReloadReceiver() {
+ final LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(mContext);
+ lbm.registerReceiver(mReloadBroadcastReceiver, new IntentFilter(RELOAD_BROADCAST));
+ }
+
+ private void unregisterReloadReceiver() {
+ final LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(mContext);
+ lbm.unregisterReceiver(mReloadBroadcastReceiver);
+ }
+
+ private class ReloadBroadcastReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ mReloadListener.onReload();
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomeContextMenuInfo.java b/mobile/android/base/java/org/mozilla/gecko/home/HomeContextMenuInfo.java
new file mode 100644
index 000000000..cefa0329d
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/HomeContextMenuInfo.java
@@ -0,0 +1,82 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.util.StringUtils;
+
+import android.database.Cursor;
+import android.text.TextUtils;
+import android.view.View;
+import android.widget.AdapterView.AdapterContextMenuInfo;
+import android.widget.ExpandableListAdapter;
+import android.widget.ListAdapter;
+
+/**
+ * A ContextMenuInfo for HomeListView
+ */
+public class HomeContextMenuInfo extends AdapterContextMenuInfo {
+
+ public String url;
+ public String title;
+ public boolean isFolder;
+ public int historyId = -1;
+ public int bookmarkId = -1;
+ public RemoveItemType itemType = null;
+
+ // Item type to be handled with "Remove" selection.
+ public static enum RemoveItemType {
+ BOOKMARKS, COMBINED, HISTORY
+ }
+
+ public HomeContextMenuInfo(View targetView, int position, long id) {
+ super(targetView, position, id);
+ }
+
+ public boolean hasBookmarkId() {
+ return bookmarkId > -1;
+ }
+
+ public boolean hasHistoryId() {
+ return historyId > -1;
+ }
+
+ public boolean hasPartnerBookmarkId() {
+ return bookmarkId <= BrowserContract.Bookmarks.FAKE_PARTNER_BOOKMARKS_START;
+ }
+
+ public boolean canRemove() {
+ return hasBookmarkId() || hasHistoryId() || hasPartnerBookmarkId();
+ }
+
+ public String getDisplayTitle() {
+ if (!TextUtils.isEmpty(title)) {
+ return title;
+ }
+ return StringUtils.stripCommonSubdomains(StringUtils.stripScheme(url, StringUtils.UrlFlags.STRIP_HTTPS));
+ }
+
+ /**
+ * Interface for creating ContextMenuInfo instances from cursors.
+ */
+ public interface Factory {
+ public HomeContextMenuInfo makeInfoForCursor(View view, int position, long id, Cursor cursor);
+ }
+
+ /**
+ * Interface for creating ContextMenuInfo instances from ListAdapters.
+ */
+ public interface ListFactory extends Factory {
+ public HomeContextMenuInfo makeInfoForAdapter(View view, int position, long id, ListAdapter adapter);
+ }
+
+ /**
+ * Interface for creating ContextMenuInfo instances from ExpandableListAdapters.
+ */
+ public interface ExpandableFactory {
+ public HomeContextMenuInfo makeInfoForAdapter(View view, int position, long id, ExpandableListAdapter adapter);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomeExpandableListView.java b/mobile/android/base/java/org/mozilla/gecko/home/HomeExpandableListView.java
new file mode 100644
index 000000000..7badd6929
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/HomeExpandableListView.java
@@ -0,0 +1,68 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.ContextMenu.ContextMenuInfo;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.AdapterView.OnItemLongClickListener;
+import android.widget.ExpandableListView;
+
+/**
+ * <code>HomeExpandableListView</code> is a custom extension of
+ * <code>ExpandableListView<code>, that packs a <code>HomeContextMenuInfo</code>
+ * when any of its rows is long pressed.
+ * <p>
+ * This is the <code>ExpandableListView</code> equivalent of
+ * <code>HomeListView</code>.
+ */
+public class HomeExpandableListView extends ExpandableListView
+ implements OnItemLongClickListener {
+
+ // ContextMenuInfo associated with the currently long pressed list item.
+ private HomeContextMenuInfo mContextMenuInfo;
+
+ // ContextMenuInfo factory.
+ private HomeContextMenuInfo.ExpandableFactory mContextMenuInfoFactory;
+
+ public HomeExpandableListView(Context context) {
+ this(context, null);
+ }
+
+ public HomeExpandableListView(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public HomeExpandableListView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+
+ setOnItemLongClickListener(this);
+ }
+
+ @Override
+ public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
+ if (mContextMenuInfoFactory == null) {
+ return false;
+ }
+
+ // HomeExpandableListView items can correspond to groups and children.
+ // The factory can determine whether to add context menu for either,
+ // both, or none by unpacking the given position.
+ mContextMenuInfo = mContextMenuInfoFactory.makeInfoForAdapter(view, position, id, getExpandableListAdapter());
+ return showContextMenuForChild(HomeExpandableListView.this);
+ }
+
+ @Override
+ public ContextMenuInfo getContextMenuInfo() {
+ return mContextMenuInfo;
+ }
+
+ public void setContextMenuInfoFactory(final HomeContextMenuInfo.ExpandableFactory factory) {
+ mContextMenuInfoFactory = factory;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomeFragment.java b/mobile/android/base/java/org/mozilla/gecko/home/HomeFragment.java
new file mode 100644
index 000000000..da6e9b703
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/HomeFragment.java
@@ -0,0 +1,498 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import java.util.EnumSet;
+
+import org.mozilla.gecko.EditBookmarkDialog;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoApplication;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.IntentHelper;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.SnackbarBuilder;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.BrowserContract.SuggestedSites;
+import org.mozilla.gecko.distribution.PartnerBookmarksProviderProxy;
+import org.mozilla.gecko.home.HomeContextMenuInfo.RemoveItemType;
+import org.mozilla.gecko.home.HomePager.OnUrlOpenInBackgroundListener;
+import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
+import org.mozilla.gecko.home.TopSitesGridView.TopSitesGridContextMenuInfo;
+import org.mozilla.gecko.reader.SavedReaderViewHelper;
+import org.mozilla.gecko.reader.ReadingListHelper;
+import org.mozilla.gecko.restrictions.Restrictable;
+import org.mozilla.gecko.restrictions.Restrictions;
+import org.mozilla.gecko.util.Clipboard;
+import org.mozilla.gecko.util.StringUtils;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.util.UIAsyncTask;
+
+import android.app.Activity;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Configuration;
+import android.os.Bundle;
+import android.support.design.widget.Snackbar;
+import android.support.v4.app.Fragment;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.ContextMenu;
+import android.view.ContextMenu.ContextMenuInfo;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+
+/**
+ * HomeFragment is an empty fragment that can be added to the HomePager.
+ * Subclasses can add their own views.
+ * <p>
+ * The containing activity <b>must</b> implement {@link OnUrlOpenListener}.
+ */
+public abstract class HomeFragment extends Fragment {
+ // Log Tag.
+ private static final String LOGTAG = "GeckoHomeFragment";
+
+ // Share MIME type.
+ protected static final String SHARE_MIME_TYPE = "text/plain";
+
+ // Default value for "can load" hint
+ static final boolean DEFAULT_CAN_LOAD_HINT = false;
+
+ // Whether the fragment can load its content or not
+ // This is used to defer data loading until the editing
+ // mode animation ends.
+ private boolean mCanLoadHint;
+
+ // Whether the fragment has loaded its content
+ private boolean mIsLoaded;
+
+ // On URL open listener
+ protected OnUrlOpenListener mUrlOpenListener;
+
+ // Helper for opening a tab in the background.
+ protected OnUrlOpenInBackgroundListener mUrlOpenInBackgroundListener;
+
+ protected PanelStateChangeListener mPanelStateChangeListener = null;
+
+ /**
+ * Listener to notify when a home panels' state has changed in a way that needs to be stored
+ * for history/restoration. E.g. when a folder is opened/closed in bookmarks.
+ */
+ public interface PanelStateChangeListener {
+
+ /**
+ * @param bundle Data that should be persisted, and passed to this panel if restored at a later
+ * stage.
+ */
+ void onStateChanged(Bundle bundle);
+
+ void setCachedRecentTabsCount(int count);
+
+ int getCachedRecentTabsCount();
+ }
+
+ public void restoreData(Bundle data) {
+ // Do nothing
+ }
+
+ public void setPanelStateChangeListener(
+ PanelStateChangeListener mPanelStateChangeListener) {
+ this.mPanelStateChangeListener = mPanelStateChangeListener;
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+
+ try {
+ mUrlOpenListener = (OnUrlOpenListener) activity;
+ } catch (ClassCastException e) {
+ throw new ClassCastException(activity.toString()
+ + " must implement HomePager.OnUrlOpenListener");
+ }
+
+ try {
+ mUrlOpenInBackgroundListener = (OnUrlOpenInBackgroundListener) activity;
+ } catch (ClassCastException e) {
+ throw new ClassCastException(activity.toString()
+ + " must implement HomePager.OnUrlOpenInBackgroundListener");
+ }
+ }
+
+ @Override
+ public void onDetach() {
+ super.onDetach();
+ mUrlOpenListener = null;
+ mUrlOpenInBackgroundListener = null;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ final Bundle args = getArguments();
+ if (args != null) {
+ mCanLoadHint = args.getBoolean(HomePager.CAN_LOAD_ARG, DEFAULT_CAN_LOAD_HINT);
+ } else {
+ mCanLoadHint = DEFAULT_CAN_LOAD_HINT;
+ }
+
+ mIsLoaded = false;
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+
+ GeckoApplication.watchReference(getActivity(), this);
+ }
+
+ @Override
+ public void onCreateContextMenu(ContextMenu menu, View view, ContextMenuInfo menuInfo) {
+ if (!(menuInfo instanceof HomeContextMenuInfo)) {
+ return;
+ }
+
+ HomeContextMenuInfo info = (HomeContextMenuInfo) menuInfo;
+
+ // Don't show the context menu for folders.
+ if (info.isFolder) {
+ return;
+ }
+
+ MenuInflater inflater = new MenuInflater(view.getContext());
+ inflater.inflate(R.menu.home_contextmenu, menu);
+
+ menu.setHeaderTitle(info.getDisplayTitle());
+
+ // Hide unused menu items.
+ menu.findItem(R.id.top_sites_edit).setVisible(false);
+ menu.findItem(R.id.top_sites_pin).setVisible(false);
+ menu.findItem(R.id.top_sites_unpin).setVisible(false);
+
+ // Hide the "Edit" menuitem if this item isn't a bookmark,
+ // or if this is a reading list item.
+ if (!info.hasBookmarkId()) {
+ menu.findItem(R.id.home_edit_bookmark).setVisible(false);
+ }
+
+ // Hide the "Remove" menuitem if this item not removable.
+ if (!info.canRemove()) {
+ menu.findItem(R.id.home_remove).setVisible(false);
+ }
+
+ if (!StringUtils.isShareableUrl(info.url) || GeckoProfile.get(getActivity()).inGuestMode()) {
+ menu.findItem(R.id.home_share).setVisible(false);
+ }
+
+ if (!Restrictions.isAllowed(view.getContext(), Restrictable.PRIVATE_BROWSING)) {
+ menu.findItem(R.id.home_open_private_tab).setVisible(false);
+ }
+ }
+
+ @Override
+ public boolean onContextItemSelected(MenuItem item) {
+ // onContextItemSelected() is first dispatched to the activity and
+ // then dispatched to its fragments. Since fragments cannot "override"
+ // menu item selection handling, it's better to avoid menu id collisions
+ // between the activity and its fragments.
+
+ ContextMenuInfo menuInfo = item.getMenuInfo();
+ if (!(menuInfo instanceof HomeContextMenuInfo)) {
+ return false;
+ }
+
+ final HomeContextMenuInfo info = (HomeContextMenuInfo) menuInfo;
+ final Context context = getActivity();
+
+ final int itemId = item.getItemId();
+
+ // Track the menu action. We don't know much about the context, but we can use this to determine
+ // the frequency of use for various actions.
+ String extras = getResources().getResourceEntryName(itemId);
+ if (TextUtils.equals(extras, "home_open_private_tab")) {
+ // Mask private browsing
+ extras = "home_open_new_tab";
+ }
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.CONTEXT_MENU, extras);
+
+ if (itemId == R.id.home_copyurl) {
+ if (info.url == null) {
+ Log.e(LOGTAG, "Can't copy address because URL is null");
+ return false;
+ }
+
+ Clipboard.setText(info.url);
+ return true;
+ }
+
+ if (itemId == R.id.home_share) {
+ if (info.url == null) {
+ Log.e(LOGTAG, "Can't share because URL is null");
+ return false;
+ } else {
+ IntentHelper.openUriExternal(info.url, SHARE_MIME_TYPE, "", "",
+ Intent.ACTION_SEND, info.getDisplayTitle(), false);
+
+ // Context: Sharing via chrome homepage contextmenu list (home session should be active)
+ Telemetry.sendUIEvent(TelemetryContract.Event.SHARE, TelemetryContract.Method.LIST, "home_contextmenu");
+ return true;
+ }
+ }
+
+ if (itemId == R.id.home_add_to_launcher) {
+ if (info.url == null) {
+ Log.e(LOGTAG, "Can't add to home screen because URL is null");
+ return false;
+ }
+
+ // Fetch an icon big enough for use as a home screen icon.
+ final String displayTitle = info.getDisplayTitle();
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ GeckoAppShell.createShortcut(displayTitle, info.url);
+
+ }
+ });
+
+ return true;
+ }
+
+ if (itemId == R.id.home_open_private_tab || itemId == R.id.home_open_new_tab) {
+ if (info.url == null) {
+ Log.e(LOGTAG, "Can't open in new tab because URL is null");
+ return false;
+ }
+
+ // Some pinned site items have "user-entered" urls. URLs entered in
+ // the PinSiteDialog are wrapped in a special URI until we can get a
+ // valid URL. If the url is a user-entered url, decode the URL
+ // before loading it.
+ final String url = StringUtils.decodeUserEnteredUrl(info.url);
+
+ final EnumSet<OnUrlOpenInBackgroundListener.Flags> flags = EnumSet.noneOf(OnUrlOpenInBackgroundListener.Flags.class);
+ if (item.getItemId() == R.id.home_open_private_tab) {
+ flags.add(OnUrlOpenInBackgroundListener.Flags.PRIVATE);
+ }
+
+ mUrlOpenInBackgroundListener.onUrlOpenInBackground(url, flags);
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.CONTEXT_MENU);
+
+ return true;
+ }
+
+ if (itemId == R.id.home_edit_bookmark) {
+ // UI Dialog associates to the activity context, not the applications'.
+ new EditBookmarkDialog(context).show(info.url);
+ return true;
+ }
+
+ if (itemId == R.id.home_remove) {
+ // For Top Sites grid items, position is required in case item is Pinned.
+ final int position = info instanceof TopSitesGridContextMenuInfo ? info.position : -1;
+
+ if (info.hasPartnerBookmarkId()) {
+ new RemovePartnerBookmarkTask(context, info.bookmarkId).execute();
+ } else {
+ new RemoveItemByUrlTask(context, info.url, info.itemType, position).execute();
+ }
+ return true;
+ }
+
+ return false;
+ }
+
+ @Override
+ public void setUserVisibleHint (boolean isVisibleToUser) {
+ if (isVisibleToUser == getUserVisibleHint()) {
+ return;
+ }
+
+ super.setUserVisibleHint(isVisibleToUser);
+ loadIfVisible();
+ }
+
+ /**
+ * Handle a configuration change by detaching and re-attaching.
+ * <p>
+ * A HomeFragment only needs to handle onConfiguration change (i.e.,
+ * re-attach) if its UI needs to change (i.e., re-inflate layouts, use
+ * different styles, etc) for different device orientations. Handling
+ * configuration changes in all HomeFragments will simply cause some
+ * redundant re-inflations on device rotation. This slight inefficiency
+ * avoids potentially not handling a needed onConfigurationChanged in a
+ * subclass.
+ */
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+
+ // Reattach the fragment, forcing a re-inflation of its view.
+ // We use commitAllowingStateLoss() instead of commit() here to avoid
+ // an IllegalStateException. If the phone is rotated while Fennec
+ // is in the background, onConfigurationChanged() is fired.
+ // onConfigurationChanged() is called before onResume(), so
+ // using commit() would throw an IllegalStateException since it can't
+ // be used between the Activity's onSaveInstanceState() and
+ // onResume().
+ if (isVisible()) {
+ getFragmentManager().beginTransaction()
+ .detach(this)
+ .attach(this)
+ .commitAllowingStateLoss();
+ }
+ }
+
+ void setCanLoadHint(boolean canLoadHint) {
+ if (mCanLoadHint == canLoadHint) {
+ return;
+ }
+
+ mCanLoadHint = canLoadHint;
+ loadIfVisible();
+ }
+
+ boolean getCanLoadHint() {
+ return mCanLoadHint;
+ }
+
+ protected abstract void load();
+
+ protected boolean canLoad() {
+ return (mCanLoadHint && isVisible() && getUserVisibleHint());
+ }
+
+ protected void loadIfVisible() {
+ if (!canLoad() || mIsLoaded) {
+ return;
+ }
+
+ load();
+ mIsLoaded = true;
+ }
+
+ protected static class RemoveItemByUrlTask extends UIAsyncTask.WithoutParams<Void> {
+ private final Context mContext;
+ private final String mUrl;
+ private final RemoveItemType mType;
+ private final int mPosition;
+ private final BrowserDB mDB;
+
+ /**
+ * Remove bookmark/history/reading list type item by url, and also unpin the
+ * Top Sites grid item at index <code>position</code>.
+ */
+ public RemoveItemByUrlTask(Context context, String url, RemoveItemType type, int position) {
+ super(ThreadUtils.getBackgroundHandler());
+
+ mContext = context;
+ mUrl = url;
+ mType = type;
+ mPosition = position;
+ mDB = BrowserDB.from(context);
+ }
+
+ @Override
+ public Void doInBackground() {
+ ContentResolver cr = mContext.getContentResolver();
+
+ if (mPosition > -1) {
+ mDB.unpinSite(cr, mPosition);
+ if (mDB.hideSuggestedSite(mUrl)) {
+ cr.notifyChange(SuggestedSites.CONTENT_URI, null);
+ }
+ }
+
+ switch (mType) {
+ case BOOKMARKS:
+ removeBookmark(cr);
+ break;
+
+ case HISTORY:
+ removeHistory(cr);
+ break;
+
+ case COMBINED:
+ removeBookmark(cr);
+ removeHistory(cr);
+ break;
+
+ default:
+ Log.e(LOGTAG, "Can't remove item type " + mType.toString());
+ break;
+ }
+ return null;
+ }
+
+ @Override
+ public void onPostExecute(Void result) {
+ SnackbarBuilder.builder((Activity) mContext)
+ .message(R.string.page_removed)
+ .duration(Snackbar.LENGTH_LONG)
+ .buildAndShow();
+ }
+
+ private void removeBookmark(ContentResolver cr) {
+ SavedReaderViewHelper rch = SavedReaderViewHelper.getSavedReaderViewHelper(mContext);
+ final boolean isReaderViewPage = rch.isURLCached(mUrl);
+
+ final String extra;
+ if (isReaderViewPage) {
+ extra = "bookmark_reader";
+ } else {
+ extra = "bookmark";
+ }
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.UNSAVE, TelemetryContract.Method.CONTEXT_MENU, extra);
+ mDB.removeBookmarksWithURL(cr, mUrl);
+
+ if (isReaderViewPage) {
+ ReadingListHelper.removeCachedReaderItem(mUrl, mContext);
+ }
+ }
+
+ private void removeHistory(ContentResolver cr) {
+ mDB.removeHistoryEntry(cr, mUrl);
+ }
+ }
+
+ private static class RemovePartnerBookmarkTask extends UIAsyncTask.WithoutParams<Void> {
+ private Context context;
+ private long bookmarkId;
+
+ public RemovePartnerBookmarkTask(Context context, long bookmarkId) {
+ super(ThreadUtils.getBackgroundHandler());
+
+ this.context = context;
+ this.bookmarkId = bookmarkId;
+ }
+
+ @Override
+ protected Void doInBackground() {
+ context.getContentResolver().delete(
+ PartnerBookmarksProviderProxy.getUriForBookmark(context, bookmarkId),
+ null,
+ null
+ );
+
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(Void aVoid) {
+ SnackbarBuilder.builder((Activity) context)
+ .message(R.string.page_removed)
+ .duration(Snackbar.LENGTH_LONG)
+ .buildAndShow();
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomeListView.java b/mobile/android/base/java/org/mozilla/gecko/home/HomeListView.java
new file mode 100644
index 000000000..d179a27ce
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/HomeListView.java
@@ -0,0 +1,138 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.database.Cursor;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+import android.view.ContextMenu.ContextMenuInfo;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.AdapterView.OnItemLongClickListener;
+import android.widget.ListView;
+
+/**
+ * HomeListView is a custom extension of ListView, that packs a HomeContextMenuInfo
+ * when any of its rows is long pressed.
+ */
+public class HomeListView extends ListView
+ implements OnItemLongClickListener {
+
+ // ContextMenuInfo associated with the currently long pressed list item.
+ private HomeContextMenuInfo mContextMenuInfo;
+
+ // On URL open listener
+ protected OnUrlOpenListener mUrlOpenListener;
+
+ // Top divider
+ private final boolean mShowTopDivider;
+
+ // ContextMenuInfo maker
+ private HomeContextMenuInfo.Factory mContextMenuInfoFactory;
+
+ public HomeListView(Context context) {
+ this(context, null);
+ }
+
+ public HomeListView(Context context, AttributeSet attrs) {
+ this(context, attrs, R.attr.homeListViewStyle);
+ }
+
+ public HomeListView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+
+ TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.HomeListView, defStyle, 0);
+ mShowTopDivider = a.getBoolean(R.styleable.HomeListView_topDivider, false);
+ a.recycle();
+
+ setOnItemLongClickListener(this);
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+
+ final Drawable divider = getDivider();
+ if (mShowTopDivider && divider != null) {
+ final int dividerHeight = getDividerHeight();
+ final View view = new View(getContext());
+ view.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, dividerHeight));
+ addHeaderView(view);
+ }
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+
+ mUrlOpenListener = null;
+ }
+
+ @Override
+ public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
+ Object item = parent.getItemAtPosition(position);
+
+ // HomeListView could hold headers too. Add a context menu info only for its children.
+ if (item instanceof Cursor) {
+ Cursor cursor = (Cursor) item;
+ if (cursor == null || mContextMenuInfoFactory == null) {
+ mContextMenuInfo = null;
+ return false;
+ }
+
+ mContextMenuInfo = mContextMenuInfoFactory.makeInfoForCursor(view, position, id, cursor);
+ return showContextMenuForChild(HomeListView.this);
+
+ } else if (mContextMenuInfoFactory instanceof HomeContextMenuInfo.ListFactory) {
+ mContextMenuInfo = ((HomeContextMenuInfo.ListFactory) mContextMenuInfoFactory).makeInfoForAdapter(view, position, id, getAdapter());
+ return showContextMenuForChild(HomeListView.this);
+ } else {
+ mContextMenuInfo = null;
+ return false;
+ }
+ }
+
+ @Override
+ public ContextMenuInfo getContextMenuInfo() {
+ return mContextMenuInfo;
+ }
+
+ @Override
+ public void setOnItemClickListener(final AdapterView.OnItemClickListener listener) {
+ if (listener == null) {
+ super.setOnItemClickListener(null);
+ return;
+ }
+
+ super.setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ if (mShowTopDivider) {
+ position--;
+ }
+
+ listener.onItemClick(parent, view, position, id);
+ }
+ });
+ }
+
+ public void setContextMenuInfoFactory(final HomeContextMenuInfo.Factory factory) {
+ mContextMenuInfoFactory = factory;
+ }
+
+ public OnUrlOpenListener getOnUrlOpenListener() {
+ return mUrlOpenListener;
+ }
+
+ public void setOnUrlOpenListener(OnUrlOpenListener listener) {
+ mUrlOpenListener = listener;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomePager.java b/mobile/android/base/java/org/mozilla/gecko/home/HomePager.java
new file mode 100644
index 000000000..4915f0c91
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/HomePager.java
@@ -0,0 +1,564 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.List;
+
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.animation.PropertyAnimator;
+import org.mozilla.gecko.animation.ViewHelper;
+import org.mozilla.gecko.home.HomeAdapter.OnAddPanelListener;
+import org.mozilla.gecko.home.HomeConfig.PanelConfig;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.content.Context;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.os.Bundle;
+import android.support.v4.app.FragmentManager;
+import android.support.v4.app.LoaderManager;
+import android.support.v4.app.LoaderManager.LoaderCallbacks;
+import android.support.v4.content.Loader;
+import android.support.v4.view.ViewPager;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+
+public class HomePager extends ViewPager implements HomeScreen {
+
+ @Override
+ public boolean requestFocus(int direction, Rect previouslyFocusedRect) {
+ return super.requestFocus(direction, previouslyFocusedRect);
+ }
+
+ private static final int LOADER_ID_CONFIG = 0;
+
+ private final Context mContext;
+ private volatile boolean mVisible;
+ private Decor mDecor;
+ private View mTabStrip;
+ private HomeBanner mHomeBanner;
+ private int mDefaultPageIndex = -1;
+
+ private final OnAddPanelListener mAddPanelListener;
+
+ private final HomeConfig mConfig;
+ private final ConfigLoaderCallbacks mConfigLoaderCallbacks;
+
+ private String mInitialPanelId;
+ private Bundle mRestoreData;
+
+ // Cached original ViewPager background.
+ private final Drawable mOriginalBackground;
+
+ // Telemetry session for current panel.
+ private TelemetryContract.Session mCurrentPanelSession;
+ private String mCurrentPanelSessionSuffix;
+
+ // Current load state of HomePager.
+ private LoadState mLoadState;
+
+ // Listens for when the current panel changes.
+ private OnPanelChangeListener mPanelChangedListener;
+
+ private HomeFragment.PanelStateChangeListener mPanelStateChangeListener;
+
+ // This is mostly used by UI tests to easily fetch
+ // specific list views at runtime.
+ public static final String LIST_TAG_HISTORY = "history";
+ public static final String LIST_TAG_BOOKMARKS = "bookmarks";
+ public static final String LIST_TAG_TOP_SITES = "top_sites";
+ public static final String LIST_TAG_RECENT_TABS = "recent_tabs";
+ public static final String LIST_TAG_BROWSER_SEARCH = "browser_search";
+ public static final String LIST_TAG_REMOTE_TABS = "remote_tabs";
+
+ public interface OnUrlOpenListener {
+ public enum Flags {
+ ALLOW_SWITCH_TO_TAB,
+ OPEN_WITH_INTENT,
+ /**
+ * Ensure that the raw URL is opened. If not set, then the reader view version of the page
+ * might be opened if the URL is stored as an offline reader-view bookmark.
+ */
+ NO_READER_VIEW
+ }
+
+ public void onUrlOpen(String url, EnumSet<Flags> flags);
+ }
+
+ /**
+ * Interface for requesting a new tab be opened in the background.
+ * <p>
+ * This is the <code>HomeFragment</code> equivalent of opening a new tab by
+ * long clicking a link and selecting the "Open new [private] tab" context
+ * menu option.
+ */
+ public interface OnUrlOpenInBackgroundListener {
+ public enum Flags {
+ PRIVATE,
+ }
+
+ /**
+ * Open a new tab with the given URL
+ *
+ * @param url to open.
+ * @param flags to open new tab with.
+ */
+ public void onUrlOpenInBackground(String url, EnumSet<Flags> flags);
+ }
+
+ /**
+ * Special type of child views that could be added as pager decorations by default.
+ */
+ public interface Decor {
+ void onAddPagerView(String title);
+ void removeAllPagerViews();
+ void onPageSelected(int position);
+ void onPageScrolled(int position, float positionOffset, int positionOffsetPixels);
+ void setOnTitleClickListener(TabMenuStrip.OnTitleClickListener onTitleClickListener);
+ }
+
+ /**
+ * State of HomePager with respect to loading its configuration.
+ */
+ private enum LoadState {
+ UNLOADED,
+ LOADING,
+ LOADED
+ }
+
+ public static final String CAN_LOAD_ARG = "canLoad";
+ public static final String PANEL_CONFIG_ARG = "panelConfig";
+
+ public HomePager(Context context) {
+ this(context, null);
+ }
+
+ public HomePager(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ mContext = context;
+
+ mConfig = HomeConfig.getDefault(mContext);
+ mConfigLoaderCallbacks = new ConfigLoaderCallbacks();
+
+ mAddPanelListener = new OnAddPanelListener() {
+ @Override
+ public void onAddPanel(String title) {
+ if (mDecor != null) {
+ mDecor.onAddPagerView(title);
+ }
+ }
+ };
+
+ // This is to keep all 4 panels in memory after they are
+ // selected in the pager.
+ setOffscreenPageLimit(3);
+
+ // We can call HomePager.requestFocus to steal focus from the URL bar and drop the soft
+ // keyboard. However, if there are no focusable views (e.g. an empty reading list), the
+ // URL bar will be refocused. Therefore, we make the HomePager container focusable to
+ // ensure there is always a focusable view. This would ordinarily be done via an XML
+ // attribute, but it is not working properly.
+ setFocusableInTouchMode(true);
+
+ mOriginalBackground = getBackground();
+ setOnPageChangeListener(new PageChangeListener());
+
+ mLoadState = LoadState.UNLOADED;
+ }
+
+ @Override
+ public void addView(View child, int index, ViewGroup.LayoutParams params) {
+ if (child instanceof Decor) {
+ ((ViewPager.LayoutParams) params).isDecor = true;
+ mDecor = (Decor) child;
+ mTabStrip = child;
+
+ mDecor.setOnTitleClickListener(new TabMenuStrip.OnTitleClickListener() {
+ @Override
+ public void onTitleClicked(int index) {
+ setCurrentItem(index, true);
+ }
+ });
+ }
+
+ super.addView(child, index, params);
+ }
+
+ /**
+ * Loads and initializes the pager.
+ *
+ * @param fm FragmentManager for the adapter
+ */
+ @Override
+ public void load(LoaderManager lm, FragmentManager fm, String panelId, Bundle restoreData, PropertyAnimator animator) {
+ mLoadState = LoadState.LOADING;
+
+ mVisible = true;
+ mInitialPanelId = panelId;
+ mRestoreData = restoreData;
+
+ // Update the home banner message each time the HomePager is loaded.
+ if (mHomeBanner != null) {
+ mHomeBanner.update();
+ }
+
+ // Only animate on post-HC devices, when a non-null animator is given
+ final boolean shouldAnimate = animator != null;
+
+ final HomeAdapter adapter = new HomeAdapter(mContext, fm);
+ adapter.setOnAddPanelListener(mAddPanelListener);
+ adapter.setPanelStateChangeListener(mPanelStateChangeListener);
+ adapter.setCanLoadHint(true);
+ setAdapter(adapter);
+
+ // Don't show the tabs strip until we have the
+ // list of panels in place.
+ mTabStrip.setVisibility(View.INVISIBLE);
+
+ // Load list of panels from configuration
+ lm.initLoader(LOADER_ID_CONFIG, null, mConfigLoaderCallbacks);
+
+ if (shouldAnimate) {
+ animator.addPropertyAnimationListener(new PropertyAnimator.PropertyAnimationListener() {
+ @Override
+ public void onPropertyAnimationStart() {
+ setLayerType(View.LAYER_TYPE_HARDWARE, null);
+ }
+
+ @Override
+ public void onPropertyAnimationEnd() {
+ setLayerType(View.LAYER_TYPE_NONE, null);
+ }
+ });
+
+ ViewHelper.setAlpha(this, 0.0f);
+
+ animator.attach(this,
+ PropertyAnimator.Property.ALPHA,
+ 1.0f);
+ }
+ }
+
+ /**
+ * Removes all child fragments to free memory.
+ */
+ @Override
+ public void unload() {
+ mVisible = false;
+ setAdapter(null);
+ mLoadState = LoadState.UNLOADED;
+
+ // Stop UI Telemetry sessions.
+ stopCurrentPanelTelemetrySession();
+ }
+
+ /**
+ * Determines whether the pager is visible.
+ *
+ * Unlike getVisibility(), this method does not need to be called on the UI
+ * thread.
+ *
+ * @return Whether the pager and its fragments are loaded
+ */
+ public boolean isVisible() {
+ return mVisible;
+ }
+
+ @Override
+ public void setCurrentItem(int item, boolean smoothScroll) {
+ super.setCurrentItem(item, smoothScroll);
+
+ if (mDecor != null) {
+ mDecor.onPageSelected(item);
+ }
+
+ if (mHomeBanner != null) {
+ mHomeBanner.setActive(item == mDefaultPageIndex);
+ }
+ }
+
+ private void restorePanelData(int item, Bundle data) {
+ ((HomeAdapter) getAdapter()).setRestoreData(item, data);
+ }
+
+ /**
+ * Shows a home panel. If the given panelId is null,
+ * the default panel will be shown. No action will be taken if:
+ * * HomePager has not loaded yet
+ * * Panel with the given panelId cannot be found
+ *
+ * If you're trying to open a built-in panel, consider loading the panel url directly with
+ * {@link org.mozilla.gecko.AboutPages#getURLForBuiltinPanelType(HomeConfig.PanelType)}.
+ *
+ * @param panelId of the home panel to be shown.
+ */
+ @Override
+ public void showPanel(String panelId, Bundle restoreData) {
+ if (!mVisible) {
+ return;
+ }
+
+ switch (mLoadState) {
+ case LOADING:
+ mInitialPanelId = panelId;
+ mRestoreData = restoreData;
+ break;
+
+ case LOADED:
+ int position = mDefaultPageIndex;
+ if (panelId != null) {
+ position = ((HomeAdapter) getAdapter()).getItemPosition(panelId);
+ }
+
+ if (position > -1) {
+ setCurrentItem(position);
+ if (restoreData != null) {
+ restorePanelData(position, restoreData);
+ }
+ }
+ break;
+
+ default:
+ // Do nothing.
+ }
+ }
+
+ @Override
+ public boolean onInterceptTouchEvent(MotionEvent event) {
+ if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
+ // Drop the soft keyboard by stealing focus from the URL bar.
+ requestFocus();
+ }
+
+ return super.onInterceptTouchEvent(event);
+ }
+
+ public void setBanner(HomeBanner banner) {
+ mHomeBanner = banner;
+ }
+
+ @Override
+ public boolean dispatchTouchEvent(MotionEvent event) {
+ if (mHomeBanner != null) {
+ mHomeBanner.handleHomeTouch(event);
+ }
+
+ return super.dispatchTouchEvent(event);
+ }
+
+ @Override
+ public void onToolbarFocusChange(boolean hasFocus) {
+ if (mHomeBanner == null) {
+ return;
+ }
+
+ // We should only make the banner active if the toolbar is not focused and we are on the default page
+ final boolean active = !hasFocus && getCurrentItem() == mDefaultPageIndex;
+ mHomeBanner.setActive(active);
+ }
+
+ private void updateUiFromConfigState(HomeConfig.State configState) {
+ // We only care about the adapter if HomePager is currently
+ // loaded, which means it's visible in the activity.
+ if (!mVisible) {
+ return;
+ }
+
+ if (mDecor != null) {
+ mDecor.removeAllPagerViews();
+ }
+
+ final HomeAdapter adapter = (HomeAdapter) getAdapter();
+
+ // Disable any fragment loading until we have the initial
+ // panel selection done.
+ adapter.setCanLoadHint(false);
+
+ // Destroy any existing panels currently loaded
+ // in the pager.
+ setAdapter(null);
+
+ // Only keep enabled panels.
+ final List<PanelConfig> enabledPanels = new ArrayList<PanelConfig>();
+
+ for (PanelConfig panelConfig : configState) {
+ if (!panelConfig.isDisabled()) {
+ enabledPanels.add(panelConfig);
+ }
+ }
+
+ // Update the adapter with the new panel configs
+ adapter.update(enabledPanels);
+
+ final int count = enabledPanels.size();
+ if (count == 0) {
+ // Set firefox watermark as background.
+ setBackgroundResource(R.drawable.home_pager_empty_state);
+ // Hide the tab strip as there are no panels.
+ mTabStrip.setVisibility(View.INVISIBLE);
+ } else {
+ mTabStrip.setVisibility(View.VISIBLE);
+ // Restore original background.
+ setBackgroundDrawable(mOriginalBackground);
+ }
+
+ // Re-install the adapter with the final state
+ // in the pager.
+ setAdapter(adapter);
+
+ if (count == 0) {
+ mDefaultPageIndex = -1;
+
+ // Hide the banner if there are no enabled panels.
+ if (mHomeBanner != null) {
+ mHomeBanner.setActive(false);
+ }
+ } else {
+ for (int i = 0; i < count; i++) {
+ if (enabledPanels.get(i).isDefault()) {
+ mDefaultPageIndex = i;
+ break;
+ }
+ }
+
+ // Use the default panel if the initial panel wasn't explicitly set by the
+ // load() caller, or if the initial panel is not found in the adapter.
+ final int itemPosition = (mInitialPanelId == null) ? -1 : adapter.getItemPosition(mInitialPanelId);
+ if (itemPosition > -1) {
+ setCurrentItem(itemPosition, false);
+ if (mRestoreData != null) {
+ restorePanelData(itemPosition, mRestoreData);
+ mRestoreData = null; // Release data since it's no longer needed
+ }
+ mInitialPanelId = null;
+ } else {
+ setCurrentItem(mDefaultPageIndex, false);
+ }
+ }
+
+ // The selection is updated asynchronously so we need to post to
+ // UI thread to give the pager time to commit the new page selection
+ // internally and load the right initial panel.
+ ThreadUtils.getUiHandler().post(new Runnable() {
+ @Override
+ public void run() {
+ adapter.setCanLoadHint(true);
+ }
+ });
+ }
+
+ @Override
+ public void setOnPanelChangeListener(OnPanelChangeListener listener) {
+ mPanelChangedListener = listener;
+ }
+
+ @Override
+ public void setPanelStateChangeListener(HomeFragment.PanelStateChangeListener listener) {
+ mPanelStateChangeListener = listener;
+
+ HomeAdapter adapter = (HomeAdapter) getAdapter();
+ if (adapter != null) {
+ adapter.setPanelStateChangeListener(listener);
+ }
+ }
+
+ /**
+ * Notify listeners of newly selected panel.
+ *
+ * @param position of the newly selected panel
+ */
+ private void notifyPanelSelected(int position) {
+ if (mDecor != null) {
+ mDecor.onPageSelected(position);
+ }
+
+ if (mPanelChangedListener != null) {
+ final String panelId = ((HomeAdapter) getAdapter()).getPanelIdAtPosition(position);
+ mPanelChangedListener.onPanelSelected(panelId);
+ }
+ }
+
+ private class ConfigLoaderCallbacks implements LoaderCallbacks<HomeConfig.State> {
+ @Override
+ public Loader<HomeConfig.State> onCreateLoader(int id, Bundle args) {
+ return new HomeConfigLoader(mContext, mConfig);
+ }
+
+ @Override
+ public void onLoadFinished(Loader<HomeConfig.State> loader, HomeConfig.State configState) {
+ mLoadState = LoadState.LOADED;
+ updateUiFromConfigState(configState);
+ }
+
+ @Override
+ public void onLoaderReset(Loader<HomeConfig.State> loader) {
+ mLoadState = LoadState.UNLOADED;
+ }
+ }
+
+ private class PageChangeListener implements ViewPager.OnPageChangeListener {
+ @Override
+ public void onPageSelected(int position) {
+ notifyPanelSelected(position);
+
+ if (mHomeBanner != null) {
+ mHomeBanner.setActive(position == mDefaultPageIndex);
+ }
+
+ // Start a UI telemetry session for the newly selected panel.
+ final String newPanelId = ((HomeAdapter) getAdapter()).getPanelIdAtPosition(position);
+ startNewPanelTelemetrySession(newPanelId);
+ }
+
+ @Override
+ public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
+ if (mDecor != null) {
+ mDecor.onPageScrolled(position, positionOffset, positionOffsetPixels);
+ }
+
+ if (mHomeBanner != null) {
+ mHomeBanner.setScrollingPages(positionOffsetPixels != 0);
+ }
+ }
+
+ @Override
+ public void onPageScrollStateChanged(int state) { }
+ }
+
+ /**
+ * Start UI telemetry session for the a panel.
+ * If there is currently a session open for a panel,
+ * it will be stopped before a new one is started.
+ *
+ * @param panelId of panel to start a session for
+ */
+ private void startNewPanelTelemetrySession(String panelId) {
+ // Stop the current panel's session if we have one.
+ stopCurrentPanelTelemetrySession();
+
+ mCurrentPanelSession = TelemetryContract.Session.HOME_PANEL;
+ mCurrentPanelSessionSuffix = panelId;
+ Telemetry.startUISession(mCurrentPanelSession, mCurrentPanelSessionSuffix);
+ }
+
+ /**
+ * Stop the current panel telemetry session if one exists.
+ */
+ private void stopCurrentPanelTelemetrySession() {
+ if (mCurrentPanelSession != null) {
+ Telemetry.stopUISession(mCurrentPanelSession, mCurrentPanelSessionSuffix);
+ mCurrentPanelSession = null;
+ mCurrentPanelSessionSuffix = null;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomePanelsManager.java b/mobile/android/base/java/org/mozilla/gecko/home/HomePanelsManager.java
new file mode 100644
index 000000000..bfd6c5624
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/HomePanelsManager.java
@@ -0,0 +1,368 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import static org.mozilla.gecko.home.HomeConfig.createBuiltinPanelConfig;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Queue;
+import java.util.Set;
+import java.util.concurrent.ConcurrentLinkedQueue;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.db.HomeProvider;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.home.HomeConfig.PanelConfig;
+import org.mozilla.gecko.home.PanelInfoManager.PanelInfo;
+import org.mozilla.gecko.home.PanelInfoManager.RequestCallback;
+import org.mozilla.gecko.util.GeckoEventListener;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.os.Handler;
+import android.util.Log;
+
+public class HomePanelsManager implements GeckoEventListener {
+ public static final String LOGTAG = "HomePanelsManager";
+
+ private static final HomePanelsManager sInstance = new HomePanelsManager();
+
+ private static final int INVALIDATION_DELAY_MSEC = 500;
+ private static final int PANEL_INFO_TIMEOUT_MSEC = 1000;
+
+ private static final String EVENT_HOMEPANELS_INSTALL = "HomePanels:Install";
+ private static final String EVENT_HOMEPANELS_UNINSTALL = "HomePanels:Uninstall";
+ private static final String EVENT_HOMEPANELS_UPDATE = "HomePanels:Update";
+ private static final String EVENT_HOMEPANELS_REFRESH = "HomePanels:RefreshDataset";
+
+ private static final String JSON_KEY_PANEL = "panel";
+ private static final String JSON_KEY_PANEL_ID = "id";
+
+ private enum ChangeType {
+ UNINSTALL,
+ INSTALL,
+ UPDATE,
+ REFRESH
+ }
+
+ private enum InvalidationMode {
+ DELAYED,
+ IMMEDIATE
+ }
+
+ private static class ConfigChange {
+ private final ChangeType type;
+ private final Object target;
+
+ public ConfigChange(ChangeType type) {
+ this(type, null);
+ }
+
+ public ConfigChange(ChangeType type, Object target) {
+ this.type = type;
+ this.target = target;
+ }
+ }
+
+ private Context mContext;
+ private HomeConfig mHomeConfig;
+ private boolean mInitialized;
+
+ private final Queue<ConfigChange> mPendingChanges = new ConcurrentLinkedQueue<ConfigChange>();
+ private final Runnable mInvalidationRunnable = new InvalidationRunnable();
+
+ public static HomePanelsManager getInstance() {
+ return sInstance;
+ }
+
+ public void init(Context context) {
+ if (mInitialized) {
+ return;
+ }
+
+ mContext = context;
+ mHomeConfig = HomeConfig.getDefault(context);
+
+ EventDispatcher.getInstance().registerGeckoThreadListener(this,
+ EVENT_HOMEPANELS_INSTALL,
+ EVENT_HOMEPANELS_UNINSTALL,
+ EVENT_HOMEPANELS_UPDATE,
+ EVENT_HOMEPANELS_REFRESH);
+
+ mInitialized = true;
+ }
+
+ public void onLocaleReady(final String locale) {
+ ThreadUtils.getBackgroundHandler().post(new Runnable() {
+ @Override
+ public void run() {
+ final String configLocale = mHomeConfig.getLocale();
+ if (configLocale == null || !configLocale.equals(locale)) {
+ handleLocaleChange();
+ }
+ }
+ });
+ }
+
+ @Override
+ public void handleMessage(String event, JSONObject message) {
+ try {
+ if (event.equals(EVENT_HOMEPANELS_INSTALL)) {
+ Log.d(LOGTAG, EVENT_HOMEPANELS_INSTALL);
+ handlePanelInstall(createPanelConfigFromMessage(message), InvalidationMode.DELAYED);
+ } else if (event.equals(EVENT_HOMEPANELS_UNINSTALL)) {
+ Log.d(LOGTAG, EVENT_HOMEPANELS_UNINSTALL);
+ final String panelId = message.getString(JSON_KEY_PANEL_ID);
+ handlePanelUninstall(panelId);
+ } else if (event.equals(EVENT_HOMEPANELS_UPDATE)) {
+ Log.d(LOGTAG, EVENT_HOMEPANELS_UPDATE);
+ handlePanelUpdate(createPanelConfigFromMessage(message));
+ } else if (event.equals(EVENT_HOMEPANELS_REFRESH)) {
+ Log.d(LOGTAG, EVENT_HOMEPANELS_REFRESH);
+ handleDatasetRefresh(message);
+ }
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Failed to handle event " + event, e);
+ }
+ }
+
+ private PanelConfig createPanelConfigFromMessage(JSONObject message) throws JSONException {
+ final JSONObject json = message.getJSONObject(JSON_KEY_PANEL);
+ return new PanelConfig(json);
+ }
+
+ /**
+ * Adds a new PanelConfig to the HomeConfig.
+ *
+ * This posts the invalidation of HomeConfig immediately.
+ *
+ * @param panelConfig panel to add
+ */
+ public void installPanel(PanelConfig panelConfig) {
+ Log.d(LOGTAG, "installPanel: " + panelConfig.getTitle());
+ handlePanelInstall(panelConfig, InvalidationMode.IMMEDIATE);
+ }
+
+ /**
+ * Runs in the gecko thread.
+ */
+ private void handlePanelInstall(PanelConfig panelConfig, InvalidationMode mode) {
+ mPendingChanges.offer(new ConfigChange(ChangeType.INSTALL, panelConfig));
+ Log.d(LOGTAG, "handlePanelInstall: " + mPendingChanges.size());
+
+ scheduleInvalidation(mode);
+ }
+
+ /**
+ * Runs in the gecko thread.
+ */
+ private void handlePanelUninstall(String panelId) {
+ mPendingChanges.offer(new ConfigChange(ChangeType.UNINSTALL, panelId));
+ Log.d(LOGTAG, "handlePanelUninstall: " + mPendingChanges.size());
+
+ scheduleInvalidation(InvalidationMode.DELAYED);
+ }
+
+ /**
+ * Runs in the gecko thread.
+ */
+ private void handlePanelUpdate(PanelConfig panelConfig) {
+ mPendingChanges.offer(new ConfigChange(ChangeType.UPDATE, panelConfig));
+ Log.d(LOGTAG, "handlePanelUpdate: " + mPendingChanges.size());
+
+ scheduleInvalidation(InvalidationMode.DELAYED);
+ }
+
+ /**
+ * Runs in the background thread.
+ */
+ private void handleLocaleChange() {
+ mPendingChanges.offer(new ConfigChange(ChangeType.REFRESH));
+ Log.d(LOGTAG, "handleLocaleChange: " + mPendingChanges.size());
+
+ scheduleInvalidation(InvalidationMode.IMMEDIATE);
+ }
+
+
+ /**
+ * Handles a dataset refresh request from Gecko. This is usually
+ * triggered by a HomeStorage.save() call in an add-on.
+ *
+ * Runs in the gecko thread.
+ */
+ private void handleDatasetRefresh(JSONObject message) {
+ final String datasetId;
+ try {
+ datasetId = message.getString("datasetId");
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Failed to handle dataset refresh", e);
+ return;
+ }
+
+ Log.d(LOGTAG, "Refresh request for dataset: " + datasetId);
+
+ final ContentResolver cr = mContext.getContentResolver();
+ cr.notifyChange(HomeProvider.getDatasetNotificationUri(datasetId), null);
+ }
+
+ /**
+ * Runs in the gecko or main thread.
+ */
+ private void scheduleInvalidation(InvalidationMode mode) {
+ final Handler handler = ThreadUtils.getBackgroundHandler();
+
+ handler.removeCallbacks(mInvalidationRunnable);
+
+ if (mode == InvalidationMode.IMMEDIATE) {
+ handler.post(mInvalidationRunnable);
+ } else {
+ handler.postDelayed(mInvalidationRunnable, INVALIDATION_DELAY_MSEC);
+ }
+
+ Log.d(LOGTAG, "scheduleInvalidation: scheduled new invalidation: " + mode);
+ }
+
+ /**
+ * Runs in the background thread.
+ */
+ private void executePendingChanges(HomeConfig.Editor editor) {
+ boolean shouldRefresh = false;
+
+ while (!mPendingChanges.isEmpty()) {
+ final ConfigChange pendingChange = mPendingChanges.poll();
+
+ switch (pendingChange.type) {
+ case UNINSTALL: {
+ final String panelId = (String) pendingChange.target;
+ if (editor.uninstall(panelId)) {
+ Log.d(LOGTAG, "executePendingChanges: uninstalled panel " + panelId);
+ }
+ break;
+ }
+
+ case INSTALL: {
+ final PanelConfig panelConfig = (PanelConfig) pendingChange.target;
+ if (editor.install(panelConfig)) {
+ Log.d(LOGTAG, "executePendingChanges: added panel " + panelConfig.getId());
+ }
+ break;
+ }
+
+ case UPDATE: {
+ final PanelConfig panelConfig = (PanelConfig) pendingChange.target;
+ if (editor.update(panelConfig)) {
+ Log.w(LOGTAG, "executePendingChanges: updated panel " + panelConfig.getId());
+ }
+ break;
+ }
+
+ case REFRESH: {
+ shouldRefresh = true;
+ }
+ }
+ }
+
+ // The editor still represents the default HomeConfig
+ // configuration and hasn't been changed by any operation
+ // above. No need to refresh as the HomeConfig backend will
+ // take of forcing all existing HomeConfigLoader instances to
+ // refresh their contents.
+ if (shouldRefresh && !editor.isDefault()) {
+ executeRefresh(editor);
+ }
+ }
+
+ /**
+ * Runs in the background thread.
+ */
+ private void refreshFromPanelInfos(HomeConfig.Editor editor, List<PanelInfo> panelInfos) {
+ Log.d(LOGTAG, "refreshFromPanelInfos");
+
+ for (PanelConfig panelConfig : editor) {
+ PanelConfig refreshedPanelConfig = null;
+
+ if (panelConfig.isDynamic()) {
+ for (PanelInfo panelInfo : panelInfos) {
+ if (panelInfo.getId().equals(panelConfig.getId())) {
+ refreshedPanelConfig = panelInfo.toPanelConfig();
+ Log.d(LOGTAG, "refreshFromPanelInfos: refreshing from panel info: " + panelInfo.getId());
+ break;
+ }
+ }
+ } else {
+ refreshedPanelConfig = createBuiltinPanelConfig(mContext, panelConfig.getType());
+ Log.d(LOGTAG, "refreshFromPanelInfos: refreshing built-in panel: " + panelConfig.getId());
+ }
+
+ if (refreshedPanelConfig == null) {
+ Log.d(LOGTAG, "refreshFromPanelInfos: no refreshed panel, falling back: " + panelConfig.getId());
+ continue;
+ }
+
+ Log.d(LOGTAG, "refreshFromPanelInfos: refreshed panel " + refreshedPanelConfig.getId());
+ editor.update(refreshedPanelConfig);
+ }
+ }
+
+ /**
+ * Runs in the background thread.
+ */
+ private void executeRefresh(HomeConfig.Editor editor) {
+ if (editor.isEmpty()) {
+ return;
+ }
+
+ Log.d(LOGTAG, "executeRefresh");
+
+ final Set<String> ids = new HashSet<String>();
+ for (PanelConfig panelConfig : editor) {
+ ids.add(panelConfig.getId());
+ }
+
+ final Object panelRequestLock = new Object();
+ final List<PanelInfo> latestPanelInfos = new ArrayList<PanelInfo>();
+
+ final PanelInfoManager pm = new PanelInfoManager();
+ pm.requestPanelsById(ids, new RequestCallback() {
+ @Override
+ public void onComplete(List<PanelInfo> panelInfos) {
+ synchronized (panelRequestLock) {
+ latestPanelInfos.addAll(panelInfos);
+ Log.d(LOGTAG, "executeRefresh: fetched panel infos: " + panelInfos.size());
+
+ panelRequestLock.notifyAll();
+ }
+ }
+ });
+
+ try {
+ synchronized (panelRequestLock) {
+ panelRequestLock.wait(PANEL_INFO_TIMEOUT_MSEC);
+
+ Log.d(LOGTAG, "executeRefresh: done fetching panel infos");
+ refreshFromPanelInfos(editor, latestPanelInfos);
+ }
+ } catch (InterruptedException e) {
+ Log.e(LOGTAG, "Failed to fetch panels from gecko", e);
+ }
+ }
+
+ /**
+ * Runs in the background thread.
+ */
+ private class InvalidationRunnable implements Runnable {
+ @Override
+ public void run() {
+ final HomeConfig.Editor editor = mHomeConfig.load().edit();
+ executePendingChanges(editor);
+ editor.apply();
+ }
+ };
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomeScreen.java b/mobile/android/base/java/org/mozilla/gecko/home/HomeScreen.java
new file mode 100644
index 000000000..1525969a0
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/HomeScreen.java
@@ -0,0 +1,57 @@
+package org.mozilla.gecko.home;
+
+import android.os.Bundle;
+import android.support.v4.app.FragmentManager;
+import android.support.v4.app.LoaderManager;
+import android.view.View;
+
+import org.mozilla.gecko.animation.PropertyAnimator;
+
+/**
+ * Generic interface for any View that can be used as the homescreen.
+ *
+ * In the past we had the HomePager, which contained the usual homepanels (multiple panels: TopSites,
+ * bookmarks, history, etc.), which could be swiped between.
+ *
+ * This interface allows easily switching between different homepanel implementations. For example
+ * the prototype activity-stream panel (which will be a single panel combining the functionality
+ * of the previous panels).
+ */
+public interface HomeScreen {
+ /**
+ * Interface for listening into ViewPager panel changes
+ */
+ public interface OnPanelChangeListener {
+ /**
+ * Called when a new panel is selected.
+ *
+ * @param panelId of the newly selected panel
+ */
+ public void onPanelSelected(String panelId);
+ }
+
+ // The following two methods are actually methods of View. Since there is no View interface
+ // we're forced to do this instead of "extending" View. Any class implementing HomeScreen
+ // will have to implement these and pass them through to the underlying View.
+ boolean isVisible();
+ boolean requestFocus();
+
+ void onToolbarFocusChange(boolean hasFocus);
+
+ // The following three methods are HomePager specific. The persistence framework might need
+ // refactoring/generalising at some point, but it isn't entirely clear what other panels
+ // might need so we can leave these as is for now.
+ void showPanel(String panelId, Bundle restoreData);
+ void setOnPanelChangeListener(OnPanelChangeListener listener);
+ void setPanelStateChangeListener(HomeFragment.PanelStateChangeListener listener);
+
+ /**
+ * Set a banner that may be displayed at the bottom of the HomeScreen. This can be used
+ * e.g. to show snippets.
+ */
+ void setBanner(HomeBanner banner);
+
+ void load(LoaderManager lm, FragmentManager fm, String panelId, Bundle restoreData, PropertyAnimator animator);
+
+ void unload();
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/ImageLoader.java b/mobile/android/base/java/org/mozilla/gecko/home/ImageLoader.java
new file mode 100644
index 000000000..2bbd82a8d
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/ImageLoader.java
@@ -0,0 +1,164 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import android.content.Context;
+import android.net.Uri;
+import android.util.DisplayMetrics;
+import android.util.Log;
+
+import com.squareup.picasso.LruCache;
+import com.squareup.picasso.Picasso;
+import com.squareup.picasso.Downloader.Response;
+import com.squareup.picasso.UrlConnectionDownloader;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.util.EnumSet;
+import java.util.Set;
+
+import org.mozilla.gecko.distribution.Distribution;
+
+public class ImageLoader {
+ private static final String LOGTAG = "GeckoImageLoader";
+
+ private static final String DISTRIBUTION_SCHEME = "gecko.distribution";
+ private static final String SUGGESTED_SITES_AUTHORITY = "suggestedsites";
+
+ // The order of density factors to try when looking for an image resource
+ // in the distribution directory. It looks for an exact match first (1.0) then
+ // tries to find images with higher density (2.0 and 1.5). If no image is found,
+ // try a lower density (0.5). See loadDistributionImage().
+ private static final float[] densityFactors = new float[] { 1.0f, 2.0f, 1.5f, 0.5f };
+
+ private static enum Density {
+ MDPI,
+ HDPI,
+ XHDPI,
+ XXHDPI;
+
+ @Override
+ public String toString() {
+ return super.toString().toLowerCase();
+ }
+ }
+
+ // Picasso instance and LruCache lrucache are protected by synchronization.
+ private static Picasso instance;
+ private static LruCache lrucache;
+
+ public static synchronized Picasso with(Context context) {
+ if (instance == null) {
+ lrucache = new LruCache(context);
+ Picasso.Builder builder = new Picasso.Builder(context).memoryCache(lrucache);
+
+ final Distribution distribution = Distribution.getInstance(context.getApplicationContext());
+ builder.downloader(new ImageDownloader(context, distribution));
+ instance = builder.build();
+ }
+
+ return instance;
+ }
+
+ public static synchronized void clearLruCache() {
+ if (lrucache != null) {
+ lrucache.evictAll();
+ }
+ }
+
+ /**
+ * Custom Downloader built on top of Picasso's UrlConnectionDownloader
+ * that supports loading images from custom URIs.
+ */
+ public static class ImageDownloader extends UrlConnectionDownloader {
+ private final Context context;
+ private final Distribution distribution;
+
+ public ImageDownloader(Context context, Distribution distribution) {
+ super(context);
+ this.context = context;
+ this.distribution = distribution;
+ }
+
+ private Density getDensity(float factor) {
+ final DisplayMetrics dm = context.getResources().getDisplayMetrics();
+ final float densityDpi = dm.densityDpi * factor;
+
+ if (densityDpi >= DisplayMetrics.DENSITY_XXHIGH) {
+ return Density.XXHDPI;
+ } else if (densityDpi >= DisplayMetrics.DENSITY_XHIGH) {
+ return Density.XHDPI;
+ } else if (densityDpi >= DisplayMetrics.DENSITY_HIGH) {
+ return Density.HDPI;
+ }
+
+ // Fallback to mdpi, no need to handle ldpi.
+ return Density.MDPI;
+ }
+
+ @Override
+ public Response load(Uri uri, boolean localCacheOnly) throws IOException {
+ final String scheme = uri.getScheme();
+ if (DISTRIBUTION_SCHEME.equals(scheme)) {
+ return loadDistributionImage(uri);
+ }
+
+ return super.load(uri, localCacheOnly);
+ }
+
+ private static String getPathForDensity(String basePath, Density density,
+ String filename) {
+ final File dir = new File(basePath, density.toString());
+ return String.format("%s/%s.png", dir.toString(), filename);
+ }
+
+ /**
+ * Handle distribution URIs in Picasso. The expected format is:
+ *
+ * gecko.distribution://<basepath>/<imagename>
+ *
+ * Which will look for the following file in the distribution:
+ *
+ * <distribution-root-dir>/<basepath>/<device-density>/<imagename>.png
+ */
+ private Response loadDistributionImage(Uri uri) throws IOException {
+ // Eliminate the leading '//'
+ final String ssp = uri.getSchemeSpecificPart().substring(2);
+
+ final String filename;
+ final String basePath;
+
+ final int slashIndex = ssp.lastIndexOf('/');
+ if (slashIndex == -1) {
+ filename = ssp;
+ basePath = "";
+ } else {
+ filename = ssp.substring(slashIndex + 1);
+ basePath = ssp.substring(0, slashIndex);
+ }
+
+ Set<Density> triedDensities = EnumSet.noneOf(Density.class);
+
+ for (int i = 0; i < densityFactors.length; i++) {
+ final Density density = getDensity(densityFactors[i]);
+ if (!triedDensities.add(density)) {
+ continue;
+ }
+
+ final String path = getPathForDensity(basePath, density, filename);
+ Log.d(LOGTAG, "Trying to load image from distribution " + path);
+
+ final File f = distribution.getDistributionFile(path);
+ if (f != null) {
+ return new Response(new FileInputStream(f), true);
+ }
+ }
+
+ throw new ResponseException("Couldn't find suggested site image in distribution");
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/MultiTypeCursorAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/MultiTypeCursorAdapter.java
new file mode 100644
index 000000000..26edf13ff
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/MultiTypeCursorAdapter.java
@@ -0,0 +1,100 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.support.v4.widget.CursorAdapter;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+/**
+ * MultiTypeCursorAdapter wraps a cursor and any meta data associated with it.
+ * A set of view types (corresponding to the cursor and its meta data)
+ * are mapped to a set of layouts.
+ */
+abstract class MultiTypeCursorAdapter extends CursorAdapter {
+ private final int[] mViewTypes;
+ private final int[] mLayouts;
+
+ // Bind the view for the given position.
+ abstract public void bindView(View view, Context context, int position);
+
+ public MultiTypeCursorAdapter(Context context, Cursor cursor, int[] viewTypes, int[] layouts) {
+ super(context, cursor, 0);
+
+ if (viewTypes.length != layouts.length) {
+ throw new IllegalStateException("The view types and the layouts should be of same size");
+ }
+
+ mViewTypes = viewTypes;
+ mLayouts = layouts;
+ }
+
+ @Override
+ public final int getViewTypeCount() {
+ return mViewTypes.length;
+ }
+
+ /**
+ * @return Cursor for the given position.
+ */
+ public final Cursor getCursor(int position) {
+ final Cursor cursor = getCursor();
+ if (cursor == null || !cursor.moveToPosition(position)) {
+ throw new IllegalStateException("Couldn't move cursor to position " + position);
+ }
+
+ return cursor;
+ }
+
+ @Override
+ public final View getView(int position, View convertView, ViewGroup parent) {
+ final Context context = parent.getContext();
+ if (convertView == null) {
+ convertView = newView(context, position, parent);
+ }
+
+ bindView(convertView, context, position);
+ return convertView;
+ }
+
+ @Override
+ public final void bindView(View view, Context context, Cursor cursor) {
+ // Do nothing.
+ }
+
+ @Override
+ public boolean hasStableIds() {
+ return false;
+ }
+
+ @Override
+ public final View newView(Context context, Cursor cursor, ViewGroup parent) {
+ return null;
+ }
+
+ /**
+ * Inflate a new view from a set of view types and layouts based on the position.
+ *
+ * @param context Context for inflating the view.
+ * @param position Position of the view.
+ * @param parent Parent view group that will hold this view.
+ */
+ private View newView(Context context, int position, ViewGroup parent) {
+ final int type = getItemViewType(position);
+ final int count = mViewTypes.length;
+
+ for (int i = 0; i < count; i++) {
+ if (mViewTypes[i] == type) {
+ return LayoutInflater.from(context).inflate(mLayouts[i], parent, false);
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelAuthCache.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelAuthCache.java
new file mode 100644
index 000000000..d66919344
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelAuthCache.java
@@ -0,0 +1,82 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.content.SharedPreferences.OnSharedPreferenceChangeListener;
+import android.util.Log;
+
+import org.mozilla.gecko.GeckoSharedPrefs;
+
+/**
+ * Cache used to store authentication state of dynamic panels. The values
+ * in this cache are set in JS through the Home.panels API.
+ *
+ * {@code DynamicPanel} uses this cache to determine whether or not to
+ * show authentication UI for dynamic panels, including listening for
+ * changes in authentication state.
+ */
+class PanelAuthCache {
+ private static final String LOGTAG = "GeckoPanelAuthCache";
+
+ // Keep this in sync with the constant defined in Home.jsm
+ private static final String PREFS_PANEL_AUTH_PREFIX = "home_panels_auth_";
+
+ private final Context mContext;
+ private SharedPrefsListener mSharedPrefsListener;
+ private OnChangeListener mChangeListener;
+
+ public interface OnChangeListener {
+ public void onChange(String panelId, boolean isAuthenticated);
+ }
+
+ public PanelAuthCache(Context context) {
+ mContext = context;
+ }
+
+ private SharedPreferences getSharedPreferences() {
+ return GeckoSharedPrefs.forProfile(mContext);
+ }
+
+ private String getPanelAuthKey(String panelId) {
+ return PREFS_PANEL_AUTH_PREFIX + panelId;
+ }
+
+ public boolean isAuthenticated(String panelId) {
+ final SharedPreferences prefs = getSharedPreferences();
+ return prefs.getBoolean(getPanelAuthKey(panelId), false);
+ }
+
+ public void setOnChangeListener(OnChangeListener listener) {
+ final SharedPreferences prefs = getSharedPreferences();
+
+ if (mChangeListener != null) {
+ prefs.unregisterOnSharedPreferenceChangeListener(mSharedPrefsListener);
+ mSharedPrefsListener = null;
+ }
+
+ mChangeListener = listener;
+
+ if (mChangeListener != null) {
+ mSharedPrefsListener = new SharedPrefsListener();
+ prefs.registerOnSharedPreferenceChangeListener(mSharedPrefsListener);
+ }
+ }
+
+ private class SharedPrefsListener implements OnSharedPreferenceChangeListener {
+ @Override
+ public void onSharedPreferenceChanged(SharedPreferences prefs, String key) {
+ if (key.startsWith(PREFS_PANEL_AUTH_PREFIX)) {
+ final String panelId = key.substring(PREFS_PANEL_AUTH_PREFIX.length());
+ final boolean isAuthenticated = prefs.getBoolean(key, false);
+
+ Log.d(LOGTAG, "Auth state changed: panelId=" + panelId + ", isAuthenticated=" + isAuthenticated);
+ mChangeListener.onChange(panelId, isAuthenticated);
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelAuthLayout.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelAuthLayout.java
new file mode 100644
index 000000000..1ad91b7ca
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelAuthLayout.java
@@ -0,0 +1,63 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.home.HomeConfig.AuthConfig;
+import org.mozilla.gecko.home.HomeConfig.PanelConfig;
+
+import android.content.Context;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.Button;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.squareup.picasso.Picasso;
+
+class PanelAuthLayout extends LinearLayout {
+
+ public PanelAuthLayout(Context context, PanelConfig panelConfig) {
+ super(context);
+
+ final AuthConfig authConfig = panelConfig.getAuthConfig();
+ if (authConfig == null) {
+ throw new IllegalStateException("Can't create PanelAuthLayout without a valid AuthConfig");
+ }
+
+ setOrientation(LinearLayout.VERTICAL);
+ LayoutInflater.from(context).inflate(R.layout.panel_auth_layout, this);
+
+ final TextView messageView = (TextView) findViewById(R.id.message);
+ messageView.setText(authConfig.getMessageText());
+
+ final Button buttonView = (Button) findViewById(R.id.button);
+ buttonView.setText(authConfig.getButtonText());
+
+ final String panelId = panelConfig.getId();
+ buttonView.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ GeckoAppShell.notifyObservers("HomePanels:Authenticate", panelId);
+ }
+ });
+
+ final ImageView imageView = (ImageView) findViewById(R.id.image);
+ final String imageUrl = authConfig.getImageUrl();
+
+ if (TextUtils.isEmpty(imageUrl)) {
+ // Use a default image if an image URL isn't specified.
+ imageView.setImageResource(R.drawable.icon_home_empty_firefox);
+ } else {
+ ImageLoader.with(getContext())
+ .load(imageUrl)
+ .into(imageView);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelBackItemView.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelBackItemView.java
new file mode 100644
index 000000000..4772e08ab
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelBackItemView.java
@@ -0,0 +1,48 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.home.PanelLayout.FilterDetail;
+
+import android.content.Context;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.squareup.picasso.Picasso;
+
+class PanelBackItemView extends LinearLayout {
+ private final TextView title;
+
+ public PanelBackItemView(Context context, String backImageUrl) {
+ super(context);
+
+ LayoutInflater.from(context).inflate(R.layout.panel_back_item, this);
+ setOrientation(HORIZONTAL);
+
+ title = (TextView) findViewById(R.id.title);
+
+ final ImageView image = (ImageView) findViewById(R.id.image);
+
+ if (TextUtils.isEmpty(backImageUrl)) {
+ image.setImageResource(R.drawable.arrow_up);
+ } else {
+ ImageLoader.with(getContext())
+ .load(backImageUrl)
+ .placeholder(R.drawable.arrow_up)
+ .into(image);
+ }
+ }
+
+ public void updateFromFilter(FilterDetail filter) {
+ final String backText = getResources()
+ .getString(R.string.home_move_back_to_filter, filter.title);
+ title.setText(backText);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelHeaderView.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelHeaderView.java
new file mode 100644
index 000000000..50c4dbc07
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelHeaderView.java
@@ -0,0 +1,28 @@
+package org.mozilla.gecko.home;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.widget.ImageView;
+
+@SuppressLint("ViewConstructor") // View is only created from code
+public class PanelHeaderView extends ImageView {
+ public PanelHeaderView(Context context, HomeConfig.HeaderConfig config) {
+ super(context);
+
+ setAdjustViewBounds(true);
+
+ ImageLoader.with(context)
+ .load(config.getImageUrl())
+ .into(this);
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ int width = MeasureSpec.getSize(widthMeasureSpec);
+
+ // Always span the whole width and adjust height as needed.
+ widthMeasureSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY);
+
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelInfoManager.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelInfoManager.java
new file mode 100644
index 000000000..089e17837
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelInfoManager.java
@@ -0,0 +1,162 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoApp;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.home.HomeConfig.PanelConfig;
+import org.mozilla.gecko.util.GeckoEventListener;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.util.Log;
+import android.util.SparseArray;
+
+public class PanelInfoManager implements GeckoEventListener {
+ private static final String LOGTAG = "GeckoPanelInfoManager";
+
+ public class PanelInfo {
+ private final String mId;
+ private final String mTitle;
+ private final JSONObject mJSONData;
+
+ public PanelInfo(String id, String title, JSONObject jsonData) {
+ mId = id;
+ mTitle = title;
+ mJSONData = jsonData;
+ }
+
+ public String getId() {
+ return mId;
+ }
+
+ public String getTitle() {
+ return mTitle;
+ }
+
+ public PanelConfig toPanelConfig() {
+ try {
+ return new PanelConfig(mJSONData);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Failed to convert PanelInfo to PanelConfig", e);
+ return null;
+ }
+ }
+ }
+
+ public interface RequestCallback {
+ public void onComplete(List<PanelInfo> panelInfos);
+ }
+
+ private static final AtomicInteger sRequestId = new AtomicInteger(0);
+
+ // Stores set of pending request callbacks.
+ private static final SparseArray<RequestCallback> sCallbacks = new SparseArray<RequestCallback>();
+
+ /**
+ * Asynchronously fetches list of available panels from Gecko
+ * for the given IDs.
+ *
+ * @param ids list of panel ids to be fetched. A null value will fetch all
+ * available panels.
+ * @param callback onComplete will be called on the UI thread.
+ */
+ public void requestPanelsById(Set<String> ids, RequestCallback callback) {
+ final int requestId = sRequestId.getAndIncrement();
+
+ synchronized (sCallbacks) {
+ // If there are no pending callbacks, register the event listener.
+ if (sCallbacks.size() == 0) {
+ GeckoApp.getEventDispatcher().registerGeckoThreadListener(this,
+ "HomePanels:Data");
+ }
+ sCallbacks.put(requestId, callback);
+ }
+
+ final JSONObject message = new JSONObject();
+ try {
+ message.put("requestId", requestId);
+
+ if (ids != null && ids.size() > 0) {
+ JSONArray idsArray = new JSONArray();
+ for (String id : ids) {
+ idsArray.put(id);
+ }
+
+ message.put("ids", idsArray);
+ }
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Failed to build event to request panels by id", e);
+ return;
+ }
+
+ GeckoAppShell.notifyObservers("HomePanels:Get", message.toString());
+ }
+
+ /**
+ * Asynchronously fetches list of available panels from Gecko.
+ *
+ * @param callback onComplete will be called on the UI thread.
+ */
+ public void requestAvailablePanels(RequestCallback callback) {
+ requestPanelsById(null, callback);
+ }
+
+ /**
+ * Handles "HomePanels:Data" events.
+ */
+ @Override
+ public void handleMessage(String event, JSONObject message) {
+ final ArrayList<PanelInfo> panelInfos = new ArrayList<PanelInfo>();
+
+ try {
+ final JSONArray panels = message.getJSONArray("panels");
+ final int count = panels.length();
+ for (int i = 0; i < count; i++) {
+ final PanelInfo panelInfo = getPanelInfoFromJSON(panels.getJSONObject(i));
+ panelInfos.add(panelInfo);
+ }
+
+ final RequestCallback callback;
+ final int requestId = message.getInt("requestId");
+
+ synchronized (sCallbacks) {
+ callback = sCallbacks.get(requestId);
+ sCallbacks.delete(requestId);
+
+ // Unregister the event listener if there are no more pending callbacks.
+ if (sCallbacks.size() == 0) {
+ GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this,
+ "HomePanels:Data");
+ }
+ }
+
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ callback.onComplete(panelInfos);
+ }
+ });
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Exception handling " + event + " message", e);
+ }
+ }
+
+ private PanelInfo getPanelInfoFromJSON(JSONObject jsonPanelInfo) throws JSONException {
+ final String id = jsonPanelInfo.getString("id");
+ final String title = jsonPanelInfo.getString("title");
+
+ return new PanelInfo(id, title, jsonPanelInfo);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelItemView.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelItemView.java
new file mode 100644
index 000000000..2a97d42bc
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelItemView.java
@@ -0,0 +1,136 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.BrowserContract.HomeItems;
+import org.mozilla.gecko.home.HomeConfig.ItemType;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.graphics.Color;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+class PanelItemView extends LinearLayout {
+ private final TextView titleView;
+ private final TextView descriptionView;
+ private final ImageView imageView;
+ private final LinearLayout titleDescContainerView;
+ private final ImageView backgroundView;
+
+ private PanelItemView(Context context, int layoutId) {
+ super(context);
+
+ LayoutInflater.from(context).inflate(layoutId, this);
+ titleView = (TextView) findViewById(R.id.title);
+ descriptionView = (TextView) findViewById(R.id.description);
+ imageView = (ImageView) findViewById(R.id.image);
+ backgroundView = (ImageView) findViewById(R.id.background);
+ titleDescContainerView = (LinearLayout) findViewById(R.id.title_desc_container);
+ }
+
+ public void updateFromCursor(Cursor cursor) {
+ int titleIndex = cursor.getColumnIndexOrThrow(HomeItems.TITLE);
+ final String titleText = cursor.getString(titleIndex);
+
+ // Only show title if the item has one
+ final boolean hasTitle = !TextUtils.isEmpty(titleText);
+ titleView.setVisibility(hasTitle ? View.VISIBLE : View.GONE);
+ if (hasTitle) {
+ titleView.setText(titleText);
+ }
+
+ int descriptionIndex = cursor.getColumnIndexOrThrow(HomeItems.DESCRIPTION);
+ final String descriptionText = cursor.getString(descriptionIndex);
+
+ // Only show description if the item has one
+ // Descriptions are not supported for IconItemView objects (Bug 1157539)
+ final boolean hasDescription = !TextUtils.isEmpty(descriptionText);
+ if (descriptionView != null) {
+ descriptionView.setVisibility(hasDescription ? View.VISIBLE : View.GONE);
+ if (hasDescription) {
+ descriptionView.setText(descriptionText);
+ }
+ }
+ if (titleDescContainerView != null) {
+ titleDescContainerView.setVisibility(hasTitle || hasDescription ? View.VISIBLE : View.GONE);
+ }
+
+ int imageIndex = cursor.getColumnIndexOrThrow(HomeItems.IMAGE_URL);
+ final String imageUrl = cursor.getString(imageIndex);
+
+ // Only try to load the image if the item has define image URL
+ final boolean hasImageUrl = !TextUtils.isEmpty(imageUrl);
+ imageView.setVisibility(hasImageUrl ? View.VISIBLE : View.GONE);
+
+ if (hasImageUrl) {
+ ImageLoader.with(getContext())
+ .load(imageUrl)
+ .into(imageView);
+ }
+
+ final int columnIndexBackgroundColor = cursor.getColumnIndex(HomeItems.BACKGROUND_COLOR);
+ if (columnIndexBackgroundColor != -1) {
+ final String color = cursor.getString(columnIndexBackgroundColor);
+ if (!TextUtils.isEmpty(color)) {
+ setBackgroundColor(Color.parseColor(color));
+ }
+ }
+
+ // Backgrounds are only supported for IconItemView objects (Bug 1157539)
+ final int columnIndexBackgroundUrl = cursor.getColumnIndex(HomeItems.BACKGROUND_URL);
+ if (columnIndexBackgroundUrl != -1) {
+ final String backgroundUrl = cursor.getString(columnIndexBackgroundUrl);
+ if (backgroundView != null && !TextUtils.isEmpty(backgroundUrl)) {
+ ImageLoader.with(getContext())
+ .load(backgroundUrl)
+ .fit()
+ .into(backgroundView);
+ }
+ }
+ }
+
+ private static class ArticleItemView extends PanelItemView {
+ private ArticleItemView(Context context) {
+ super(context, R.layout.panel_article_item);
+ setOrientation(LinearLayout.HORIZONTAL);
+ }
+ }
+
+ private static class ImageItemView extends PanelItemView {
+ private ImageItemView(Context context) {
+ super(context, R.layout.panel_image_item);
+ setOrientation(LinearLayout.VERTICAL);
+ }
+ }
+
+ private static class IconItemView extends PanelItemView {
+ private IconItemView(Context context) {
+ super(context, R.layout.panel_icon_item);
+ }
+ }
+
+ public static PanelItemView create(Context context, ItemType itemType) {
+ switch (itemType) {
+ case ARTICLE:
+ return new ArticleItemView(context);
+
+ case IMAGE:
+ return new ImageItemView(context);
+
+ case ICON:
+ return new IconItemView(context);
+
+ default:
+ throw new IllegalArgumentException("Could not create panel item view from " + itemType);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelLayout.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelLayout.java
new file mode 100644
index 000000000..2c2d89ae0
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelLayout.java
@@ -0,0 +1,747 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.BrowserContract.HomeItems;
+import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
+import org.mozilla.gecko.home.HomeConfig.EmptyViewConfig;
+import org.mozilla.gecko.home.HomeConfig.ItemHandler;
+import org.mozilla.gecko.home.HomeConfig.PanelConfig;
+import org.mozilla.gecko.home.HomeConfig.ViewConfig;
+import org.mozilla.gecko.util.StringUtils;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.text.TextUtils;
+import android.util.Log;
+import android.util.SparseArray;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import java.lang.ref.SoftReference;
+import java.util.EnumSet;
+import java.util.LinkedList;
+import java.util.Map;
+import java.util.WeakHashMap;
+
+import com.squareup.picasso.Picasso;
+
+/**
+ * {@code PanelLayout} is the base class for custom layouts to be
+ * used in {@code DynamicPanel}. It provides the basic framework
+ * that enables custom layouts to request and reset datasets and
+ * create panel views. Furthermore, it automates most of the process
+ * of binding panel views with their respective datasets.
+ *
+ * {@code PanelLayout} abstracts the implemention details of how
+ * datasets are actually loaded through the {@DatasetHandler} interface.
+ * {@code DatasetHandler} provides two operations: request and reset.
+ * The results of the dataset requests done via the {@code DatasetHandler}
+ * are delivered to the {@code PanelLayout} with the {@code deliverDataset()}
+ * method.
+ *
+ * Subclasses of {@code PanelLayout} should simply use the utilities
+ * provided by {@code PanelLayout}. Namely:
+ *
+ * {@code requestDataset()} - To fetch datasets and auto-bind them to
+ * the existing panel views backed by them.
+ *
+ * {@code resetDataset()} - To release any resources associated with a
+ * previously loaded dataset.
+ *
+ * {@code createPanelView()} - To create a panel view for a ViewConfig
+ * associated with the panel.
+ *
+ * {@code disposePanelView()} - To dispose any dataset references associated
+ * with the given view.
+ *
+ * {@code PanelLayout} subclasses should always use {@code createPanelView()}
+ * to create the views dynamically created based on {@code ViewConfig}. This
+ * allows {@code PanelLayout} to auto-bind datasets with panel views.
+ * {@code PanelLayout} subclasses are free to have any type of views to arrange
+ * the panel views in different ways.
+ */
+abstract class PanelLayout extends FrameLayout {
+ private static final String LOGTAG = "GeckoPanelLayout";
+
+ protected final SparseArray<ViewState> mViewStates;
+ private final PanelConfig mPanelConfig;
+ private final DatasetHandler mDatasetHandler;
+ private final OnUrlOpenListener mUrlOpenListener;
+ private final ContextMenuRegistry mContextMenuRegistry;
+
+ /**
+ * To be used by panel views to express that they are
+ * backed by datasets.
+ */
+ public interface DatasetBacked {
+ public void setDataset(Cursor cursor);
+ public void setFilterManager(FilterManager manager);
+ }
+
+ /**
+ * To be used by requests made to {@code DatasetHandler}s to couple dataset ID with current
+ * filter for queries on the database.
+ */
+ public static class DatasetRequest implements Parcelable {
+ public enum Type implements Parcelable {
+ DATASET_LOAD,
+ FILTER_PUSH,
+ FILTER_POP;
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(ordinal());
+ }
+
+ public static final Creator<Type> CREATOR = new Creator<Type>() {
+ @Override
+ public Type createFromParcel(final Parcel source) {
+ return Type.values()[source.readInt()];
+ }
+
+ @Override
+ public Type[] newArray(final int size) {
+ return new Type[size];
+ }
+ };
+ }
+
+ private final int mViewIndex;
+ private final Type mType;
+ private final String mDatasetId;
+ private final FilterDetail mFilterDetail;
+
+ private DatasetRequest(Parcel in) {
+ this.mViewIndex = in.readInt();
+ this.mType = (Type) in.readParcelable(getClass().getClassLoader());
+ this.mDatasetId = in.readString();
+ this.mFilterDetail = (FilterDetail) in.readParcelable(getClass().getClassLoader());
+ }
+
+ public DatasetRequest(int index, String datasetId, FilterDetail filterDetail) {
+ this(index, Type.DATASET_LOAD, datasetId, filterDetail);
+ }
+
+ public DatasetRequest(int index, Type type, String datasetId, FilterDetail filterDetail) {
+ this.mViewIndex = index;
+ this.mType = type;
+ this.mDatasetId = datasetId;
+ this.mFilterDetail = filterDetail;
+ }
+
+ public int getViewIndex() {
+ return mViewIndex;
+ }
+
+ public Type getType() {
+ return mType;
+ }
+
+ public String getDatasetId() {
+ return mDatasetId;
+ }
+
+ public String getFilter() {
+ return (mFilterDetail != null ? mFilterDetail.filter : null);
+ }
+
+ public FilterDetail getFilterDetail() {
+ return mFilterDetail;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(mViewIndex);
+ dest.writeParcelable(mType, 0);
+ dest.writeString(mDatasetId);
+ dest.writeParcelable(mFilterDetail, 0);
+ }
+
+ public String toString() {
+ return "{ index: " + mViewIndex +
+ ", type: " + mType +
+ ", dataset: " + mDatasetId +
+ ", filter: " + mFilterDetail +
+ " }";
+ }
+
+ public static final Creator<DatasetRequest> CREATOR = new Creator<DatasetRequest>() {
+ @Override
+ public DatasetRequest createFromParcel(Parcel in) {
+ return new DatasetRequest(in);
+ }
+
+ @Override
+ public DatasetRequest[] newArray(int size) {
+ return new DatasetRequest[size];
+ }
+ };
+ }
+
+ /**
+ * Defines the contract with the component that is responsible
+ * for handling datasets requests.
+ */
+ public interface DatasetHandler {
+ /**
+ * Requests a dataset to be fetched and auto-bound to the
+ * panel views backed by it.
+ */
+ public void requestDataset(DatasetRequest request);
+
+ /**
+ * Releases any resources associated with a panel view. It will
+ * do nothing if the view with the given index been created
+ * before.
+ */
+ public void resetDataset(int viewIndex);
+ }
+
+ public interface PanelView {
+ public void setOnItemOpenListener(OnItemOpenListener listener);
+ public void setOnKeyListener(OnKeyListener listener);
+ public void setContextMenuInfoFactory(HomeContextMenuInfo.Factory factory);
+ }
+
+ public interface FilterManager {
+ public FilterDetail getPreviousFilter();
+ public boolean canGoBack();
+ public void goBack();
+ }
+
+ public interface ContextMenuRegistry {
+ public void register(View view);
+ }
+
+ public PanelLayout(Context context, PanelConfig panelConfig, DatasetHandler datasetHandler,
+ OnUrlOpenListener urlOpenListener, ContextMenuRegistry contextMenuRegistry) {
+ super(context);
+ mViewStates = new SparseArray<ViewState>();
+ mPanelConfig = panelConfig;
+ mDatasetHandler = datasetHandler;
+ mUrlOpenListener = urlOpenListener;
+ mContextMenuRegistry = contextMenuRegistry;
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+
+ final int count = mViewStates.size();
+ for (int i = 0; i < count; i++) {
+ final ViewState viewState = mViewStates.valueAt(i);
+
+ final View view = viewState.getView();
+ if (view != null) {
+ maybeSetDataset(view, null);
+ }
+ }
+ mViewStates.clear();
+ }
+
+ /**
+ * Delivers the dataset as a {@code Cursor} to be bound to the
+ * panel view backed by it. This is used by the {@code DatasetHandler}
+ * in response to a dataset request.
+ */
+ public final void deliverDataset(DatasetRequest request, Cursor cursor) {
+ Log.d(LOGTAG, "Delivering request: " + request);
+ final ViewState viewState = mViewStates.get(request.getViewIndex());
+ if (viewState == null) {
+ return;
+ }
+
+ switch (request.getType()) {
+ case FILTER_PUSH:
+ viewState.pushFilter(request.getFilterDetail());
+ break;
+ case FILTER_POP:
+ viewState.popFilter();
+ break;
+ }
+
+ final View activeView = viewState.getActiveView();
+ if (activeView == null) {
+ throw new IllegalStateException("No active view for view state: " + viewState.getIndex());
+ }
+
+ final ViewConfig viewConfig = viewState.getViewConfig();
+
+ final View newView;
+ if (cursor == null || cursor.getCount() == 0) {
+ newView = createEmptyView(viewConfig);
+ maybeSetDataset(activeView, null);
+ } else {
+ newView = createPanelView(viewConfig);
+ maybeSetDataset(newView, cursor);
+ }
+
+ if (activeView != newView) {
+ replacePanelView(activeView, newView);
+ }
+ }
+
+ /**
+ * Releases any references to the given dataset from all
+ * existing panel views.
+ */
+ public final void releaseDataset(int viewIndex) {
+ Log.d(LOGTAG, "Releasing dataset: " + viewIndex);
+ final ViewState viewState = mViewStates.get(viewIndex);
+ if (viewState == null) {
+ return;
+ }
+
+ final View view = viewState.getView();
+ if (view != null) {
+ maybeSetDataset(view, null);
+ }
+ }
+
+ /**
+ * Requests a dataset to be loaded and bound to any existing
+ * panel view backed by it.
+ */
+ protected final void requestDataset(DatasetRequest request) {
+ Log.d(LOGTAG, "Requesting request: " + request);
+ if (mViewStates.get(request.getViewIndex()) == null) {
+ return;
+ }
+
+ mDatasetHandler.requestDataset(request);
+ }
+
+ /**
+ * Releases any resources associated with a panel view.
+ * e.g. close any associated {@code Cursor}.
+ */
+ protected final void resetDataset(int viewIndex) {
+ Log.d(LOGTAG, "Resetting view with index: " + viewIndex);
+ if (mViewStates.get(viewIndex) == null) {
+ return;
+ }
+
+ mDatasetHandler.resetDataset(viewIndex);
+ }
+
+ /**
+ * Factory method to create instance of panels from a given
+ * {@code ViewConfig}. All panel views defined in {@code PanelConfig}
+ * should be created using this method so that {@PanelLayout} can
+ * keep track of panel views and their associated datasets.
+ */
+ protected final View createPanelView(ViewConfig viewConfig) {
+ Log.d(LOGTAG, "Creating panel view: " + viewConfig.getType());
+
+ ViewState viewState = mViewStates.get(viewConfig.getIndex());
+ if (viewState == null) {
+ viewState = new ViewState(viewConfig);
+ mViewStates.put(viewConfig.getIndex(), viewState);
+ }
+
+ View view = viewState.getView();
+ if (view == null) {
+ switch (viewConfig.getType()) {
+ case LIST:
+ view = new PanelListView(getContext(), viewConfig);
+ break;
+
+ case GRID:
+ view = new PanelRecyclerView(getContext(), viewConfig);
+ break;
+
+ default:
+ throw new IllegalStateException("Unrecognized view type in " + getClass().getSimpleName());
+ }
+
+ PanelView panelView = (PanelView) view;
+ panelView.setOnItemOpenListener(new PanelOnItemOpenListener(viewState));
+ panelView.setOnKeyListener(new PanelKeyListener(viewState));
+ panelView.setContextMenuInfoFactory(new HomeContextMenuInfo.Factory() {
+ @Override
+ public HomeContextMenuInfo makeInfoForCursor(View view, int position, long id, Cursor cursor) {
+ final HomeContextMenuInfo info = new HomeContextMenuInfo(view, position, id);
+ info.url = cursor.getString(cursor.getColumnIndexOrThrow(HomeItems.URL));
+ info.title = cursor.getString(cursor.getColumnIndexOrThrow(HomeItems.TITLE));
+ return info;
+ }
+ });
+
+ mContextMenuRegistry.register(view);
+
+ if (view instanceof DatasetBacked) {
+ DatasetBacked datasetBacked = (DatasetBacked) view;
+ datasetBacked.setFilterManager(new PanelFilterManager(viewState));
+
+ if (viewConfig.isRefreshEnabled()) {
+ view = new PanelRefreshLayout(getContext(), view,
+ mPanelConfig.getId(), viewConfig.getIndex());
+ }
+ }
+
+ viewState.setView(view);
+ }
+
+ return view;
+ }
+
+ /**
+ * Dispose any dataset references associated with the
+ * given view.
+ */
+ protected final void disposePanelView(View view) {
+ Log.d(LOGTAG, "Disposing panel view");
+ final int count = mViewStates.size();
+ for (int i = 0; i < count; i++) {
+ final ViewState viewState = mViewStates.valueAt(i);
+
+ if (viewState.getView() == view) {
+ maybeSetDataset(view, null);
+ mViewStates.remove(viewState.getIndex());
+ break;
+ }
+ }
+ }
+
+ private void maybeSetDataset(View view, Cursor cursor) {
+ if (view instanceof DatasetBacked) {
+ final DatasetBacked dsb = (DatasetBacked) view;
+ dsb.setDataset(cursor);
+ }
+ }
+
+ private View createEmptyView(ViewConfig viewConfig) {
+ Log.d(LOGTAG, "Creating empty view: " + viewConfig.getType());
+
+ ViewState viewState = mViewStates.get(viewConfig.getIndex());
+ if (viewState == null) {
+ throw new IllegalStateException("No view state found for view index: " + viewConfig.getIndex());
+ }
+
+ View view = viewState.getEmptyView();
+ if (view == null) {
+ view = LayoutInflater.from(getContext()).inflate(R.layout.home_empty_panel, null);
+
+ final EmptyViewConfig emptyViewConfig = viewConfig.getEmptyViewConfig();
+
+ // XXX: Refactor this into a custom view (bug 985134)
+ final String text = (emptyViewConfig == null) ? null : emptyViewConfig.getText();
+ final TextView textView = (TextView) view.findViewById(R.id.home_empty_text);
+ if (TextUtils.isEmpty(text)) {
+ textView.setText(R.string.home_default_empty);
+ } else {
+ textView.setText(text);
+ }
+
+ final String imageUrl = (emptyViewConfig == null) ? null : emptyViewConfig.getImageUrl();
+ final ImageView imageView = (ImageView) view.findViewById(R.id.home_empty_image);
+
+ if (TextUtils.isEmpty(imageUrl)) {
+ imageView.setImageResource(R.drawable.icon_home_empty_firefox);
+ } else {
+ ImageLoader.with(getContext())
+ .load(imageUrl)
+ .error(R.drawable.icon_home_empty_firefox)
+ .into(imageView);
+ }
+
+ viewState.setEmptyView(view);
+ }
+
+ return view;
+ }
+
+ private void replacePanelView(View currentView, View newView) {
+ final ViewGroup parent = (ViewGroup) currentView.getParent();
+ parent.addView(newView, parent.indexOfChild(currentView), currentView.getLayoutParams());
+ parent.removeView(currentView);
+ }
+
+ /**
+ * Must be implemented by {@code PanelLayout} subclasses to define
+ * what happens then the layout is first loaded. Should set initial
+ * UI state and request any necessary datasets.
+ */
+ public abstract void load();
+
+ /**
+ * Represents a 'live' instance of a panel view associated with
+ * the {@code PanelLayout}. Is responsible for tracking the history stack of filters.
+ */
+ protected class ViewState {
+ private final ViewConfig mViewConfig;
+ private SoftReference<View> mView;
+ private SoftReference<View> mEmptyView;
+ private LinkedList<FilterDetail> mFilterStack;
+
+ public ViewState(ViewConfig viewConfig) {
+ mViewConfig = viewConfig;
+ mView = new SoftReference<View>(null);
+ mEmptyView = new SoftReference<View>(null);
+ }
+
+ public ViewConfig getViewConfig() {
+ return mViewConfig;
+ }
+
+ public int getIndex() {
+ return mViewConfig.getIndex();
+ }
+
+ public View getView() {
+ return mView.get();
+ }
+
+ public void setView(View view) {
+ mView = new SoftReference<View>(view);
+ }
+
+ public View getEmptyView() {
+ return mEmptyView.get();
+ }
+
+ public void setEmptyView(View view) {
+ mEmptyView = new SoftReference<View>(view);
+ }
+
+ public View getActiveView() {
+ final View view = getView();
+ if (view != null && view.getParent() != null) {
+ return view;
+ }
+
+ final View emptyView = getEmptyView();
+ if (emptyView != null && emptyView.getParent() != null) {
+ return emptyView;
+ }
+
+ return null;
+ }
+
+ public String getDatasetId() {
+ return mViewConfig.getDatasetId();
+ }
+
+ public ItemHandler getItemHandler() {
+ return mViewConfig.getItemHandler();
+ }
+
+ /**
+ * Get the current filter that this view is displaying, or null if none.
+ */
+ public FilterDetail getCurrentFilter() {
+ if (mFilterStack == null) {
+ return null;
+ } else {
+ return mFilterStack.peek();
+ }
+ }
+
+ /**
+ * Get the previous filter that this view was displaying, or null if none.
+ */
+ public FilterDetail getPreviousFilter() {
+ if (!canPopFilter()) {
+ return null;
+ }
+
+ return mFilterStack.get(1);
+ }
+
+ /**
+ * Adds a filter to the history stack for this view.
+ */
+ public void pushFilter(FilterDetail filter) {
+ if (mFilterStack == null) {
+ mFilterStack = new LinkedList<FilterDetail>();
+
+ // Initialize with the initial filter.
+ mFilterStack.push(new FilterDetail(mViewConfig.getFilter(),
+ mPanelConfig.getTitle()));
+ }
+
+ mFilterStack.push(filter);
+ }
+
+ /**
+ * Remove the most recent filter from the stack.
+ *
+ * @return whether the filter was popped
+ */
+ public boolean popFilter() {
+ if (!canPopFilter()) {
+ return false;
+ }
+
+ mFilterStack.pop();
+ return true;
+ }
+
+ public boolean canPopFilter() {
+ return (mFilterStack != null && mFilterStack.size() > 1);
+ }
+ }
+
+ static class FilterDetail implements Parcelable {
+ final String filter;
+ final String title;
+
+ private FilterDetail(Parcel in) {
+ this.filter = in.readString();
+ this.title = in.readString();
+ }
+
+ public FilterDetail(String filter, String title) {
+ this.filter = filter;
+ this.title = title;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeString(filter);
+ dest.writeString(title);
+ }
+
+ public static final Creator<FilterDetail> CREATOR = new Creator<FilterDetail>() {
+ @Override
+ public FilterDetail createFromParcel(Parcel in) {
+ return new FilterDetail(in);
+ }
+
+ @Override
+ public FilterDetail[] newArray(int size) {
+ return new FilterDetail[size];
+ }
+ };
+ }
+
+ /**
+ * Pushes filter to {@code ViewState}'s stack and makes request for new filter value.
+ */
+ private void pushFilterOnView(ViewState viewState, FilterDetail filterDetail) {
+ final int index = viewState.getIndex();
+ final String datasetId = viewState.getDatasetId();
+
+ mDatasetHandler.requestDataset(new DatasetRequest(index,
+ DatasetRequest.Type.FILTER_PUSH,
+ datasetId,
+ filterDetail));
+ }
+
+ /**
+ * Pops filter from {@code ViewState}'s stack and makes request for previous filter value.
+ *
+ * @return whether the filter has changed
+ */
+ private boolean popFilterOnView(ViewState viewState) {
+ if (viewState.canPopFilter()) {
+ final int index = viewState.getIndex();
+ final String datasetId = viewState.getDatasetId();
+ final FilterDetail filterDetail = viewState.getPreviousFilter();
+
+ mDatasetHandler.requestDataset(new DatasetRequest(index,
+ DatasetRequest.Type.FILTER_POP,
+ datasetId,
+ filterDetail));
+
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ public interface OnItemOpenListener {
+ public void onItemOpen(String url, String title);
+ }
+
+ private class PanelOnItemOpenListener implements OnItemOpenListener {
+ private final ViewState mViewState;
+
+ public PanelOnItemOpenListener(ViewState viewState) {
+ mViewState = viewState;
+ }
+
+ @Override
+ public void onItemOpen(String url, String title) {
+ if (StringUtils.isFilterUrl(url)) {
+ FilterDetail filterDetail = new FilterDetail(StringUtils.getFilterFromUrl(url), title);
+ pushFilterOnView(mViewState, filterDetail);
+ } else {
+ EnumSet<OnUrlOpenListener.Flags> flags = EnumSet.noneOf(OnUrlOpenListener.Flags.class);
+ if (mViewState.getItemHandler() == ItemHandler.INTENT) {
+ flags.add(OnUrlOpenListener.Flags.OPEN_WITH_INTENT);
+ }
+
+ mUrlOpenListener.onUrlOpen(url, flags);
+ }
+ }
+ }
+
+ private class PanelKeyListener implements View.OnKeyListener {
+ private final ViewState mViewState;
+
+ public PanelKeyListener(ViewState viewState) {
+ mViewState = viewState;
+ }
+
+ @Override
+ public boolean onKey(View v, int keyCode, KeyEvent event) {
+ if (event.getAction() == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) {
+ return popFilterOnView(mViewState);
+ }
+
+ return false;
+ }
+ }
+
+ private class PanelFilterManager implements FilterManager {
+ private final ViewState mViewState;
+
+ public PanelFilterManager(ViewState viewState) {
+ mViewState = viewState;
+ }
+
+ @Override
+ public FilterDetail getPreviousFilter() {
+ return mViewState.getPreviousFilter();
+ }
+
+ @Override
+ public boolean canGoBack() {
+ return mViewState.canPopFilter();
+ }
+
+ @Override
+ public void goBack() {
+ popFilterOnView(mViewState);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelListView.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelListView.java
new file mode 100644
index 000000000..505fb9b0d
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelListView.java
@@ -0,0 +1,83 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import java.util.EnumSet;
+
+import org.mozilla.gecko.db.BrowserContract.HomeItems;
+import org.mozilla.gecko.home.HomeConfig.ItemHandler;
+import org.mozilla.gecko.home.HomeConfig.ViewConfig;
+import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
+import org.mozilla.gecko.home.PanelLayout.DatasetBacked;
+import org.mozilla.gecko.home.PanelLayout.FilterManager;
+import org.mozilla.gecko.home.PanelLayout.OnItemOpenListener;
+import org.mozilla.gecko.home.PanelLayout.PanelView;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.util.Log;
+import android.view.View;
+import android.widget.AdapterView;
+
+public class PanelListView extends HomeListView
+ implements DatasetBacked, PanelView {
+
+ private static final String LOGTAG = "GeckoPanelListView";
+
+ private final ViewConfig viewConfig;
+ private final PanelViewAdapter adapter;
+ private final PanelViewItemHandler itemHandler;
+ private OnItemOpenListener itemOpenListener;
+
+ public PanelListView(Context context, ViewConfig viewConfig) {
+ super(context);
+
+ this.viewConfig = viewConfig;
+ itemHandler = new PanelViewItemHandler();
+
+ adapter = new PanelViewAdapter(context, viewConfig);
+ setAdapter(adapter);
+
+ setOnItemClickListener(new PanelListItemClickListener());
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ itemHandler.setOnItemOpenListener(itemOpenListener);
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ itemHandler.setOnItemOpenListener(null);
+ }
+
+ @Override
+ public void setDataset(Cursor cursor) {
+ Log.d(LOGTAG, "Setting dataset: " + viewConfig.getDatasetId());
+ adapter.swapCursor(cursor);
+ }
+
+ @Override
+ public void setOnItemOpenListener(OnItemOpenListener listener) {
+ itemHandler.setOnItemOpenListener(listener);
+ itemOpenListener = listener;
+ }
+
+ @Override
+ public void setFilterManager(FilterManager filterManager) {
+ adapter.setFilterManager(filterManager);
+ itemHandler.setFilterManager(filterManager);
+ }
+
+ private class PanelListItemClickListener implements AdapterView.OnItemClickListener {
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ itemHandler.openItemAtPosition(adapter.getCursor(), position);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelRecyclerView.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelRecyclerView.java
new file mode 100644
index 000000000..9145ab1e1
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelRecyclerView.java
@@ -0,0 +1,178 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.home.PanelLayout.DatasetBacked;
+import org.mozilla.gecko.home.PanelLayout.PanelView;
+import org.mozilla.gecko.widget.RecyclerViewClickSupport;
+import org.mozilla.gecko.widget.RecyclerViewClickSupport.OnItemClickListener;
+import org.mozilla.gecko.widget.RecyclerViewClickSupport.OnItemLongClickListener;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.support.v7.widget.GridLayoutManager;
+import android.support.v7.widget.RecyclerView;
+import android.view.View;
+
+/**
+ * RecyclerView implementation for grid home panels.
+ */
+@SuppressLint("ViewConstructor") // View is only created from code
+public class PanelRecyclerView extends RecyclerView
+ implements DatasetBacked, PanelView, OnItemClickListener, OnItemLongClickListener {
+ private final PanelRecyclerViewAdapter adapter;
+ private final GridLayoutManager layoutManager;
+ private final PanelViewItemHandler itemHandler;
+ private final float columnWidth;
+ private final boolean autoFit;
+ private final HomeConfig.ViewConfig viewConfig;
+
+ private PanelLayout.OnItemOpenListener itemOpenListener;
+ private HomeContextMenuInfo contextMenuInfo;
+ private HomeContextMenuInfo.Factory contextMenuInfoFactory;
+
+ public PanelRecyclerView(Context context, HomeConfig.ViewConfig viewConfig) {
+ super(context);
+
+ this.viewConfig = viewConfig;
+
+ final Resources resources = context.getResources();
+
+ int spanCount;
+ if (viewConfig.getItemType() == HomeConfig.ItemType.ICON) {
+ autoFit = false;
+ spanCount = getResources().getInteger(R.integer.panel_icon_grid_view_columns);
+ } else {
+ autoFit = true;
+ spanCount = 1;
+ }
+
+ columnWidth = resources.getDimension(R.dimen.panel_grid_view_column_width);
+ layoutManager = new GridLayoutManager(context, spanCount);
+ adapter = new PanelRecyclerViewAdapter(context, viewConfig);
+ itemHandler = new PanelViewItemHandler();
+
+ layoutManager.setSpanSizeLookup(new PanelSpanSizeLookup());
+
+ setLayoutManager(layoutManager);
+ setAdapter(adapter);
+
+ int horizontalSpacing = (int) resources.getDimension(R.dimen.panel_grid_view_horizontal_spacing);
+ int verticalSpacing = (int) resources.getDimension(R.dimen.panel_grid_view_vertical_spacing);
+ int outerSpacing = (int) resources.getDimension(R.dimen.panel_grid_view_outer_spacing);
+
+ addItemDecoration(new SpacingDecoration(horizontalSpacing, verticalSpacing));
+
+ setPadding(outerSpacing, outerSpacing, outerSpacing, outerSpacing);
+ setClipToPadding(false);
+
+ RecyclerViewClickSupport.addTo(this)
+ .setOnItemClickListener(this)
+ .setOnItemLongClickListener(this);
+ }
+
+ @Override
+ protected void onMeasure(int widthSpec, int heightSpec) {
+ super.onMeasure(widthSpec, heightSpec);
+
+ if (autoFit) {
+ // Adjust span based on space available (What GridView does when you say numColumns="auto_fit")
+ final int spanCount = (int) Math.max(1, getMeasuredWidth() / columnWidth);
+ layoutManager.setSpanCount(spanCount);
+ }
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ itemHandler.setOnItemOpenListener(itemOpenListener);
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ itemHandler.setOnItemOpenListener(null);
+ }
+
+ @Override
+ public void setDataset(Cursor cursor) {
+ adapter.swapCursor(cursor);
+ }
+
+ @Override
+ public void setFilterManager(PanelLayout.FilterManager manager) {
+ adapter.setFilterManager(manager);
+ itemHandler.setFilterManager(manager);
+ }
+
+ @Override
+ public void setOnItemOpenListener(PanelLayout.OnItemOpenListener listener) {
+ itemOpenListener = listener;
+ itemHandler.setOnItemOpenListener(listener);
+ }
+
+ @Override
+ public HomeContextMenuInfo getContextMenuInfo() {
+ return contextMenuInfo;
+ }
+
+ @Override
+ public void setContextMenuInfoFactory(HomeContextMenuInfo.Factory factory) {
+ contextMenuInfoFactory = factory;
+ }
+
+ @Override
+ public void onItemClicked(RecyclerView recyclerView, int position, View v) {
+ if (viewConfig.hasHeaderConfig()) {
+ if (position == 0) {
+ itemOpenListener.onItemOpen(viewConfig.getHeaderConfig().getUrl(), null);
+ return;
+ }
+
+ position--;
+ }
+
+ itemHandler.openItemAtPosition(adapter.getCursor(), position);
+ }
+
+ @Override
+ public boolean onItemLongClicked(RecyclerView recyclerView, int position, View v) {
+ if (viewConfig.hasHeaderConfig()) {
+ if (position == 0) {
+ final HomeConfig.HeaderConfig headerConfig = viewConfig.getHeaderConfig();
+
+ final HomeContextMenuInfo info = new HomeContextMenuInfo(v, position, -1);
+ info.url = headerConfig.getUrl();
+ info.title = headerConfig.getUrl();
+
+ contextMenuInfo = info;
+ return showContextMenuForChild(this);
+ }
+
+ position--;
+ }
+
+ Cursor cursor = adapter.getCursor();
+ cursor.moveToPosition(position);
+
+ contextMenuInfo = contextMenuInfoFactory.makeInfoForCursor(recyclerView, position, -1, cursor);
+ return showContextMenuForChild(this);
+ }
+
+ private class PanelSpanSizeLookup extends GridLayoutManager.SpanSizeLookup {
+ @Override
+ public int getSpanSize(int position) {
+ if (position == 0 && viewConfig.hasHeaderConfig()) {
+ return layoutManager.getSpanCount();
+ }
+
+ return 1;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelRecyclerViewAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelRecyclerViewAdapter.java
new file mode 100644
index 000000000..fa632bccd
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelRecyclerViewAdapter.java
@@ -0,0 +1,137 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import org.mozilla.gecko.R;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.support.v7.widget.RecyclerView;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+
+public class PanelRecyclerViewAdapter extends RecyclerView.Adapter<PanelRecyclerViewAdapter.PanelViewHolder> {
+ private static final int VIEW_TYPE_ITEM = 0;
+ private static final int VIEW_TYPE_BACK = 1;
+ private static final int VIEW_TYPE_HEADER = 2;
+
+ public static class PanelViewHolder extends RecyclerView.ViewHolder {
+ public static PanelViewHolder create(View itemView) {
+
+ // Wrap in a FrameLayout that will handle the highlight on touch
+ FrameLayout frameLayout = (FrameLayout) LayoutInflater.from(itemView.getContext())
+ .inflate(R.layout.panel_item_container, null);
+
+ frameLayout.addView(itemView, 0, new FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
+
+ return new PanelViewHolder(frameLayout);
+ }
+
+ private PanelViewHolder(View itemView) {
+ super(itemView);
+ }
+ }
+
+ private final Context context;
+ private final HomeConfig.ViewConfig viewConfig;
+ private PanelLayout.FilterManager filterManager;
+ private Cursor cursor;
+
+ public PanelRecyclerViewAdapter(Context context, HomeConfig.ViewConfig viewConfig) {
+ this.context = context;
+ this.viewConfig = viewConfig;
+ }
+
+ public void setFilterManager(PanelLayout.FilterManager filterManager) {
+ this.filterManager = filterManager;
+ }
+
+ private boolean isShowingBack() {
+ return filterManager != null && filterManager.canGoBack();
+ }
+
+ public void swapCursor(Cursor cursor) {
+ this.cursor = cursor;
+
+ notifyDataSetChanged();
+ }
+
+ public Cursor getCursor() {
+ return cursor;
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ if (viewConfig.hasHeaderConfig() && position == 0) {
+ return VIEW_TYPE_HEADER;
+ } else if (isShowingBack() && position == getBackPosition()) {
+ return VIEW_TYPE_BACK;
+ } else {
+ return VIEW_TYPE_ITEM;
+ }
+ }
+
+ @Override
+ public PanelViewHolder onCreateViewHolder(ViewGroup viewGroup, int viewType) {
+ switch (viewType) {
+ case VIEW_TYPE_HEADER:
+ return PanelViewHolder.create(new PanelHeaderView(context, viewConfig.getHeaderConfig()));
+ case VIEW_TYPE_BACK:
+ return PanelViewHolder.create(new PanelBackItemView(context, viewConfig.getBackImageUrl()));
+ case VIEW_TYPE_ITEM:
+ return PanelViewHolder.create(PanelItemView.create(context, viewConfig.getItemType()));
+ default:
+ throw new IllegalArgumentException("Unknown view type: " + viewType);
+ }
+ }
+
+ @Override
+ public void onBindViewHolder(PanelViewHolder panelViewHolder, int position) {
+ final View view = ((FrameLayout) panelViewHolder.itemView).getChildAt(0);
+
+ if (viewConfig.hasHeaderConfig()) {
+ if (position == 0) {
+ // Nothing to do here, the header is static
+ return;
+ }
+ }
+
+ if (isShowingBack()) {
+ if (position == getBackPosition()) {
+ final PanelBackItemView item = (PanelBackItemView) view;
+ item.updateFromFilter(filterManager.getPreviousFilter());
+ return;
+ }
+ }
+
+ int actualPosition = position
+ - (isShowingBack() ? 1 : 0)
+ - (viewConfig.hasHeaderConfig() ? 1 : 0);
+
+ cursor.moveToPosition(actualPosition);
+
+ final PanelItemView panelItemView = (PanelItemView) view;
+ panelItemView.updateFromCursor(cursor);
+ }
+
+ private int getBackPosition() {
+ return viewConfig.hasHeaderConfig() ? 1 : 0;
+ }
+
+ @Override
+ public int getItemCount() {
+ if (cursor == null) {
+ return 0;
+ }
+
+ return cursor.getCount()
+ + (isShowingBack() ? 1 : 0)
+ + (viewConfig.hasHeaderConfig() ? 1 : 0);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelRefreshLayout.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelRefreshLayout.java
new file mode 100644
index 000000000..d43a97f31
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelRefreshLayout.java
@@ -0,0 +1,90 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import org.mozilla.gecko.R;
+
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.home.PanelLayout.DatasetBacked;
+import org.mozilla.gecko.home.PanelLayout.FilterManager;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.support.v4.widget.SwipeRefreshLayout;
+import android.util.Log;
+import android.view.View;
+
+/**
+ * Used to wrap a {@code DatasetBacked} ListView or GridView to give the child view swipe-to-refresh
+ * capabilities.
+ *
+ * This view acts as a decorator to forward the {@code DatasetBacked} methods to the child view
+ * while providing the refresh gesture support on top of it.
+ */
+class PanelRefreshLayout extends SwipeRefreshLayout implements DatasetBacked {
+ private static final String LOGTAG = "GeckoPanelRefreshLayout";
+
+ private static final String JSON_KEY_PANEL_ID = "panelId";
+ private static final String JSON_KEY_VIEW_INDEX = "viewIndex";
+
+ private final String panelId;
+ private final int viewIndex;
+ private final DatasetBacked datasetBacked;
+
+ /**
+ * @param context Android context.
+ * @param childView ListView or GridView. Must implement {@code DatasetBacked}.
+ * @param panelId The ID from the {@code PanelConfig}.
+ * @param viewIndex The index from the {@code ViewConfig}.
+ */
+ public PanelRefreshLayout(Context context, View childView, String panelId, int viewIndex) {
+ super(context);
+
+ if (!(childView instanceof DatasetBacked)) {
+ throw new IllegalArgumentException("View must implement DatasetBacked to be refreshed");
+ }
+
+ this.panelId = panelId;
+ this.viewIndex = viewIndex;
+ this.datasetBacked = (DatasetBacked) childView;
+
+ setOnRefreshListener(new RefreshListener());
+ addView(childView);
+
+ // Must be called after the child view has been added.
+ setColorSchemeResources(R.color.fennec_ui_orange, R.color.action_orange);
+ }
+
+ @Override
+ public void setDataset(Cursor cursor) {
+ datasetBacked.setDataset(cursor);
+ setRefreshing(false);
+ }
+
+ @Override
+ public void setFilterManager(FilterManager manager) {
+ datasetBacked.setFilterManager(manager);
+ }
+
+ private class RefreshListener implements OnRefreshListener {
+ @Override
+ public void onRefresh() {
+ final JSONObject response = new JSONObject();
+ try {
+ response.put(JSON_KEY_PANEL_ID, panelId);
+ response.put(JSON_KEY_VIEW_INDEX, viewIndex);
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Could not create refresh message", e);
+ return;
+ }
+
+ GeckoAppShell.notifyObservers("HomePanels:RefreshView", response.toString());
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelViewAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelViewAdapter.java
new file mode 100644
index 000000000..cf03c50c0
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelViewAdapter.java
@@ -0,0 +1,113 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import org.mozilla.gecko.home.HomeConfig.ItemType;
+import org.mozilla.gecko.home.HomeConfig.ViewConfig;
+import org.mozilla.gecko.home.PanelLayout.FilterManager;
+
+import org.mozilla.gecko.R;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.support.v4.widget.CursorAdapter;
+import android.view.View;
+import android.view.ViewGroup;
+
+class PanelViewAdapter extends CursorAdapter {
+ private static final int VIEW_TYPE_ITEM = 0;
+ private static final int VIEW_TYPE_BACK = 1;
+
+ private final ViewConfig viewConfig;
+ private FilterManager filterManager;
+ private final Context context;
+
+ public PanelViewAdapter(Context context, ViewConfig viewConfig) {
+ super(context, null, 0);
+ this.context = context;
+ this.viewConfig = viewConfig;
+ }
+
+ public void setFilterManager(FilterManager manager) {
+ this.filterManager = manager;
+ }
+
+ @Override
+ public final int getViewTypeCount() {
+ return 2;
+ }
+
+ @Override
+ public int getCount() {
+ return super.getCount() + (isShowingBack() ? 1 : 0);
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ if (isShowingBack() && position == 0) {
+ return VIEW_TYPE_BACK;
+ } else {
+ return VIEW_TYPE_ITEM;
+ }
+ }
+
+ @Override
+ public final View getView(int position, View convertView, ViewGroup parent) {
+ if (convertView == null) {
+ convertView = newView(parent.getContext(), position, parent);
+ }
+
+ bindView(convertView, position);
+ return convertView;
+ }
+
+ private View newView(Context context, int position, ViewGroup parent) {
+ if (getItemViewType(position) == VIEW_TYPE_BACK) {
+ return new PanelBackItemView(context, viewConfig.getBackImageUrl());
+ } else {
+ return PanelItemView.create(context, viewConfig.getItemType());
+ }
+ }
+
+ private void bindView(View view, int position) {
+ if (isShowingBack()) {
+ if (position == 0) {
+ final PanelBackItemView item = (PanelBackItemView) view;
+ item.updateFromFilter(filterManager.getPreviousFilter());
+ return;
+ }
+
+ position--;
+ }
+
+ final Cursor cursor = getCursor(position);
+ final PanelItemView item = (PanelItemView) view;
+ item.updateFromCursor(cursor);
+ }
+
+ private boolean isShowingBack() {
+ return filterManager != null && filterManager.canGoBack();
+ }
+
+ private final Cursor getCursor(int position) {
+ final Cursor cursor = getCursor();
+ if (cursor == null || !cursor.moveToPosition(position)) {
+ throw new IllegalStateException("Couldn't move cursor to position " + position);
+ }
+
+ return cursor;
+ }
+
+ @Override
+ public final void bindView(View view, Context context, Cursor cursor) {
+ // Do nothing.
+ }
+
+ @Override
+ public final View newView(Context context, Cursor cursor, ViewGroup parent) {
+ return null;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelViewItemHandler.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelViewItemHandler.java
new file mode 100644
index 000000000..a69db0b41
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelViewItemHandler.java
@@ -0,0 +1,59 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import org.mozilla.gecko.db.BrowserContract.HomeItems;
+import org.mozilla.gecko.home.HomeConfig.ViewConfig;
+import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
+import org.mozilla.gecko.home.PanelLayout.FilterManager;
+import org.mozilla.gecko.home.PanelLayout.OnItemOpenListener;
+
+import android.database.Cursor;
+
+import java.util.EnumSet;
+
+class PanelViewItemHandler {
+ private OnItemOpenListener mItemOpenListener;
+ private FilterManager mFilterManager;
+
+ public void setOnItemOpenListener(OnItemOpenListener listener) {
+ mItemOpenListener = listener;
+ }
+
+ public void setFilterManager(FilterManager manager) {
+ mFilterManager = manager;
+ }
+
+ /**
+ * If item at this position is a back item, perform the go back action via the
+ * {@code FilterManager}. Otherwise, prepare the url to be opened by the
+ * {@code OnUrlOpenListener}.
+ */
+ public void openItemAtPosition(Cursor cursor, int position) {
+ if (mFilterManager != null && mFilterManager.canGoBack()) {
+ if (position == 0) {
+ mFilterManager.goBack();
+ return;
+ }
+
+ position--;
+ }
+
+ if (cursor == null || !cursor.moveToPosition(position)) {
+ throw new IllegalStateException("Couldn't move cursor to position " + position);
+ }
+
+ int urlIndex = cursor.getColumnIndexOrThrow(HomeItems.URL);
+ final String url = cursor.getString(urlIndex);
+
+ int titleIndex = cursor.getColumnIndexOrThrow(HomeItems.TITLE);
+ final String title = cursor.getString(titleIndex);
+
+ if (mItemOpenListener != null) {
+ mItemOpenListener.onItemOpen(url, title);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PinSiteDialog.java b/mobile/android/base/java/org/mozilla/gecko/home/PinSiteDialog.java
new file mode 100644
index 000000000..230b1d329
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/PinSiteDialog.java
@@ -0,0 +1,256 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import java.util.EnumSet;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.BrowserContract.URLColumns;
+import org.mozilla.gecko.db.BrowserDB.FilterFlags;
+import org.mozilla.gecko.util.StringUtils;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.database.Cursor;
+import android.os.Bundle;
+import android.support.v4.app.DialogFragment;
+import android.support.v4.app.LoaderManager;
+import android.support.v4.app.LoaderManager.LoaderCallbacks;
+import android.support.v4.content.Loader;
+import android.support.v4.widget.CursorAdapter;
+import android.text.Editable;
+import android.text.TextUtils;
+import android.text.TextWatcher;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.Window;
+import android.view.WindowManager;
+import android.widget.AdapterView;
+import android.widget.EditText;
+import android.widget.ListView;
+
+/**
+ * Dialog fragment that displays frecency search results, for pinning a site, in a GridView.
+ */
+class PinSiteDialog extends DialogFragment {
+ // Listener for url selection
+ public static interface OnSiteSelectedListener {
+ public void onSiteSelected(String url, String title);
+ }
+
+ // Cursor loader ID for search query
+ private static final int LOADER_ID_SEARCH = 0;
+
+ // Holds the current search term to use in the query
+ private String mSearchTerm;
+
+ // Adapter for the list of search results
+ private SearchAdapter mAdapter;
+
+ // Search entry
+ private EditText mSearch;
+
+ // Search results
+ private ListView mList;
+
+ // Callbacks used for the search loader
+ private CursorLoaderCallbacks mLoaderCallbacks;
+
+ // Bookmark selected listener
+ private OnSiteSelectedListener mOnSiteSelectedListener;
+
+ public static PinSiteDialog newInstance() {
+ return new PinSiteDialog();
+ }
+
+ private PinSiteDialog() {
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setStyle(DialogFragment.STYLE_NO_TITLE, android.R.style.Theme_Holo_Light_Dialog);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ // All list views are styled to look the same with a global activity theme.
+ // If the style of the list changes, inflate it from an XML.
+ return inflater.inflate(R.layout.pin_site_dialog, container, false);
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ mSearch = (EditText) view.findViewById(R.id.search);
+ mSearch.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void afterTextChanged(Editable s) {
+ }
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ setSearchTerm(mSearch.getText().toString());
+ filter(mSearchTerm);
+ }
+ });
+
+ mSearch.setOnKeyListener(new View.OnKeyListener() {
+ @Override
+ public boolean onKey(View v, int keyCode, KeyEvent event) {
+ if (keyCode != KeyEvent.KEYCODE_ENTER || mOnSiteSelectedListener == null) {
+ return false;
+ }
+
+ // If the user manually entered a search term or URL, wrap the value in
+ // a special URI until we can get a valid URL for this bookmark.
+ final String text = mSearch.getText().toString().trim();
+ if (!TextUtils.isEmpty(text)) {
+ final String url = StringUtils.encodeUserEnteredUrl(text);
+ mOnSiteSelectedListener.onSiteSelected(url, text);
+ dismiss();
+ }
+
+ return true;
+ }
+ });
+
+ mSearch.setOnFocusChangeListener(new View.OnFocusChangeListener() {
+ @Override
+ public void onFocusChange(View v, boolean hasFocus) {
+ if (hasFocus) {
+ // On rotation, the view gets destroyed and we could be in a race to get the dialog
+ // and window (see bug 1072959).
+ Dialog dialog = getDialog();
+ if (dialog == null) {
+ return;
+ }
+ Window window = dialog.getWindow();
+ if (window == null) {
+ return;
+ }
+ window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE);
+ }
+ }
+ });
+
+ mList = (HomeListView) view.findViewById(R.id.list);
+ mList.setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ if (mOnSiteSelectedListener != null) {
+ final Cursor c = mAdapter.getCursor();
+ if (c == null || !c.moveToPosition(position)) {
+ return;
+ }
+
+ final String url = c.getString(c.getColumnIndexOrThrow(URLColumns.URL));
+ final String title = c.getString(c.getColumnIndexOrThrow(URLColumns.TITLE));
+ mOnSiteSelectedListener.onSiteSelected(url, title);
+ }
+
+ // Dismiss the fragment and the dialog.
+ dismiss();
+ }
+ });
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+
+ final LoaderManager manager = getLoaderManager();
+
+ // Initialize the search adapter
+ mAdapter = new SearchAdapter(getActivity());
+ mList.setAdapter(mAdapter);
+
+ // Create callbacks before the initial loader is started
+ mLoaderCallbacks = new CursorLoaderCallbacks();
+
+ // Reconnect to the loader only if present
+ manager.initLoader(LOADER_ID_SEARCH, null, mLoaderCallbacks);
+
+ // If there is a search term, put it in the text field
+ if (!TextUtils.isEmpty(mSearchTerm)) {
+ mSearch.setText(mSearchTerm);
+ mSearch.selectAll();
+ }
+
+ // Always start with an empty filter
+ filter("");
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+
+ // Discard any additional site selection as the dialog
+ // is getting destroyed (see bug 935542).
+ setOnSiteSelectedListener(null);
+ }
+
+ public void setSearchTerm(String searchTerm) {
+ mSearchTerm = searchTerm;
+ }
+
+ private void filter(String searchTerm) {
+ // Restart loaders with the new search term
+ SearchLoader.restart(getLoaderManager(), LOADER_ID_SEARCH,
+ mLoaderCallbacks, searchTerm,
+ EnumSet.of(FilterFlags.EXCLUDE_PINNED_SITES));
+ }
+
+ public void setOnSiteSelectedListener(OnSiteSelectedListener listener) {
+ mOnSiteSelectedListener = listener;
+ }
+
+ private static class SearchAdapter extends CursorAdapter {
+ private final LayoutInflater mInflater;
+
+ public SearchAdapter(Context context) {
+ super(context, null, 0);
+ mInflater = LayoutInflater.from(context);
+ }
+
+ @Override
+ public void bindView(View view, Context context, Cursor cursor) {
+ TwoLinePageRow row = (TwoLinePageRow) view;
+ row.setShowIcons(false);
+ row.updateFromCursor(cursor);
+ }
+
+ @Override
+ public View newView(Context context, Cursor cursor, ViewGroup parent) {
+ return (TwoLinePageRow) mInflater.inflate(R.layout.home_item_row, parent, false);
+ }
+ }
+
+ private class CursorLoaderCallbacks implements LoaderCallbacks<Cursor> {
+ @Override
+ public Loader<Cursor> onCreateLoader(int id, Bundle args) {
+ return SearchLoader.createInstance(getActivity(), args);
+ }
+
+ @Override
+ public void onLoadFinished(Loader<Cursor> loader, Cursor c) {
+ mAdapter.swapCursor(c);
+ }
+
+ @Override
+ public void onLoaderReset(Loader<Cursor> loader) {
+ mAdapter.swapCursor(null);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/RecentTabsAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/RecentTabsAdapter.java
new file mode 100755
index 000000000..3091f77da
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/RecentTabsAdapter.java
@@ -0,0 +1,454 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import android.content.Context;
+import android.support.v7.widget.RecyclerView;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.AboutPages;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoApp;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.SessionParser;
+import org.mozilla.gecko.home.CombinedHistoryAdapter.RecentTabsUpdateHandler;
+import org.mozilla.gecko.home.CombinedHistoryPanel.PanelStateUpdateHandler;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.NativeEventListener;
+import org.mozilla.gecko.util.NativeJSObject;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.mozilla.gecko.home.CombinedHistoryItem.ItemType;
+import static org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener.PanelLevel.CHILD_RECENT_TABS;
+
+public class RecentTabsAdapter extends RecyclerView.Adapter<CombinedHistoryItem>
+ implements CombinedHistoryRecyclerView.AdapterContextMenuBuilder, NativeEventListener {
+ private static final String LOGTAG = "GeckoRecentTabsAdapter";
+
+ private static final int NAVIGATION_BACK_BUTTON_INDEX = 0;
+
+ private static final String TELEMETRY_EXTRA_LAST_TIME = "recent_tabs_last_time";
+ private static final String TELEMETRY_EXTRA_RECENTLY_CLOSED = "recent_closed_tabs";
+ private static final String TELEMETRY_EXTRA_MIXED = "recent_tabs_mixed";
+
+ // Recently closed tabs from Gecko.
+ private ClosedTab[] recentlyClosedTabs;
+ private boolean recentlyClosedTabsReceived = false;
+
+ // "Tabs from last time".
+ private ClosedTab[] lastSessionTabs;
+
+ public static final class ClosedTab {
+ public final String url;
+ public final String title;
+ public final String data;
+
+ public ClosedTab(String url, String title, String data) {
+ this.url = url;
+ this.title = title;
+ this.data = data;
+ }
+ }
+
+ private final Context context;
+ private final RecentTabsUpdateHandler recentTabsUpdateHandler;
+ private final PanelStateUpdateHandler panelStateUpdateHandler;
+
+ public RecentTabsAdapter(Context context,
+ RecentTabsUpdateHandler recentTabsUpdateHandler,
+ PanelStateUpdateHandler panelStateUpdateHandler) {
+ this.context = context;
+ this.recentTabsUpdateHandler = recentTabsUpdateHandler;
+ this.panelStateUpdateHandler = panelStateUpdateHandler;
+ recentlyClosedTabs = new ClosedTab[0];
+ lastSessionTabs = new ClosedTab[0];
+
+ readPreviousSessionData();
+ }
+
+ public void startListeningForClosedTabs() {
+ EventDispatcher.getInstance().registerGeckoThreadListener(this, "ClosedTabs:Data");
+ GeckoAppShell.notifyObservers("ClosedTabs:StartNotifications", null);
+ }
+
+ public void stopListeningForClosedTabs() {
+ GeckoAppShell.notifyObservers("ClosedTabs:StopNotifications", null);
+ EventDispatcher.getInstance().unregisterGeckoThreadListener(this, "ClosedTabs:Data");
+ recentlyClosedTabsReceived = false;
+ }
+
+ public void startListeningForHistorySanitize() {
+ EventDispatcher.getInstance().registerGeckoThreadListener(this, "Sanitize:Finished");
+ }
+
+ public void stopListeningForHistorySanitize() {
+ EventDispatcher.getInstance().unregisterGeckoThreadListener(this, "Sanitize:Finished");
+ }
+
+ @Override
+ public void handleMessage(String event, NativeJSObject message, EventCallback callback) {
+ switch (event) {
+ case "ClosedTabs:Data":
+ updateRecentlyClosedTabs(message);
+ break;
+ case "Sanitize:Finished":
+ clearLastSessionData();
+ break;
+ }
+ }
+
+ private void updateRecentlyClosedTabs(NativeJSObject message) {
+ final NativeJSObject[] tabs = message.getObjectArray("tabs");
+ final int length = tabs.length;
+
+ final ClosedTab[] closedTabs = new ClosedTab[length];
+ for (int i = 0; i < length; i++) {
+ final NativeJSObject tab = tabs[i];
+ closedTabs[i] = new ClosedTab(tab.getString("url"), tab.getString("title"), tab.getObject("data").toString());
+ }
+
+ // Only modify recentlyClosedTabs on the UI thread.
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ // Save some data about the old panel state, so we can be
+ // smarter about notifying the recycler view which bits changed.
+ int prevClosedTabsCount = recentlyClosedTabs.length;
+ boolean prevSectionHeaderVisibility = isSectionHeaderVisible();
+ int prevSectionHeaderIndex = getSectionHeaderIndex();
+
+ recentlyClosedTabs = closedTabs;
+ recentlyClosedTabsReceived = true;
+ recentTabsUpdateHandler.onRecentTabsCountUpdated(
+ getClosedTabsCount(), recentlyClosedTabsReceived);
+ panelStateUpdateHandler.onPanelStateUpdated(CHILD_RECENT_TABS);
+
+ // Handle the section header hiding/unhiding.
+ updateHeaderVisibility(prevSectionHeaderVisibility, prevSectionHeaderIndex);
+
+ // Update the "Recently closed" part of the tab list.
+ updateTabsList(prevClosedTabsCount, recentlyClosedTabs.length, getFirstRecentTabIndex(), getLastRecentTabIndex());
+ }
+ });
+ }
+
+ private void readPreviousSessionData() {
+ // If we happen to initialise before GeckoApp, waiting on either the main or the background
+ // thread can lead to a deadlock, so we have to run on a separate thread instead.
+ final Thread parseThread = new Thread(new Runnable() {
+ @Override
+ public void run() {
+ // Make sure that the start up code has had a chance to update sessionstore.old as necessary.
+ GeckoProfile.get(context).waitForOldSessionDataProcessing();
+
+ final String jsonString = GeckoProfile.get(context).readPreviousSessionFile();
+ if (jsonString == null) {
+ // No previous session data.
+ return;
+ }
+
+ final List<ClosedTab> parsedTabs = new ArrayList<>();
+
+ new SessionParser() {
+ @Override
+ public void onTabRead(SessionTab tab) {
+ final String url = tab.getUrl();
+
+ // Don't show last tabs for about:home
+ if (AboutPages.isAboutHome(url)) {
+ return;
+ }
+
+ parsedTabs.add(new ClosedTab(url, tab.getTitle(), tab.getTabObject().toString()));
+ }
+ }.parse(jsonString);
+
+ final ClosedTab[] closedTabs = parsedTabs.toArray(new ClosedTab[parsedTabs.size()]);
+
+ // Only modify lastSessionTabs on the UI thread.
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ // Save some data about the old panel state, so we can be
+ // smarter about notifying the recycler view which bits changed.
+ int prevClosedTabsCount = lastSessionTabs.length;
+ boolean prevSectionHeaderVisibility = isSectionHeaderVisible();
+ int prevSectionHeaderIndex = getSectionHeaderIndex();
+
+ lastSessionTabs = closedTabs;
+ recentTabsUpdateHandler.onRecentTabsCountUpdated(
+ getClosedTabsCount(), recentlyClosedTabsReceived);
+ panelStateUpdateHandler.onPanelStateUpdated(CHILD_RECENT_TABS);
+
+ // Handle the section header hiding/unhiding.
+ updateHeaderVisibility(prevSectionHeaderVisibility, prevSectionHeaderIndex);
+
+ // Update the "Tabs from last time" part of the tab list.
+ updateTabsList(prevClosedTabsCount, lastSessionTabs.length, getFirstLastSessionTabIndex(), getLastLastSessionTabIndex());
+ }
+ });
+ }
+ }, "LastSessionTabsThread");
+
+ parseThread.start();
+ }
+
+ private void clearLastSessionData() {
+ final ClosedTab[] emptyLastSessionTabs = new ClosedTab[0];
+
+ // Only modify mLastSessionTabs on the UI thread.
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ // Save some data about the old panel state, so we can be
+ // smarter about notifying the recycler view which bits changed.
+ int prevClosedTabsCount = lastSessionTabs.length;
+ boolean prevSectionHeaderVisibility = isSectionHeaderVisible();
+ int prevSectionHeaderIndex = getSectionHeaderIndex();
+
+ lastSessionTabs = emptyLastSessionTabs;
+ recentTabsUpdateHandler.onRecentTabsCountUpdated(
+ getClosedTabsCount(), recentlyClosedTabsReceived);
+ panelStateUpdateHandler.onPanelStateUpdated(CHILD_RECENT_TABS);
+
+ // Handle the section header hiding.
+ updateHeaderVisibility(prevSectionHeaderVisibility, prevSectionHeaderIndex);
+
+ // Handle the "tabs from last time" being cleared.
+ if (prevClosedTabsCount > 0) {
+ notifyItemRangeRemoved(getFirstLastSessionTabIndex(), prevClosedTabsCount);
+ }
+ }
+ });
+ }
+
+ private void updateHeaderVisibility(boolean prevSectionHeaderVisibility, int prevSectionHeaderIndex) {
+ if (prevSectionHeaderVisibility && !isSectionHeaderVisible()) {
+ notifyItemRemoved(prevSectionHeaderIndex);
+ } else if (!prevSectionHeaderVisibility && isSectionHeaderVisible()) {
+ notifyItemInserted(getSectionHeaderIndex());
+ }
+ }
+
+ /**
+ * Updates the tab list as necessary to account for any changes in tab count in a particular data source.
+ *
+ * Since the session store only sends out full updates, we don't know for sure what has changed compared
+ * to the last data set, so we can only animate if the tab count actually changes.
+ *
+ * @param prevClosedTabsCount The previous number of closed tabs from that data source.
+ * @param closedTabsCount The current number of closed tabs contained in that data source.
+ * @param firstTabListIndex The current position of that data source's first item in the RecyclerView.
+ * @param lastTabListIndex The current position of that data source's last item in the RecyclerView.
+ */
+ private void updateTabsList(int prevClosedTabsCount, int closedTabsCount, int firstTabListIndex, int lastTabListIndex) {
+ final int closedTabsCountChange = closedTabsCount - prevClosedTabsCount;
+
+ if (closedTabsCountChange <= 0) {
+ notifyItemRangeRemoved(lastTabListIndex + 1, -closedTabsCountChange); // Remove tabs from the bottom of the list.
+ notifyItemRangeChanged(firstTabListIndex, closedTabsCount); // Update the contents of the remaining items.
+ } else { // closedTabsCountChange > 0
+ notifyItemRangeInserted(firstTabListIndex, closedTabsCountChange); // Add additional tabs at the top of the list.
+ notifyItemRangeChanged(firstTabListIndex + closedTabsCountChange, prevClosedTabsCount); // Update any previous list items.
+ }
+ }
+
+ public String restoreTabFromPosition(int position) {
+ final List<String> dataList = new ArrayList<>(1);
+ dataList.add(getClosedTabForPosition(position).data);
+
+ final String telemetryExtra =
+ position > getLastRecentTabIndex() ? TELEMETRY_EXTRA_LAST_TIME : TELEMETRY_EXTRA_RECENTLY_CLOSED;
+
+ restoreSessionWithHistory(dataList);
+
+ return telemetryExtra;
+ }
+
+ public String restoreAllTabs() {
+ if (recentlyClosedTabs.length == 0 && lastSessionTabs.length == 0) {
+ return null;
+ }
+
+ final List<String> dataList = new ArrayList<>(getClosedTabsCount());
+ addTabDataToList(dataList, recentlyClosedTabs);
+ addTabDataToList(dataList, lastSessionTabs);
+
+ final String telemetryExtra = recentlyClosedTabs.length > 0 && lastSessionTabs.length > 0 ? TELEMETRY_EXTRA_MIXED :
+ recentlyClosedTabs.length > 0 ? TELEMETRY_EXTRA_RECENTLY_CLOSED : TELEMETRY_EXTRA_LAST_TIME;
+
+ restoreSessionWithHistory(dataList);
+
+ return telemetryExtra;
+ }
+
+ private void addTabDataToList(List<String> dataList, ClosedTab[] closedTabs) {
+ for (ClosedTab closedTab : closedTabs) {
+ dataList.add(closedTab.data);
+ }
+ }
+
+ private static void restoreSessionWithHistory(List<String> dataList) {
+ final JSONObject json = new JSONObject();
+ try {
+ json.put("tabs", new JSONArray(dataList));
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "JSON error", e);
+ }
+
+ GeckoAppShell.notifyObservers("Session:RestoreRecentTabs", json.toString());
+ }
+
+ @Override
+ public CombinedHistoryItem onCreateViewHolder(ViewGroup parent, int viewType) {
+ final LayoutInflater inflater = LayoutInflater.from(parent.getContext());
+ final View view;
+
+ final CombinedHistoryItem.ItemType itemType = CombinedHistoryItem.ItemType.viewTypeToItemType(viewType);
+
+ switch (itemType) {
+ case NAVIGATION_BACK:
+ view = inflater.inflate(R.layout.home_combined_back_item, parent, false);
+ return new CombinedHistoryItem.HistoryItem(view);
+
+ case SECTION_HEADER:
+ view = inflater.inflate(R.layout.home_header_row, parent, false);
+ return new CombinedHistoryItem.BasicItem(view);
+
+ case CLOSED_TAB:
+ view = inflater.inflate(R.layout.home_item_row, parent, false);
+ return new CombinedHistoryItem.HistoryItem(view);
+ }
+ return null;
+ }
+
+ @Override
+ public void onBindViewHolder(CombinedHistoryItem holder, final int position) {
+ final CombinedHistoryItem.ItemType itemType = getItemTypeForPosition(position);
+
+ switch (itemType) {
+ case SECTION_HEADER:
+ ((TextView) holder.itemView).setText(context.getString(R.string.home_closed_tabs_title2));
+ break;
+
+ case CLOSED_TAB:
+ final ClosedTab closedTab = getClosedTabForPosition(position);
+ ((CombinedHistoryItem.HistoryItem) holder).bind(closedTab);
+ break;
+ }
+ }
+
+ @Override
+ public int getItemCount() {
+ int itemCount = 1; // NAVIGATION_BACK button is always visible.
+
+ if (isSectionHeaderVisible()) {
+ itemCount += 1;
+ }
+
+ itemCount += getClosedTabsCount();
+
+ return itemCount;
+ }
+
+ private CombinedHistoryItem.ItemType getItemTypeForPosition(int position) {
+ if (position == NAVIGATION_BACK_BUTTON_INDEX) {
+ return ItemType.NAVIGATION_BACK;
+ }
+
+ if (position == getSectionHeaderIndex() && isSectionHeaderVisible()) {
+ return ItemType.SECTION_HEADER;
+ }
+
+ return ItemType.CLOSED_TAB;
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ return CombinedHistoryItem.ItemType.itemTypeToViewType(getItemTypeForPosition(position));
+ }
+
+ public int getClosedTabsCount() {
+ return recentlyClosedTabs.length + lastSessionTabs.length;
+ }
+
+ private boolean isSectionHeaderVisible() {
+ return recentlyClosedTabs.length > 0 || lastSessionTabs.length > 0;
+ }
+
+ private int getSectionHeaderIndex() {
+ return isSectionHeaderVisible() ?
+ NAVIGATION_BACK_BUTTON_INDEX + 1 :
+ NAVIGATION_BACK_BUTTON_INDEX;
+ }
+
+ private int getFirstRecentTabIndex() {
+ return getSectionHeaderIndex() + 1;
+ }
+
+ private int getLastRecentTabIndex() {
+ return getSectionHeaderIndex() + recentlyClosedTabs.length;
+ }
+
+ private int getFirstLastSessionTabIndex() {
+ return getLastRecentTabIndex() + 1;
+ }
+
+ private int getLastLastSessionTabIndex() {
+ return getLastRecentTabIndex() + lastSessionTabs.length;
+ }
+
+ /**
+ * Get the closed tab corresponding to a RecyclerView list item.
+ *
+ * The Recent Tab folder combines two data sources, so if we want to get the ClosedTab object
+ * behind a certain list item, we need to route this request to the corresponding data source
+ * and also transform the global list position into a local array index.
+ */
+ private ClosedTab getClosedTabForPosition(int position) {
+ final ClosedTab closedTab;
+ if (position <= getLastRecentTabIndex()) { // Upper part of the list is "Recently closed tabs".
+ closedTab = recentlyClosedTabs[position - getFirstRecentTabIndex()];
+ } else { // Lower part is "Tabs from last time".
+ closedTab = lastSessionTabs[position - getFirstLastSessionTabIndex()];
+ }
+
+ return closedTab;
+ }
+
+ @Override
+ public HomeContextMenuInfo makeContextMenuInfoFromPosition(View view, int position) {
+ final CombinedHistoryItem.ItemType itemType = getItemTypeForPosition(position);
+ final HomeContextMenuInfo info;
+
+ switch (itemType) {
+ case CLOSED_TAB:
+ info = new HomeContextMenuInfo(view, position, -1);
+ ClosedTab closedTab = getClosedTabForPosition(position);
+ return populateChildInfoFromTab(info, closedTab);
+ }
+
+ return null;
+ }
+
+ protected static HomeContextMenuInfo populateChildInfoFromTab(HomeContextMenuInfo info, ClosedTab tab) {
+ info.url = tab.url;
+ info.title = tab.title;
+ return info;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/RemoteTabsExpandableListState.java b/mobile/android/base/java/org/mozilla/gecko/home/RemoteTabsExpandableListState.java
new file mode 100644
index 000000000..43497ae6c
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/RemoteTabsExpandableListState.java
@@ -0,0 +1,163 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import org.mozilla.gecko.util.PrefUtils;
+
+import android.content.SharedPreferences;
+import android.content.SharedPreferences.Editor;
+
+/**
+ * Encapsulate visual state maintained by the Remote Tabs home panel.
+ * <p>
+ * This state should persist across database updates by Sync and the like. This
+ * state could be stored in a separate "clients_metadata" table and served by
+ * the Tabs provider, but that is heavy-weight for what we want to achieve. Such
+ * a scheme would require either an expensive table join, or a tricky
+ * co-ordination between multiple cursors. In contrast, this is easy and cheap
+ * enough to do on the main thread.
+ * <p>
+ * This state is "per SharedPreferences" object. In practice, there should exist
+ * one state object per Gecko Profile; since we can't change profiles without
+ * killing our process, this can be a static singleton.
+ */
+public class RemoteTabsExpandableListState {
+ private static final String PREF_COLLAPSED_CLIENT_GUIDS = "remote_tabs_collapsed_client_guids";
+ private static final String PREF_HIDDEN_CLIENT_GUIDS = "remote_tabs_hidden_client_guids";
+ private static final String PREF_SELECTED_CLIENT_GUID = "remote_tabs_selected_client_guid";
+
+ protected final SharedPreferences sharedPrefs;
+
+ // Synchronized by the state instance. The default is to expand a clients
+ // tabs, so "not present" means "expanded".
+ // Only accessed from the UI thread.
+ protected final Set<String> collapsedClients;
+
+ // Synchronized by the state instance. The default is to show a client, so
+ // "not present" means "shown".
+ // Only accessed from the UI thread.
+ protected final Set<String> hiddenClients;
+
+ // Synchronized by the state instance. The last user selected client guid.
+ // The selectedClient may be invalid or null.
+ protected String selectedClient;
+
+ public RemoteTabsExpandableListState(SharedPreferences sharedPrefs) {
+ if (null == sharedPrefs) {
+ throw new IllegalArgumentException("sharedPrefs must not be null");
+ }
+ this.sharedPrefs = sharedPrefs;
+
+ this.collapsedClients = getStringSet(PREF_COLLAPSED_CLIENT_GUIDS);
+ this.hiddenClients = getStringSet(PREF_HIDDEN_CLIENT_GUIDS);
+ this.selectedClient = sharedPrefs.getString(PREF_SELECTED_CLIENT_GUID, null);
+ }
+
+ /**
+ * Extract a string set from shared preferences.
+ * <p>
+ * Nota bene: it is not OK to modify the set returned by {@link SharedPreferences#getStringSet(String, Set)}.
+ *
+ * @param pref to read from.
+ * @returns string set; never null.
+ */
+ protected Set<String> getStringSet(String pref) {
+ final Set<String> loaded = PrefUtils.getStringSet(sharedPrefs, pref, null);
+ if (loaded != null) {
+ return new HashSet<String>(loaded);
+ } else {
+ return new HashSet<String>();
+ }
+ }
+
+ /**
+ * Update client membership in a set.
+ *
+ * @param pref
+ * to write updated set to.
+ * @param clients
+ * set to update membership in.
+ * @param clientGuid
+ * to update membership of.
+ * @param isMember
+ * whether the client is a member of the set.
+ * @return true if the set of clients was modified.
+ */
+ protected boolean updateClientMembership(String pref, Set<String> clients, String clientGuid, boolean isMember) {
+ final boolean modified;
+ if (isMember) {
+ modified = clients.add(clientGuid);
+ } else {
+ modified = clients.remove(clientGuid);
+ }
+
+ if (modified) {
+ // This starts an asynchronous write. We don't care if we drop the
+ // write, and we don't really care if we race between writes, since
+ // we will return results from our in-memory cache.
+ final Editor editor = sharedPrefs.edit();
+ PrefUtils.putStringSet(editor, pref, clients);
+ editor.apply();
+ }
+
+ return modified;
+ }
+
+ /**
+ * Mark a client as collapsed.
+ *
+ * @param clientGuid
+ * to update.
+ * @param collapsed
+ * whether the client is collapsed.
+ * @return true if the set of collapsed clients was modified.
+ */
+ protected synchronized boolean setClientCollapsed(String clientGuid, boolean collapsed) {
+ return updateClientMembership(PREF_COLLAPSED_CLIENT_GUIDS, collapsedClients, clientGuid, collapsed);
+ }
+
+ /**
+ * Mark a client as the selected.
+ *
+ * @param clientGuid
+ * to update.
+ */
+ protected synchronized void setClientAsSelected(String clientGuid) {
+ if (hiddenClients.contains(clientGuid)) {
+ selectedClient = null;
+ } else {
+ selectedClient = clientGuid;
+ }
+
+ final Editor editor = sharedPrefs.edit();
+ editor.putString(PREF_SELECTED_CLIENT_GUID, selectedClient);
+ editor.apply();
+ }
+
+ public synchronized boolean isClientCollapsed(String clientGuid) {
+ return collapsedClients.contains(clientGuid);
+ }
+
+ /**
+ * Mark a client as hidden.
+ *
+ * @param clientGuid
+ * to update.
+ * @param hidden
+ * whether the client is hidden.
+ * @return true if the set of hidden clients was modified.
+ */
+ protected synchronized boolean setClientHidden(String clientGuid, boolean hidden) {
+ return updateClientMembership(PREF_HIDDEN_CLIENT_GUIDS, hiddenClients, clientGuid, hidden);
+ }
+
+ public synchronized boolean isClientHidden(String clientGuid) {
+ return hiddenClients.contains(clientGuid);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/SearchEngine.java b/mobile/android/base/java/org/mozilla/gecko/home/SearchEngine.java
new file mode 100644
index 000000000..9b2d2746a
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/SearchEngine.java
@@ -0,0 +1,102 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import android.support.annotation.NonNull;
+import org.mozilla.gecko.gfx.BitmapUtils;
+import org.mozilla.gecko.R;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.util.Log;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class SearchEngine {
+ public static final String LOG_TAG = "GeckoSearchEngine";
+
+ public final String name; // Never null.
+ public final String identifier; // Can be null.
+
+ private final Bitmap icon;
+ private volatile List<String> suggestions = new ArrayList<String>(); // Never null.
+
+ public SearchEngine(final Context context, final JSONObject engineJSON) throws JSONException {
+ if (engineJSON == null) {
+ throw new IllegalArgumentException("Can't instantiate SearchEngine from null JSON.");
+ }
+
+ this.name = getString(engineJSON, "name");
+ if (this.name == null) {
+ throw new IllegalArgumentException("Cannot have an unnamed search engine.");
+ }
+
+ this.identifier = getString(engineJSON, "identifier");
+
+ final String iconURI = getString(engineJSON, "iconURI");
+ if (iconURI == null) {
+ Log.w(LOG_TAG, "iconURI is null for search engine " + this.name);
+ }
+ final Bitmap tempIcon = BitmapUtils.getBitmapFromDataURI(iconURI);
+
+ this.icon = (tempIcon != null) ? tempIcon : getDefaultFavicon(context);
+ }
+
+ private Bitmap getDefaultFavicon(final Context context) {
+ return BitmapFactory.decodeResource(context.getResources(), R.drawable.search_icon_inactive);
+ }
+
+ private static String getString(JSONObject data, String key) throws JSONException {
+ if (data.isNull(key)) {
+ return null;
+ }
+ return data.getString(key);
+ }
+
+ /**
+ * @return a non-null string suitable for use by FHR.
+ */
+ @NonNull
+ public String getEngineIdentifier() {
+ if (this.identifier != null) {
+ return this.identifier;
+ }
+ if (this.name != null) {
+ return "other-" + this.name;
+ }
+ return "other";
+ }
+
+ public boolean hasSuggestions() {
+ return !this.suggestions.isEmpty();
+ }
+
+ public int getSuggestionsCount() {
+ return this.suggestions.size();
+ }
+
+ public Iterable<String> getSuggestions() {
+ return this.suggestions;
+ }
+
+ public void setSuggestions(List<String> suggestions) {
+ if (suggestions == null) {
+ this.suggestions = new ArrayList<String>();
+ return;
+ }
+ this.suggestions = suggestions;
+ }
+
+ public Bitmap getIcon() {
+ return this.icon;
+ }
+}
+
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/SearchEngineAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/SearchEngineAdapter.java
new file mode 100644
index 000000000..be5b3b461
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/SearchEngineAdapter.java
@@ -0,0 +1,122 @@
+package org.mozilla.gecko.home;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.support.v4.content.ContextCompat;
+import android.support.v4.graphics.drawable.DrawableCompat;
+import android.support.v7.widget.RecyclerView;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+
+import org.mozilla.gecko.R;
+
+import java.util.Collections;
+import java.util.List;
+
+public class SearchEngineAdapter
+ extends RecyclerView.Adapter<SearchEngineAdapter.SearchEngineViewHolder> {
+
+ private static final String LOGTAG = SearchEngineAdapter.class.getSimpleName();
+
+ private static final int VIEW_TYPE_SEARCH_ENGINE = 0;
+ private static final int VIEW_TYPE_LABEL = 1;
+ private final Context mContext;
+
+ private int mContainerWidth;
+ private List<SearchEngine> mSearchEngines = Collections.emptyList();
+
+ public void setSearchEngines(List<SearchEngine> searchEngines) {
+ mSearchEngines = searchEngines;
+ notifyDataSetChanged();
+ }
+
+ /**
+ * The container width is used for setting the appropriate calculated amount of width that
+ * a search engine icon can have. This varies depending on the space available in the
+ * {@link SearchEngineBar}. The setter exists for this attribute, in creating the view in the
+ * adapter after said calculation is done when the search bar is created.
+ * @param iconContainerWidth Width of each search icon.
+ */
+ void setIconContainerWidth(int iconContainerWidth) {
+ mContainerWidth = iconContainerWidth;
+ }
+
+ public static class SearchEngineViewHolder extends RecyclerView.ViewHolder {
+ final private ImageView faviconView;
+
+ public void bindItem(SearchEngine searchEngine) {
+ faviconView.setImageBitmap(searchEngine.getIcon());
+ final String desc = itemView.getResources().getString(R.string.search_bar_item_desc,
+ searchEngine.getEngineIdentifier());
+ itemView.setContentDescription(desc);
+ }
+
+ public SearchEngineViewHolder(View itemView) {
+ super(itemView);
+ faviconView = (ImageView) itemView.findViewById(R.id.search_engine_icon);
+ }
+ }
+
+ public SearchEngineAdapter(Context context) {
+ mContext = context;
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ return position == 0 ? VIEW_TYPE_LABEL : VIEW_TYPE_SEARCH_ENGINE;
+ }
+
+ public SearchEngine getItem(int position) {
+ // We omit the first position which is where the label currently is.
+ return position == 0 ? null : mSearchEngines.get(position - 1);
+ }
+
+ @Override
+ public SearchEngineViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
+ switch (viewType) {
+ case VIEW_TYPE_LABEL:
+ return new SearchEngineViewHolder(createLabelView(parent));
+ case VIEW_TYPE_SEARCH_ENGINE:
+ return new SearchEngineViewHolder(createSearchEngineView(parent));
+ default:
+ throw new IllegalArgumentException("Unknown view type: " + viewType);
+ }
+ }
+
+ @Override
+ public void onBindViewHolder(SearchEngineViewHolder holder, int position) {
+ if (position != 0) {
+ holder.bindItem(getItem(position));
+ }
+ }
+
+ @Override
+ public int getItemCount() {
+ return mSearchEngines.size() + 1;
+ }
+
+ private View createLabelView(ViewGroup parent) {
+ View view = LayoutInflater.from(mContext)
+ .inflate(R.layout.search_engine_bar_label, parent, false);
+ final Drawable icon = DrawableCompat.wrap(
+ ContextCompat.getDrawable(mContext, R.drawable.search_icon_active).mutate());
+ DrawableCompat.setTint(icon, ContextCompat.getColor(mContext, R.color.disabled_grey));
+
+ final ImageView iconView = (ImageView) view.findViewById(R.id.search_engine_label);
+ iconView.setImageDrawable(icon);
+ return view;
+ }
+
+ private View createSearchEngineView(ViewGroup parent) {
+ View view = LayoutInflater.from(mContext)
+ .inflate(R.layout.search_engine_bar_item, parent, false);
+
+ ViewGroup.LayoutParams params = view.getLayoutParams();
+ params.width = mContainerWidth;
+ view.setLayoutParams(params);
+
+ return view;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/SearchEngineBar.java b/mobile/android/base/java/org/mozilla/gecko/home/SearchEngineBar.java
new file mode 100644
index 000000000..6a6509bcb
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/SearchEngineBar.java
@@ -0,0 +1,148 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+/* 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.home;
+
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.support.v4.content.ContextCompat;
+import android.support.v7.widget.LinearLayoutManager;
+import android.support.v7.widget.RecyclerView;
+import android.util.AttributeSet;
+import android.util.DisplayMetrics;
+import android.util.TypedValue;
+import android.view.View;
+
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.preferences.GeckoPreferences;
+import org.mozilla.gecko.widget.RecyclerViewClickSupport;
+
+import java.util.List;
+
+public class SearchEngineBar extends RecyclerView
+ implements RecyclerViewClickSupport.OnItemClickListener {
+ private static final String LOGTAG = SearchEngineBar.class.getSimpleName();
+
+ private static final float ICON_CONTAINER_MIN_WIDTH_DP = 72;
+ private static final float LABEL_CONTAINER_WIDTH_DP = 48;
+
+ public interface OnSearchBarClickListener {
+ void onSearchBarClickListener(SearchEngine searchEngine);
+ }
+
+ private final SearchEngineAdapter mAdapter;
+ private final LinearLayoutManager mLayoutManager;
+ private final Paint mDividerPaint;
+ private final float mMinIconContainerWidth;
+ private final float mDividerHeight;
+ private final int mLabelContainerWidth;
+
+ private int mIconContainerWidth;
+ private OnSearchBarClickListener mOnSearchBarClickListener;
+
+ public SearchEngineBar(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+
+ mDividerPaint = new Paint();
+ mDividerPaint.setColor(ContextCompat.getColor(context, R.color.toolbar_divider_grey));
+ mDividerPaint.setStyle(Paint.Style.FILL_AND_STROKE);
+
+ final DisplayMetrics displayMetrics = getResources().getDisplayMetrics();
+ mMinIconContainerWidth = TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP, ICON_CONTAINER_MIN_WIDTH_DP, displayMetrics);
+ mDividerHeight = context.getResources().getDimension(R.dimen.page_row_divider_height);
+ mLabelContainerWidth = Math.round(TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP, LABEL_CONTAINER_WIDTH_DP, displayMetrics));
+
+ mIconContainerWidth = Math.round(mMinIconContainerWidth);
+
+ mAdapter = new SearchEngineAdapter(context);
+ mAdapter.setIconContainerWidth(mIconContainerWidth);
+ mLayoutManager = new LinearLayoutManager(context);
+ mLayoutManager.setOrientation(LinearLayoutManager.HORIZONTAL);
+
+ setAdapter(mAdapter);
+ setLayoutManager(mLayoutManager);
+
+ RecyclerViewClickSupport.addTo(this)
+ .setOnItemClickListener(this);
+ }
+
+ public void setSearchEngines(List<SearchEngine> searchEngines) {
+ mAdapter.setSearchEngines(searchEngines);
+ }
+
+ public void setOnSearchBarClickListener(OnSearchBarClickListener listener) {
+ mOnSearchBarClickListener = listener;
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+
+ final int searchEngineCount = mAdapter.getItemCount() - 1;
+
+ if (searchEngineCount > 0) {
+ final int availableWidth = getMeasuredWidth() - mLabelContainerWidth;
+
+ if (searchEngineCount * mMinIconContainerWidth <= availableWidth) {
+ // All search engines fit int: So let's just display all.
+ mIconContainerWidth = (int) mMinIconContainerWidth;
+ } else {
+ // If only (n) search engines fit into the available space then display only (x)
+ // search engines with (x) picked so that the last search engine will be cut-off
+ // (we only display half of it) to show the ability to scroll this view.
+
+ final double searchEnginesToDisplay = Math.floor((availableWidth / mMinIconContainerWidth) - 0.5) + 0.5;
+ // Use all available width and spread search engine icons
+ mIconContainerWidth = (int) (availableWidth / searchEnginesToDisplay);
+ }
+
+ mAdapter.setIconContainerWidth(mIconContainerWidth);
+ }
+ }
+
+ @Override
+ public void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+
+ canvas.drawRect(0, 0, getWidth(), mDividerHeight, mDividerPaint);
+ }
+
+ @Override
+ public void onItemClicked(RecyclerView recyclerView, int position, View v) {
+ if (mOnSearchBarClickListener == null) {
+ throw new IllegalStateException(
+ OnSearchBarClickListener.class.getSimpleName() + " is not initializer."
+ );
+ }
+
+ if (position == 0) {
+ final Intent settingsIntent = new Intent(getContext(), GeckoPreferences.class);
+ GeckoPreferences.setResourceToOpen(settingsIntent, "preferences_search");
+ getContext().startActivity(settingsIntent);
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, "searchenginebar-settings");
+ return;
+ }
+
+ final SearchEngine searchEngine = mAdapter.getItem(position);
+ mOnSearchBarClickListener.onSearchBarClickListener(searchEngine);
+ }
+
+ /**
+ * We manually add the override for getAdapter because we see this method getting stripped
+ * out during compile time by aggressive proguard rules.
+ */
+ @RobocopTarget
+ @Override
+ public SearchEngineAdapter getAdapter() {
+ return mAdapter;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/SearchEngineRow.java b/mobile/android/base/java/org/mozilla/gecko/home/SearchEngineRow.java
new file mode 100644
index 000000000..5b97a8f5f
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/SearchEngineRow.java
@@ -0,0 +1,494 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.home.BrowserSearch.OnEditSuggestionListener;
+import org.mozilla.gecko.home.BrowserSearch.OnSearchListener;
+import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.preferences.GeckoPreferences;
+import org.mozilla.gecko.util.DrawableUtil;
+import org.mozilla.gecko.util.StringUtils;
+import org.mozilla.gecko.util.HardwareUtils;
+import org.mozilla.gecko.widget.AnimatedHeightLayout;
+import org.mozilla.gecko.widget.FaviconView;
+import org.mozilla.gecko.widget.FlowLayout;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.graphics.drawable.Drawable;
+import android.graphics.Typeface;
+import android.support.annotation.Nullable;
+import android.text.TextUtils;
+import android.text.style.StyleSpan;
+import android.text.Spannable;
+import android.text.SpannableStringBuilder;
+import android.util.AttributeSet;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.animation.AlphaAnimation;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.regex.Pattern;
+
+class SearchEngineRow extends AnimatedHeightLayout {
+ // Duration for fade-in animation
+ private static final int ANIMATION_DURATION = 250;
+
+ // Inner views
+ private final FlowLayout mSuggestionView;
+ private final FaviconView mIconView;
+ private final LinearLayout mUserEnteredView;
+ private final TextView mUserEnteredTextView;
+
+ // Inflater used when updating from suggestions
+ private final LayoutInflater mInflater;
+
+ // Search engine associated with this view
+ private SearchEngine mSearchEngine;
+
+ // Event listeners for suggestion views
+ private final OnClickListener mClickListener;
+ private final OnLongClickListener mLongClickListener;
+
+ // On URL open listener
+ private OnUrlOpenListener mUrlOpenListener;
+
+ // On search listener
+ private OnSearchListener mSearchListener;
+
+ // On edit suggestion listener
+ private OnEditSuggestionListener mEditSuggestionListener;
+
+ // Selected suggestion view
+ private int mSelectedView;
+
+ // android:backgroundTint only works in Android 21 and higher so we can't do this statically in the xml
+ private Drawable mSearchHistorySuggestionIcon;
+
+ // Maximums for suggestions
+ private int mMaxSavedSuggestions;
+ private int mMaxSearchSuggestions;
+
+ private final List<Integer> mOccurrences = new ArrayList<Integer>();
+
+ public SearchEngineRow(Context context) {
+ this(context, null);
+ }
+
+ public SearchEngineRow(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public SearchEngineRow(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+
+ mClickListener = new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ final String suggestion = getSuggestionTextFromView(v);
+
+ // If we're not clicking the user-entered view (the first suggestion item)
+ // and the search matches a URL pattern, go to that URL. Otherwise, do a
+ // search for the term.
+ if (v != mUserEnteredView && !StringUtils.isSearchQuery(suggestion, true)) {
+ if (mUrlOpenListener != null) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.SUGGESTION, "url");
+
+ mUrlOpenListener.onUrlOpen(suggestion, EnumSet.noneOf(OnUrlOpenListener.Flags.class));
+ }
+ } else if (mSearchListener != null) {
+ if (v == mUserEnteredView) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.SUGGESTION, "user");
+ } else {
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.SUGGESTION, (String) v.getTag());
+ }
+ mSearchListener.onSearch(mSearchEngine, suggestion, TelemetryContract.Method.SUGGESTION);
+ }
+ }
+ };
+
+ mLongClickListener = new OnLongClickListener() {
+ @Override
+ public boolean onLongClick(View v) {
+ if (mEditSuggestionListener != null) {
+ final String suggestion = getSuggestionTextFromView(v);
+ mEditSuggestionListener.onEditSuggestion(suggestion);
+ return true;
+ }
+
+ return false;
+ }
+ };
+
+ mInflater = LayoutInflater.from(context);
+ mInflater.inflate(R.layout.search_engine_row, this);
+
+ mSuggestionView = (FlowLayout) findViewById(R.id.suggestion_layout);
+ mIconView = (FaviconView) findViewById(R.id.suggestion_icon);
+
+ // User-entered search term is first suggestion
+ mUserEnteredView = (LinearLayout) findViewById(R.id.suggestion_user_entered);
+ mUserEnteredView.setOnClickListener(mClickListener);
+
+ mUserEnteredTextView = (TextView) findViewById(R.id.suggestion_text);
+ mSearchHistorySuggestionIcon = DrawableUtil.tintDrawableWithColorRes(getContext(), R.drawable.icon_most_recent_empty, R.color.tabs_tray_icon_grey);
+
+ // Suggestion limits
+ mMaxSavedSuggestions = getResources().getInteger(R.integer.max_saved_suggestions);
+ mMaxSearchSuggestions = getResources().getInteger(R.integer.max_search_suggestions);
+ }
+
+ private void setDescriptionOnSuggestion(View v, String suggestion) {
+ v.setContentDescription(getResources().getString(R.string.suggestion_for_engine,
+ mSearchEngine.name, suggestion));
+ }
+
+ private String getSuggestionTextFromView(View v) {
+ final TextView suggestionText = (TextView) v.findViewById(R.id.suggestion_text);
+ return suggestionText.getText().toString();
+ }
+
+ /**
+ * Finds all occurrences of pattern in string and returns a list of the starting indices
+ * of each occurrence.
+ *
+ * @param pattern The pattern that is searched for
+ * @param string The string where we search for the pattern
+ */
+ private void refreshOccurrencesWith(String pattern, String string) {
+ mOccurrences.clear();
+
+ // Don't try to search for an empty string - String.indexOf will return 0, which would result
+ // in us iterating with lastIndexOfMatch = 0, which eventually results in an OOM.
+ if (TextUtils.isEmpty(pattern)) {
+ return;
+ }
+
+ final int patternLength = pattern.length();
+
+ int indexOfMatch = 0;
+ int lastIndexOfMatch = 0;
+ while (indexOfMatch != -1) {
+ indexOfMatch = string.indexOf(pattern, lastIndexOfMatch);
+ lastIndexOfMatch = indexOfMatch + patternLength;
+ if (indexOfMatch != -1) {
+ mOccurrences.add(indexOfMatch);
+ }
+ }
+ }
+
+ /**
+ * Sets the content for the suggestion view.
+ *
+ * If the suggestion doesn't contain mUserSearchTerm, nothing is made bold.
+ * All instances of mUserSearchTerm in the suggestion are not bold.
+ *
+ * @param v The View that needs to be populated
+ * @param suggestion The suggestion text that will be placed in the view
+ * @param isUserSavedSearch whether the suggestion is from history or not
+ */
+ private void setSuggestionOnView(View v, String suggestion, boolean isUserSavedSearch) {
+ final ImageView historyIcon = (ImageView) v.findViewById(R.id.suggestion_item_icon);
+ if (isUserSavedSearch) {
+ historyIcon.setImageDrawable(mSearchHistorySuggestionIcon);
+ historyIcon.setVisibility(View.VISIBLE);
+ } else {
+ historyIcon.setVisibility(View.GONE);
+ }
+
+ final TextView suggestionText = (TextView) v.findViewById(R.id.suggestion_text);
+ final String searchTerm = getSuggestionTextFromView(mUserEnteredView);
+ final int searchTermLength = searchTerm.length();
+ refreshOccurrencesWith(searchTerm, suggestion);
+ if (mOccurrences.size() > 0) {
+ final SpannableStringBuilder sb = new SpannableStringBuilder(suggestion);
+ int nextStartSpanIndex = 0;
+ // Done to make sure that the stretch of text after the last occurrence, till the end of the suggestion, is made bold
+ mOccurrences.add(suggestion.length());
+ for (int occurrence : mOccurrences) {
+ // Even though they're the same style, SpannableStringBuilder will interpret there as being only one Span present if we re-use a StyleSpan
+ StyleSpan boldSpan = new StyleSpan(Typeface.BOLD);
+ sb.setSpan(boldSpan, nextStartSpanIndex, occurrence, Spannable.SPAN_INCLUSIVE_INCLUSIVE);
+ nextStartSpanIndex = occurrence + searchTermLength;
+ }
+ mOccurrences.clear();
+ suggestionText.setText(sb);
+ } else {
+ suggestionText.setText(suggestion);
+ }
+
+ setDescriptionOnSuggestion(suggestionText, suggestion);
+ }
+
+ /**
+ * Perform a search for the user-entered term.
+ */
+ public void performUserEnteredSearch() {
+ String searchTerm = getSuggestionTextFromView(mUserEnteredView);
+ if (mSearchListener != null) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.SUGGESTION, "user");
+ mSearchListener.onSearch(mSearchEngine, searchTerm, TelemetryContract.Method.SUGGESTION);
+ }
+ }
+
+ public void setSearchTerm(String searchTerm) {
+ mUserEnteredTextView.setText(searchTerm);
+
+ // mSearchEngine is not set in the first call to this method; the content description
+ // is instead initially set in updateSuggestions().
+ if (mSearchEngine != null) {
+ setDescriptionOnSuggestion(mUserEnteredTextView, searchTerm);
+ }
+ }
+
+ public void setOnUrlOpenListener(OnUrlOpenListener listener) {
+ mUrlOpenListener = listener;
+ }
+
+ public void setOnSearchListener(OnSearchListener listener) {
+ mSearchListener = listener;
+ }
+
+ public void setOnEditSuggestionListener(OnEditSuggestionListener listener) {
+ mEditSuggestionListener = listener;
+ }
+
+ private void bindSuggestionView(String suggestion, boolean animate, int recycledSuggestionCount, Integer previousSuggestionChildIndex, boolean isUserSavedSearch, String telemetryTag) {
+ final View suggestionItem;
+
+ // Reuse suggestion views from recycled view, if possible.
+ if (previousSuggestionChildIndex + 1 < recycledSuggestionCount) {
+ suggestionItem = mSuggestionView.getChildAt(previousSuggestionChildIndex + 1);
+ suggestionItem.setVisibility(View.VISIBLE);
+ } else {
+ suggestionItem = mInflater.inflate(R.layout.suggestion_item, null);
+
+ suggestionItem.setOnClickListener(mClickListener);
+ suggestionItem.setOnLongClickListener(mLongClickListener);
+
+ suggestionItem.setTag(telemetryTag);
+
+ mSuggestionView.addView(suggestionItem);
+ }
+
+ setSuggestionOnView(suggestionItem, suggestion, isUserSavedSearch);
+
+ if (animate) {
+ AlphaAnimation anim = new AlphaAnimation(0, 1);
+ anim.setDuration(ANIMATION_DURATION);
+ anim.setStartOffset(previousSuggestionChildIndex * ANIMATION_DURATION);
+ suggestionItem.startAnimation(anim);
+ }
+ }
+
+ private void hideRecycledSuggestions(int lastVisibleChildIndex, int recycledSuggestionCount) {
+ // Hide extra suggestions that have been recycled.
+ for (int i = lastVisibleChildIndex + 1; i < recycledSuggestionCount; ++i) {
+ mSuggestionView.getChildAt(i).setVisibility(View.GONE);
+ }
+ }
+
+ /**
+ * Displays search suggestions from previous searches.
+ *
+ * @param savedSuggestions The List to iterate over for saved search suggestions to display. This function does not
+ * enforce a ui maximum or filter. It will show all the suggestions in this list.
+ * @param suggestionStartIndex global index of where to start adding suggestion "buttons" in the search engine row. Also
+ * acts as a counter for total number of suggestions visible.
+ * @param animate whether or not to animate suggestions for visual polish
+ * @param recycledSuggestionCount How many suggestion "button" views we could recycle from previous calls
+ */
+ private void updateFromSavedSearches(List<String> savedSuggestions, boolean animate, int suggestionStartIndex, int recycledSuggestionCount) {
+ if (savedSuggestions == null || savedSuggestions.isEmpty()) {
+ hideRecycledSuggestions(suggestionStartIndex, recycledSuggestionCount);
+ return;
+ }
+
+ final int numSavedSearches = savedSuggestions.size();
+ int indexOfPreviousSuggestion = 0;
+ for (int i = 0; i < numSavedSearches; i++) {
+ String telemetryTag = "history." + i;
+ final String suggestion = savedSuggestions.get(i);
+ indexOfPreviousSuggestion = suggestionStartIndex + i;
+ bindSuggestionView(suggestion, animate, recycledSuggestionCount, indexOfPreviousSuggestion, true, telemetryTag);
+ }
+
+ hideRecycledSuggestions(indexOfPreviousSuggestion + 1, recycledSuggestionCount);
+ }
+
+ /**
+ * Displays suggestions supplied by the search engine, relative to number of suggestions from search history.
+ *
+ * @param animate whether or not to animate suggestions for visual polish
+ * @param recycledSuggestionCount How many suggestion "button" views we could recycle from previous calls
+ * @param savedSuggestionCount how many saved searches this searchTerm has
+ * @return the global count of how many suggestions have been bound/shown in the search engine row
+ */
+ private int updateFromSearchEngine(boolean animate, List<String> searchEngineSuggestions, int recycledSuggestionCount, int savedSuggestionCount) {
+ int maxSuggestions = mMaxSearchSuggestions;
+ // If there are less than max saved searches on phones, fill the space with more search engine suggestions
+ if (!HardwareUtils.isTablet() && savedSuggestionCount < mMaxSavedSuggestions) {
+ maxSuggestions += mMaxSavedSuggestions - savedSuggestionCount;
+ }
+
+ final int numSearchEngineSuggestions = searchEngineSuggestions.size();
+ int relativeIndex;
+ for (relativeIndex = 0; relativeIndex < numSearchEngineSuggestions; relativeIndex++) {
+ if (relativeIndex == maxSuggestions) {
+ break;
+ }
+
+ // Since the search engine suggestions are listed first, their relative index is their global index
+ String telemetryTag = "engine." + relativeIndex;
+ final String suggestion = searchEngineSuggestions.get(relativeIndex);
+ bindSuggestionView(suggestion, animate, recycledSuggestionCount, relativeIndex, false, telemetryTag);
+ }
+
+ hideRecycledSuggestions(relativeIndex + 1, recycledSuggestionCount);
+
+ // Make sure mSelectedView is still valid.
+ if (mSelectedView >= mSuggestionView.getChildCount()) {
+ mSelectedView = mSuggestionView.getChildCount() - 1;
+ }
+
+ return relativeIndex;
+ }
+
+ /**
+ * Updates the whole suggestions UI, the search engine UI, suggestions from the default search engine,
+ * and suggestions from search history.
+ *
+ * This can be called before the opt-in permission prompt is shown or set.
+ * Even if both suggestion types are disabled, we need to update the search engine, its image, and the content description.
+ *
+ * @param searchSuggestionsEnabled whether or not suggestions from the default search engine are enabled
+ * @param searchEngine the search engine to use throughout the SearchEngineRow class
+ * @param rawSearchHistorySuggestions search history suggestions
+ * @param animate whether or not to use animations
+ **/
+ public void updateSuggestions(boolean searchSuggestionsEnabled, SearchEngine searchEngine, @Nullable List<String> rawSearchHistorySuggestions, boolean animate) {
+ mSearchEngine = searchEngine;
+ // Set the search engine icon (e.g., Google) for the row.
+
+ mIconView.updateAndScaleImage(IconResponse.create(mSearchEngine.getIcon()));
+ // Set the initial content description.
+ setDescriptionOnSuggestion(mUserEnteredTextView, mUserEnteredTextView.getText().toString());
+
+ final int recycledSuggestionCount = mSuggestionView.getChildCount();
+ final SharedPreferences prefs = GeckoSharedPrefs.forApp(getContext());
+ final boolean savedSearchesEnabled = prefs.getBoolean(GeckoPreferences.PREFS_HISTORY_SAVED_SEARCH, true);
+
+ // Remove duplicates of search engine suggestions from saved searches.
+ List<String> searchHistorySuggestions = (rawSearchHistorySuggestions != null) ? rawSearchHistorySuggestions : new ArrayList<String>();
+
+ // Filter out URLs and long search suggestions
+ Iterator<String> searchHistoryIterator = searchHistorySuggestions.iterator();
+ while (searchHistoryIterator.hasNext()) {
+ final String currentSearchHistory = searchHistoryIterator.next();
+
+ if (currentSearchHistory.length() > 50 || Pattern.matches("^(https?|ftp|file)://.*", currentSearchHistory)) {
+ searchHistoryIterator.remove();
+ }
+ }
+
+
+ List<String> searchEngineSuggestions = new ArrayList<String>();
+ for (String suggestion : searchEngine.getSuggestions()) {
+ searchHistorySuggestions.remove(suggestion);
+ searchEngineSuggestions.add(suggestion);
+ }
+ // Make sure the search term itself isn't duplicated. This is more important on phones than tablets where screen
+ // space is more precious.
+ searchHistorySuggestions.remove(getSuggestionTextFromView(mUserEnteredView));
+
+ // Trim the history suggestions down to the maximum allowed.
+ if (searchHistorySuggestions.size() >= mMaxSavedSuggestions) {
+ // The second index to subList() is exclusive, so this looks like an off by one error but it is not.
+ searchHistorySuggestions = searchHistorySuggestions.subList(0, mMaxSavedSuggestions);
+ }
+ final int searchHistoryCount = searchHistorySuggestions.size();
+
+ if (searchSuggestionsEnabled && savedSearchesEnabled) {
+ final int suggestionViewCount = updateFromSearchEngine(animate, searchEngineSuggestions, recycledSuggestionCount, searchHistoryCount);
+ updateFromSavedSearches(searchHistorySuggestions, animate, suggestionViewCount, recycledSuggestionCount);
+ } else if (savedSearchesEnabled) {
+ updateFromSavedSearches(searchHistorySuggestions, animate, 0, recycledSuggestionCount);
+ } else if (searchSuggestionsEnabled) {
+ updateFromSearchEngine(animate, searchEngineSuggestions, recycledSuggestionCount, 0);
+ } else {
+ // The current search term is treated separately from the suggestions list, hence we can
+ // recycle ALL suggestion items here. (We always show the current search term, i.e. 1 item,
+ // in front of the search engine suggestions and/or the search history.)
+ hideRecycledSuggestions(0, recycledSuggestionCount);
+ }
+ }
+
+ @Override
+ public boolean onKeyDown(int keyCode, android.view.KeyEvent event) {
+ final View suggestion = mSuggestionView.getChildAt(mSelectedView);
+
+ if (event.getAction() != android.view.KeyEvent.ACTION_DOWN) {
+ return false;
+ }
+
+ switch (event.getKeyCode()) {
+ case KeyEvent.KEYCODE_DPAD_RIGHT:
+ final View nextSuggestion = mSuggestionView.getChildAt(mSelectedView + 1);
+ if (nextSuggestion != null) {
+ changeSelectedSuggestion(suggestion, nextSuggestion);
+ mSelectedView++;
+ return true;
+ }
+ break;
+
+ case KeyEvent.KEYCODE_DPAD_LEFT:
+ final View prevSuggestion = mSuggestionView.getChildAt(mSelectedView - 1);
+ if (prevSuggestion != null) {
+ changeSelectedSuggestion(suggestion, prevSuggestion);
+ mSelectedView--;
+ return true;
+ }
+ break;
+
+ case KeyEvent.KEYCODE_BUTTON_A:
+ // TODO: handle long pressing for editing suggestions
+ return suggestion.performClick();
+ }
+
+ return false;
+ }
+
+ private void changeSelectedSuggestion(View oldSuggestion, View newSuggestion) {
+ oldSuggestion.setDuplicateParentStateEnabled(false);
+ newSuggestion.setDuplicateParentStateEnabled(true);
+ oldSuggestion.refreshDrawableState();
+ newSuggestion.refreshDrawableState();
+ }
+
+ public void onSelected() {
+ mSelectedView = 0;
+ mUserEnteredView.setDuplicateParentStateEnabled(true);
+ mUserEnteredView.refreshDrawableState();
+ }
+
+ public void onDeselected() {
+ final View suggestion = mSuggestionView.getChildAt(mSelectedView);
+ suggestion.setDuplicateParentStateEnabled(false);
+ suggestion.refreshDrawableState();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/SearchLoader.java b/mobile/android/base/java/org/mozilla/gecko/home/SearchLoader.java
new file mode 100644
index 000000000..f7b5b6586
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/SearchLoader.java
@@ -0,0 +1,114 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import java.util.EnumSet;
+
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.BrowserDB.FilterFlags;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.os.Bundle;
+import android.os.SystemClock;
+import android.support.v4.app.LoaderManager;
+import android.support.v4.app.LoaderManager.LoaderCallbacks;
+import android.support.v4.content.Loader;
+
+/**
+ * Encapsulates the implementation of the search cursor loader.
+ */
+class SearchLoader {
+ public static final String LOGTAG = "GeckoSearchLoader";
+
+ private static final String KEY_SEARCH_TERM = "search_term";
+ private static final String KEY_FILTER_FLAGS = "flags";
+
+ private SearchLoader() {
+ }
+
+ @SuppressWarnings("unchecked")
+ public static Loader<Cursor> createInstance(Context context, Bundle args) {
+ if (args != null) {
+ final String searchTerm = args.getString(KEY_SEARCH_TERM);
+ final EnumSet<FilterFlags> flags =
+ (EnumSet<FilterFlags>) args.getSerializable(KEY_FILTER_FLAGS);
+ return new SearchCursorLoader(context, searchTerm, flags);
+ } else {
+ return new SearchCursorLoader(context, "", EnumSet.noneOf(FilterFlags.class));
+ }
+ }
+
+ private static Bundle createArgs(String searchTerm, EnumSet<FilterFlags> flags) {
+ Bundle args = new Bundle();
+ args.putString(SearchLoader.KEY_SEARCH_TERM, searchTerm);
+ args.putSerializable(SearchLoader.KEY_FILTER_FLAGS, flags);
+
+ return args;
+ }
+
+ public static void init(LoaderManager manager, int loaderId,
+ LoaderCallbacks<Cursor> callbacks, String searchTerm) {
+ init(manager, loaderId, callbacks, searchTerm, EnumSet.noneOf(FilterFlags.class));
+ }
+
+ public static void init(LoaderManager manager, int loaderId,
+ LoaderCallbacks<Cursor> callbacks, String searchTerm,
+ EnumSet<FilterFlags> flags) {
+ final Bundle args = createArgs(searchTerm, flags);
+ manager.initLoader(loaderId, args, callbacks);
+ }
+
+ public static void restart(LoaderManager manager, int loaderId,
+ LoaderCallbacks<Cursor> callbacks, String searchTerm) {
+ restart(manager, loaderId, callbacks, searchTerm, EnumSet.noneOf(FilterFlags.class));
+ }
+
+ public static void restart(LoaderManager manager, int loaderId,
+ LoaderCallbacks<Cursor> callbacks, String searchTerm,
+ EnumSet<FilterFlags> flags) {
+ final Bundle args = createArgs(searchTerm, flags);
+ manager.restartLoader(loaderId, args, callbacks);
+ }
+
+ public static class SearchCursorLoader extends SimpleCursorLoader {
+ private static final String TELEMETRY_HISTOGRAM_LOAD_CURSOR = "FENNEC_SEARCH_LOADER_TIME_MS";
+
+ // Max number of search results.
+ private static final int SEARCH_LIMIT = 100;
+
+ // The target search term associated with the loader.
+ private final String mSearchTerm;
+
+ // The filter flags associated with the loader.
+ private final EnumSet<FilterFlags> mFlags;
+ private final GeckoProfile mProfile;
+
+ public SearchCursorLoader(Context context, String searchTerm, EnumSet<FilterFlags> flags) {
+ super(context);
+ mSearchTerm = searchTerm;
+ mFlags = flags;
+ mProfile = GeckoProfile.get(context);
+ }
+
+ @Override
+ public Cursor loadCursor() {
+ final long start = SystemClock.uptimeMillis();
+ final Cursor cursor = BrowserDB.from(mProfile).filter(getContext().getContentResolver(), mSearchTerm, SEARCH_LIMIT, mFlags);
+ final long end = SystemClock.uptimeMillis();
+ final long took = end - start;
+ Telemetry.addToHistogram(TELEMETRY_HISTOGRAM_LOAD_CURSOR, (int) Math.min(took, Integer.MAX_VALUE));
+ return cursor;
+ }
+
+ public String getSearchTerm() {
+ return mSearchTerm;
+ }
+ }
+
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/SimpleCursorLoader.java b/mobile/android/base/java/org/mozilla/gecko/home/SimpleCursorLoader.java
new file mode 100644
index 000000000..b8889c033
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/SimpleCursorLoader.java
@@ -0,0 +1,147 @@
+/*
+ * This is an adapted version of Android's original CursorLoader
+ * without all the ContentProvider-specific bits.
+ *
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.mozilla.gecko.home;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.support.v4.content.AsyncTaskLoader;
+
+import org.mozilla.gecko.GeckoApplication;
+
+/**
+ * A copy of the framework's {@link android.content.CursorLoader} that
+ * instead allows the caller to load the Cursor themselves via the abstract
+ * {@link #loadCursor()} method, rather than calling out to a ContentProvider via
+ * class methods.
+ *
+ * For new code, prefer {@link android.content.CursorLoader} (see @deprecated).
+ *
+ * This was originally created to re-use existing code which loaded Cursors manually.
+ *
+ * @deprecated since the framework provides an implementation, we'd like to eventually remove
+ * this class to reduce maintenance burden. Originally planned for bug 1239491, but
+ * it'd be more efficient to do this over time, rather than all at once.
+ */
+@Deprecated
+public abstract class SimpleCursorLoader extends AsyncTaskLoader<Cursor> {
+ final ForceLoadContentObserver mObserver;
+ Cursor mCursor;
+
+ public SimpleCursorLoader(Context context) {
+ super(context);
+ mObserver = new ForceLoadContentObserver();
+ }
+
+ /**
+ * Loads the target cursor for this loader. This method is called
+ * on a worker thread.
+ */
+ protected abstract Cursor loadCursor();
+
+ /* Runs on a worker thread */
+ @Override
+ public Cursor loadInBackground() {
+ Cursor cursor = loadCursor();
+
+ if (cursor != null) {
+ // Ensure the cursor window is filled
+ cursor.getCount();
+ cursor.registerContentObserver(mObserver);
+ }
+
+ return cursor;
+ }
+
+ /* Runs on the UI thread */
+ @Override
+ public void deliverResult(Cursor cursor) {
+ if (isReset()) {
+ // An async query came in while the loader is stopped
+ if (cursor != null) {
+ cursor.close();
+ }
+
+ return;
+ }
+
+ Cursor oldCursor = mCursor;
+ mCursor = cursor;
+
+ if (isStarted()) {
+ super.deliverResult(cursor);
+ }
+
+ if (oldCursor != null && oldCursor != cursor && !oldCursor.isClosed()) {
+ oldCursor.close();
+
+ // Trying to read from the closed cursor will cause crashes, hence we should make
+ // sure that no adapters/LoaderCallbacks are holding onto this cursor.
+ GeckoApplication.getRefWatcher(getContext()).watch(oldCursor);
+ }
+ }
+
+ /**
+ * Starts an asynchronous load of the list data. When the result is ready the callbacks
+ * will be called on the UI thread. If a previous load has been completed and is still valid
+ * the result may be passed to the callbacks immediately.
+ *
+ * Must be called from the UI thread
+ */
+ @Override
+ protected void onStartLoading() {
+ if (mCursor != null) {
+ deliverResult(mCursor);
+ }
+
+ if (takeContentChanged() || mCursor == null) {
+ forceLoad();
+ }
+ }
+
+ /**
+ * Must be called from the UI thread
+ */
+ @Override
+ protected void onStopLoading() {
+ // Attempt to cancel the current load task if possible.
+ cancelLoad();
+ }
+
+ @Override
+ public void onCanceled(Cursor cursor) {
+ if (cursor != null && !cursor.isClosed()) {
+ cursor.close();
+ }
+ }
+
+ @Override
+ protected void onReset() {
+ super.onReset();
+
+ // Ensure the loader is stopped
+ onStopLoading();
+
+ if (mCursor != null && !mCursor.isClosed()) {
+ mCursor.close();
+ }
+
+ mCursor = null;
+ }
+} \ No newline at end of file
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/SpacingDecoration.java b/mobile/android/base/java/org/mozilla/gecko/home/SpacingDecoration.java
new file mode 100644
index 000000000..039b65e82
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/SpacingDecoration.java
@@ -0,0 +1,20 @@
+package org.mozilla.gecko.home;
+
+import android.graphics.Rect;
+import android.support.v7.widget.RecyclerView;
+import android.view.View;
+
+public class SpacingDecoration extends RecyclerView.ItemDecoration {
+ private final int horizontalSpacing;
+ private final int verticalSpacing;
+
+ public SpacingDecoration(int horizontalSpacing, int verticalSpacing) {
+ this.horizontalSpacing = horizontalSpacing;
+ this.verticalSpacing = verticalSpacing;
+ }
+
+ @Override
+ public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
+ outRect.set(horizontalSpacing, verticalSpacing, horizontalSpacing, verticalSpacing);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/TabMenuStrip.java b/mobile/android/base/java/org/mozilla/gecko/home/TabMenuStrip.java
new file mode 100644
index 000000000..b302d3522
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/TabMenuStrip.java
@@ -0,0 +1,127 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import android.support.v4.content.ContextCompat;
+import org.mozilla.gecko.R;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewTreeObserver;
+import android.view.accessibility.AccessibilityEvent;
+import android.widget.HorizontalScrollView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+/**
+ * {@code TabMenuStrip} is the view used to display {@code HomePager} tabs
+ * on tablets. See {@code TabMenuStripLayout} for details about how the
+ * tabs are created and updated.
+ */
+public class TabMenuStrip extends HorizontalScrollView
+ implements HomePager.Decor {
+
+ // Offset between the selected tab title and the edge of the screen,
+ // except for the first and last tab in the tab strip.
+ private static final int TITLE_OFFSET_DIPS = 24;
+
+ private final int titleOffset;
+ private final TabMenuStripLayout layout;
+
+ private final Paint shadowPaint;
+ private final int shadowSize;
+
+ public interface OnTitleClickListener {
+ void onTitleClicked(int index);
+ }
+
+ public TabMenuStrip(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ // Disable the scroll bar.
+ setHorizontalScrollBarEnabled(false);
+ setFillViewport(true);
+
+ final Resources res = getResources();
+
+ titleOffset = (int) (TITLE_OFFSET_DIPS * res.getDisplayMetrics().density);
+
+ layout = new TabMenuStripLayout(context, attrs);
+ addView(layout, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
+
+ shadowSize = res.getDimensionPixelSize(R.dimen.tabs_strip_shadow_size);
+
+ shadowPaint = new Paint();
+ shadowPaint.setColor(ContextCompat.getColor(context, R.color.url_bar_shadow));
+ shadowPaint.setStrokeWidth(0.0f);
+ }
+
+ @Override
+ public void draw(Canvas canvas) {
+ super.draw(canvas);
+
+ final int height = getHeight();
+ canvas.drawRect(0, height - shadowSize, layout.getWidth(), height, shadowPaint);
+ }
+
+ @Override
+ public void onAddPagerView(String title) {
+ layout.onAddPagerView(title);
+ }
+
+ @Override
+ public void removeAllPagerViews() {
+ layout.removeAllViews();
+ }
+
+ @Override
+ public void onPageSelected(final int position) {
+ layout.onPageSelected(position);
+ }
+
+ @Override
+ public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
+ layout.onPageScrolled(position, positionOffset, positionOffsetPixels);
+
+ final View selectedTitle = layout.getChildAt(position);
+ if (selectedTitle == null) {
+ return;
+ }
+
+ final int selectedTitleOffset = (int) (positionOffset * selectedTitle.getWidth());
+
+ int titleLeft = selectedTitle.getLeft() + selectedTitleOffset;
+ if (position > 0) {
+ titleLeft -= titleOffset;
+ }
+
+ int titleRight = selectedTitle.getRight() + selectedTitleOffset;
+ if (position < layout.getChildCount() - 1) {
+ titleRight += titleOffset;
+ }
+
+ final int scrollX = getScrollX();
+ if (titleLeft < scrollX) {
+ // Tab strip overflows to the left.
+ scrollTo(titleLeft, 0);
+ } else if (titleRight > scrollX + getWidth()) {
+ // Tab strip overflows to the right.
+ scrollTo(titleRight - getWidth(), 0);
+ }
+ }
+
+ @Override
+ public void setOnTitleClickListener(OnTitleClickListener onTitleClickListener) {
+ layout.setOnTitleClickListener(onTitleClickListener);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/TabMenuStripLayout.java b/mobile/android/base/java/org/mozilla/gecko/home/TabMenuStripLayout.java
new file mode 100644
index 000000000..a09add80b
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/TabMenuStripLayout.java
@@ -0,0 +1,246 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import android.view.ViewGroup;
+import android.widget.LinearLayout;
+
+import android.content.res.ColorStateList;
+import org.mozilla.gecko.R;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewTreeObserver;
+import android.view.accessibility.AccessibilityEvent;
+import android.widget.TextView;
+
+/**
+ * {@code TabMenuStripLayout} is the view that draws the {@code HomePager}
+ * tabs that are displayed in {@code TabMenuStrip}.
+ */
+class TabMenuStripLayout extends LinearLayout
+ implements View.OnFocusChangeListener {
+
+ private TabMenuStrip.OnTitleClickListener onTitleClickListener;
+ private Drawable strip;
+ private TextView selectedView;
+
+ // Data associated with the scrolling of the strip drawable.
+ private View toTab;
+ private View fromTab;
+ private int fromPosition;
+ private int toPosition;
+ private float progress;
+
+ // This variable is used to predict the direction of scroll.
+ private float prevProgress;
+ private int tabContentStart;
+ private boolean titlebarFill;
+ private int activeTextColor;
+ private ColorStateList inactiveTextColor;
+
+ TabMenuStripLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TabMenuStrip);
+ final int stripResId = a.getResourceId(R.styleable.TabMenuStrip_strip, -1);
+
+ titlebarFill = a.getBoolean(R.styleable.TabMenuStrip_titlebarFill, false);
+ tabContentStart = a.getDimensionPixelSize(R.styleable.TabMenuStrip_tabsMarginLeft, 0);
+ activeTextColor = a.getColor(R.styleable.TabMenuStrip_activeTextColor, R.color.text_and_tabs_tray_grey);
+ inactiveTextColor = a.getColorStateList(R.styleable.TabMenuStrip_inactiveTextColor);
+ a.recycle();
+
+ if (stripResId != -1) {
+ strip = getResources().getDrawable(stripResId);
+ }
+
+ setWillNotDraw(false);
+ }
+
+ void onAddPagerView(String title) {
+ final TextView button = (TextView) LayoutInflater.from(getContext()).inflate(R.layout.tab_menu_strip, this, false);
+ button.setText(title.toUpperCase());
+ button.setTextColor(inactiveTextColor);
+
+ // Set titles width to weight, or wrap text width.
+ if (titlebarFill) {
+ button.setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, 1.0f));
+ } else {
+ button.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT));
+ }
+
+ if (getChildCount() == 0) {
+ button.setPadding(button.getPaddingLeft() + tabContentStart,
+ button.getPaddingTop(),
+ button.getPaddingRight(),
+ button.getPaddingBottom());
+ }
+
+ addView(button);
+ button.setOnClickListener(new ViewClickListener(getChildCount() - 1));
+ button.setOnFocusChangeListener(this);
+ }
+
+ void onPageSelected(final int position) {
+ if (selectedView != null) {
+ selectedView.setTextColor(inactiveTextColor);
+ }
+
+ selectedView = (TextView) getChildAt(position);
+ selectedView.setTextColor(activeTextColor);
+
+ // Callback to measure and draw the strip after the view is visible.
+ ViewTreeObserver vto = selectedView.getViewTreeObserver();
+ if (vto.isAlive()) {
+ vto.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
+ @Override
+ public void onGlobalLayout() {
+ selectedView.getViewTreeObserver().removeGlobalOnLayoutListener(this);
+
+ if (strip != null) {
+ strip.setBounds(selectedView.getLeft() + (position == 0 ? tabContentStart : 0),
+ selectedView.getTop(),
+ selectedView.getRight(),
+ selectedView.getBottom());
+ }
+
+ prevProgress = position;
+ }
+ });
+ }
+ }
+
+ // Page scroll animates the drawable and its bounds from the previous to next child view.
+ void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
+ if (strip == null) {
+ return;
+ }
+
+ setScrollingData(position, positionOffset);
+
+ if (fromTab == null || toTab == null) {
+ return;
+ }
+
+ final int fromTabLeft = fromTab.getLeft();
+ final int fromTabRight = fromTab.getRight();
+
+ final int toTabLeft = toTab.getLeft();
+ final int toTabRight = toTab.getRight();
+
+ // The first tab has a padding applied (tabContentStart). We don't want the 'strip' to jump around so we remove
+ // this padding slowly (modifier) when scrolling to or from the first tab.
+ final int modifier;
+
+ if (fromPosition == 0 && toPosition == 1) {
+ // Slowly remove extra padding (tabContentStart) based on scroll progress
+ modifier = (int) (tabContentStart * (1 - progress));
+ } else if (fromPosition == 1 && toPosition == 0) {
+ // Slowly add extra padding (tabContentStart) based on scroll progress
+ modifier = (int) (tabContentStart * progress);
+ } else {
+ // We are not scrolling tab 0 in any way, no modifier needed
+ modifier = 0;
+ }
+
+ strip.setBounds((int) (fromTabLeft + ((toTabLeft - fromTabLeft) * progress)) + modifier,
+ 0,
+ (int) (fromTabRight + ((toTabRight - fromTabRight) * progress)),
+ getHeight());
+ invalidate();
+ }
+
+ /*
+ * position + positionOffset goes from 0 to 2 as we scroll from page 1 to 3.
+ * Normalized progress is relative to the the direction the page is being scrolled towards.
+ * For this, we maintain direction of scroll with a state, and the child view we are moving towards and away from.
+ */
+ void setScrollingData(int position, float positionOffset) {
+ if (position >= getChildCount() - 1) {
+ return;
+ }
+
+ final float currProgress = position + positionOffset;
+
+ if (prevProgress > currProgress) {
+ toPosition = position;
+ fromPosition = position + 1;
+ progress = 1 - positionOffset;
+ } else {
+ toPosition = position + 1;
+ fromPosition = position;
+ progress = positionOffset;
+ }
+
+ toTab = getChildAt(toPosition);
+ fromTab = getChildAt(fromPosition);
+
+ prevProgress = currProgress;
+ }
+
+ @Override
+ public void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+
+ if (strip != null) {
+ strip.draw(canvas);
+ }
+ }
+
+ @Override
+ public void onFocusChange(View v, boolean hasFocus) {
+ if (v == this && hasFocus && getChildCount() > 0) {
+ selectedView.requestFocus();
+ return;
+ }
+
+ if (!hasFocus) {
+ return;
+ }
+
+ int i = 0;
+ final int numTabs = getChildCount();
+
+ while (i < numTabs) {
+ View view = getChildAt(i);
+ if (view == v) {
+ view.requestFocus();
+ if (isShown()) {
+ // A view is focused so send an event to announce the menu strip state.
+ sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED);
+ }
+ break;
+ }
+
+ i++;
+ }
+ }
+
+ void setOnTitleClickListener(TabMenuStrip.OnTitleClickListener onTitleClickListener) {
+ this.onTitleClickListener = onTitleClickListener;
+ }
+
+ private class ViewClickListener implements OnClickListener {
+ private final int mIndex;
+
+ public ViewClickListener(int index) {
+ mIndex = index;
+ }
+
+ @Override
+ public void onClick(View view) {
+ if (onTitleClickListener != null) {
+ onTitleClickListener.onTitleClicked(mIndex);
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/TopSitesGridItemView.java b/mobile/android/base/java/org/mozilla/gecko/home/TopSitesGridItemView.java
new file mode 100644
index 000000000..c17aff209
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/TopSitesGridItemView.java
@@ -0,0 +1,312 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.widget.ImageView.ScaleType;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.BrowserContract.TopSites;
+import org.mozilla.gecko.icons.IconCallback;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+
+import java.util.concurrent.Future;
+
+/**
+ * A view that displays the thumbnail and the title/url for a top/pinned site.
+ * If the title/url is longer than the width of the view, they are faded out.
+ * If there is no valid url, a default string is shown at 50% opacity.
+ * This is denoted by the empty state.
+ */
+public class TopSitesGridItemView extends RelativeLayout implements IconCallback {
+ private static final String LOGTAG = "GeckoTopSitesGridItemView";
+
+ // Empty state, to denote there is no valid url.
+ private static final int[] STATE_EMPTY = { android.R.attr.state_empty };
+
+ private static final ScaleType SCALE_TYPE_FAVICON = ScaleType.CENTER;
+ private static final ScaleType SCALE_TYPE_RESOURCE = ScaleType.CENTER;
+ private static final ScaleType SCALE_TYPE_THUMBNAIL = ScaleType.CENTER_CROP;
+ private static final ScaleType SCALE_TYPE_URL = ScaleType.CENTER_INSIDE;
+
+ // Child views.
+ private final TextView mTitleView;
+ private final TopSitesThumbnailView mThumbnailView;
+
+ // Data backing this view.
+ private String mTitle;
+ private String mUrl;
+
+ private boolean mThumbnailSet;
+
+ // Matches BrowserContract.TopSites row types
+ private int mType = -1;
+
+ // Dirty state.
+ private boolean mIsDirty;
+
+ private Future<IconResponse> mOngoingIconRequest;
+
+ public TopSitesGridItemView(Context context) {
+ this(context, null);
+ }
+
+ public TopSitesGridItemView(Context context, AttributeSet attrs) {
+ this(context, attrs, R.attr.topSitesGridItemViewStyle);
+ }
+
+ public TopSitesGridItemView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+
+ LayoutInflater.from(context).inflate(R.layout.top_sites_grid_item_view, this);
+
+ mTitleView = (TextView) findViewById(R.id.title);
+ mThumbnailView = (TopSitesThumbnailView) findViewById(R.id.thumbnail);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public int[] onCreateDrawableState(int extraSpace) {
+ final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
+
+ if (mType == TopSites.TYPE_BLANK) {
+ mergeDrawableStates(drawableState, STATE_EMPTY);
+ }
+
+ return drawableState;
+ }
+
+ /**
+ * @return The title shown by this view.
+ */
+ public String getTitle() {
+ return (!TextUtils.isEmpty(mTitle) ? mTitle : mUrl);
+ }
+
+ /**
+ * @return The url shown by this view.
+ */
+ public String getUrl() {
+ return mUrl;
+ }
+
+ /**
+ * @return The site type associated with this view.
+ */
+ public int getType() {
+ return mType;
+ }
+
+ /**
+ * @param title The title for this view.
+ */
+ public void setTitle(String title) {
+ if (mTitle != null && mTitle.equals(title)) {
+ return;
+ }
+
+ mTitle = title;
+ updateTitleView();
+ }
+
+ /**
+ * @param url The url for this view.
+ */
+ public void setUrl(String url) {
+ if (mUrl != null && mUrl.equals(url)) {
+ return;
+ }
+
+ mUrl = url;
+ updateTitleView();
+ }
+
+ public void blankOut() {
+ mUrl = "";
+ mTitle = "";
+ updateType(TopSites.TYPE_BLANK);
+ updateTitleView();
+ cancelIconLoading();
+ ImageLoader.with(getContext()).cancelRequest(mThumbnailView);
+ displayThumbnail(R.drawable.top_site_add);
+
+ }
+
+ public void markAsDirty() {
+ mIsDirty = true;
+ }
+
+ /**
+ * Updates the title, URL, and pinned state of this view.
+ *
+ * Also resets our loadId to NOT_LOADING.
+ *
+ * Returns true if any fields changed.
+ */
+ public boolean updateState(final String title, final String url, final int type, final TopSitesPanel.ThumbnailInfo thumbnail) {
+ boolean changed = false;
+ if (mUrl == null || !mUrl.equals(url)) {
+ mUrl = url;
+ changed = true;
+ }
+
+ if (mTitle == null || !mTitle.equals(title)) {
+ mTitle = title;
+ changed = true;
+ }
+
+ if (thumbnail != null) {
+ if (thumbnail.imageUrl != null) {
+ displayThumbnail(thumbnail.imageUrl, thumbnail.bgColor);
+ } else if (thumbnail.bitmap != null) {
+ displayThumbnail(thumbnail.bitmap);
+ }
+ } else if (changed) {
+ // Because we'll have a new favicon or thumbnail arriving shortly, and
+ // we need to not reject it because we already had a thumbnail.
+ mThumbnailSet = false;
+ }
+
+ if (changed) {
+ updateTitleView();
+ cancelIconLoading();
+ ImageLoader.with(getContext()).cancelRequest(mThumbnailView);
+ }
+
+ if (updateType(type)) {
+ changed = true;
+ }
+
+ // The dirty state forces the state update to return true
+ // so that the adapter loads favicons once the thumbnails
+ // are loaded in TopSitesPanel/TopSitesGridAdapter.
+ changed = (changed || mIsDirty);
+ mIsDirty = false;
+
+ return changed;
+ }
+
+ /**
+ * Try to load an icon for the given page URL.
+ */
+ public void loadFavicon(String pageUrl) {
+ mOngoingIconRequest = Icons.with(getContext())
+ .pageUrl(pageUrl)
+ .skipNetwork()
+ .build()
+ .execute(this);
+ }
+
+ private void cancelIconLoading() {
+ if (mOngoingIconRequest != null) {
+ mOngoingIconRequest.cancel(true);
+ }
+ }
+
+ /**
+ * Display the thumbnail from a resource.
+ *
+ * @param resId Resource ID of the drawable to show.
+ */
+ public void displayThumbnail(int resId) {
+ mThumbnailView.setScaleType(SCALE_TYPE_RESOURCE);
+ mThumbnailView.setImageResource(resId);
+ mThumbnailView.setBackgroundColor(0x0);
+ mThumbnailSet = false;
+ }
+
+ /**
+ * Display the thumbnail from a bitmap.
+ *
+ * @param thumbnail The bitmap to show as thumbnail.
+ */
+ public void displayThumbnail(Bitmap thumbnail) {
+ if (thumbnail == null) {
+ return;
+ }
+
+ mThumbnailSet = true;
+
+ cancelIconLoading();
+ ImageLoader.with(getContext()).cancelRequest(mThumbnailView);
+
+ mThumbnailView.setScaleType(SCALE_TYPE_THUMBNAIL);
+ mThumbnailView.setImageBitmap(thumbnail, true);
+ mThumbnailView.setBackgroundDrawable(null);
+ }
+
+ /**
+ * Display the thumbnail from a URL.
+ *
+ * @param imageUrl URL of the image to show.
+ * @param bgColor background color to use in the view.
+ */
+ public void displayThumbnail(final String imageUrl, final int bgColor) {
+ mThumbnailView.setScaleType(SCALE_TYPE_URL);
+ mThumbnailView.setBackgroundColor(bgColor);
+ mThumbnailSet = true;
+
+ ImageLoader.with(getContext())
+ .load(imageUrl)
+ .noFade()
+ .into(mThumbnailView);
+ }
+
+ /**
+ * Update the item type associated with this view. Returns true if
+ * the type has changed, false otherwise.
+ */
+ private boolean updateType(int type) {
+ if (mType == type) {
+ return false;
+ }
+
+ mType = type;
+ refreshDrawableState();
+
+ int pinResourceId = (type == TopSites.TYPE_PINNED ? R.drawable.pin : 0);
+ mTitleView.setCompoundDrawablesWithIntrinsicBounds(pinResourceId, 0, 0, 0);
+
+ return true;
+ }
+
+ /**
+ * Update the title shown by this view. If both title and url
+ * are empty, mark the state as STATE_EMPTY and show a default text.
+ */
+ private void updateTitleView() {
+ String title = getTitle();
+ if (!TextUtils.isEmpty(title)) {
+ mTitleView.setText(title);
+ } else {
+ mTitleView.setText(R.string.home_top_sites_add);
+ }
+ }
+
+ /**
+ * Display the loaded icon (if no thumbnail is set).
+ */
+ @Override
+ public void onIconResponse(IconResponse response) {
+ if (mThumbnailSet) {
+ // Already showing a thumbnail; do nothing.
+ return;
+ }
+
+ mThumbnailView.setScaleType(SCALE_TYPE_FAVICON);
+ mThumbnailView.setImageBitmap(response.getBitmap(), false);
+ mThumbnailView.setBackgroundColorWithOpacityFilter(response.getColor());
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/TopSitesGridView.java b/mobile/android/base/java/org/mozilla/gecko/home/TopSitesGridView.java
new file mode 100644
index 000000000..58a05b198
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/TopSitesGridView.java
@@ -0,0 +1,169 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.ThumbnailHelper;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Rect;
+import android.util.AttributeSet;
+import android.view.ContextMenu.ContextMenuInfo;
+import android.view.View;
+import android.widget.AbsListView;
+import android.widget.GridView;
+
+/**
+ * A grid view of top and pinned sites.
+ * Each cell in the grid is a TopSitesGridItemView.
+ */
+public class TopSitesGridView extends GridView {
+ private static final String LOGTAG = "GeckoTopSitesGridView";
+
+ // Listener for editing pinned sites.
+ public static interface OnEditPinnedSiteListener {
+ public void onEditPinnedSite(int position, String searchTerm);
+ }
+
+ // Max number of top sites that needs to be shown.
+ private final int mMaxSites;
+
+ // Number of columns to show.
+ private final int mNumColumns;
+
+ // Horizontal spacing in between the rows.
+ private final int mHorizontalSpacing;
+
+ // Vertical spacing in between the rows.
+ private final int mVerticalSpacing;
+
+ // Measured width of this view.
+ private int mMeasuredWidth;
+
+ // Measured height of this view.
+ private int mMeasuredHeight;
+
+ // A dummy View used to measure the required size of the child Views.
+ private final TopSitesGridItemView dummyChildView;
+
+ // Context menu info.
+ private TopSitesGridContextMenuInfo mContextMenuInfo;
+
+ // Whether we're handling focus changes or not. This is used
+ // to avoid infinite re-layouts when using this GridView as
+ // a ListView header view (see bug 918044).
+ private boolean mIsHandlingFocusChange;
+
+ public TopSitesGridView(Context context) {
+ this(context, null);
+ }
+
+ public TopSitesGridView(Context context, AttributeSet attrs) {
+ this(context, attrs, R.attr.topSitesGridViewStyle);
+ }
+
+ public TopSitesGridView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ mMaxSites = getResources().getInteger(R.integer.number_of_top_sites);
+ mNumColumns = getResources().getInteger(R.integer.number_of_top_sites_cols);
+ setNumColumns(mNumColumns);
+
+ TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TopSitesGridView, defStyle, 0);
+ mHorizontalSpacing = a.getDimensionPixelOffset(R.styleable.TopSitesGridView_android_horizontalSpacing, 0x00);
+ mVerticalSpacing = a.getDimensionPixelOffset(R.styleable.TopSitesGridView_android_verticalSpacing, 0x00);
+ a.recycle();
+
+ dummyChildView = new TopSitesGridItemView(context);
+ // Set a default LayoutParams on the child, if it doesn't have one on its own.
+ AbsListView.LayoutParams params = (AbsListView.LayoutParams) dummyChildView.getLayoutParams();
+ if (params == null) {
+ params = new AbsListView.LayoutParams(AbsListView.LayoutParams.WRAP_CONTENT,
+ AbsListView.LayoutParams.WRAP_CONTENT);
+ dummyChildView.setLayoutParams(params);
+ }
+ }
+
+ @Override
+ protected void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) {
+ mIsHandlingFocusChange = true;
+ super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
+ mIsHandlingFocusChange = false;
+ }
+
+ @Override
+ public void requestLayout() {
+ if (!mIsHandlingFocusChange) {
+ super.requestLayout();
+ }
+ }
+
+ @Override
+ public int getColumnWidth() {
+ // This method will be called from onMeasure() too.
+ // It's better to use getMeasuredWidth(), as it is safe in this case.
+ final int totalHorizontalSpacing = mNumColumns > 0 ? (mNumColumns - 1) * mHorizontalSpacing : 0;
+ return (getMeasuredWidth() - getPaddingLeft() - getPaddingRight() - totalHorizontalSpacing) / mNumColumns;
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ // Sets the padding for this view.
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+
+ final int measuredWidth = getMeasuredWidth();
+ if (measuredWidth == mMeasuredWidth) {
+ // Return the cached values as the width is the same.
+ setMeasuredDimension(mMeasuredWidth, mMeasuredHeight);
+ return;
+ }
+
+ final int columnWidth = getColumnWidth();
+
+ // Measure the exact width of the child, and the height based on the width.
+ // Note: the child (and TopSitesThumbnailView) takes care of calculating its height.
+ int childWidthSpec = MeasureSpec.makeMeasureSpec(columnWidth, MeasureSpec.EXACTLY);
+ int childHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
+ dummyChildView.measure(childWidthSpec, childHeightSpec);
+ final int childHeight = dummyChildView.getMeasuredHeight();
+
+ // This is the maximum width of the contents of each child in the grid.
+ // Use this as the target width for thumbnails.
+ final int thumbnailWidth = dummyChildView.getMeasuredWidth() - dummyChildView.getPaddingLeft() - dummyChildView.getPaddingRight();
+ ThumbnailHelper.getInstance().setThumbnailWidth(thumbnailWidth);
+
+ // Number of rows required to show these top sites.
+ final int rows = (int) Math.ceil((double) mMaxSites / mNumColumns);
+ final int childrenHeight = childHeight * rows;
+ final int totalVerticalSpacing = rows > 0 ? (rows - 1) * mVerticalSpacing : 0;
+
+ // Total height of this view.
+ final int measuredHeight = childrenHeight + getPaddingTop() + getPaddingBottom() + totalVerticalSpacing;
+ setMeasuredDimension(measuredWidth, measuredHeight);
+ mMeasuredWidth = measuredWidth;
+ mMeasuredHeight = measuredHeight;
+ }
+
+ @Override
+ public ContextMenuInfo getContextMenuInfo() {
+ return mContextMenuInfo;
+ }
+
+ public void setContextMenuInfo(TopSitesGridContextMenuInfo contextMenuInfo) {
+ mContextMenuInfo = contextMenuInfo;
+ }
+
+ /**
+ * Stores information regarding the creation of the context menu for a GridView item.
+ */
+ public static class TopSitesGridContextMenuInfo extends HomeContextMenuInfo {
+ public int type = -1;
+
+ public TopSitesGridContextMenuInfo(View targetView, int position, long id) {
+ super(targetView, position, id);
+ this.itemType = RemoveItemType.HISTORY;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/TopSitesPanel.java b/mobile/android/base/java/org/mozilla/gecko/home/TopSitesPanel.java
new file mode 100644
index 000000000..f39e51ac5
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/TopSitesPanel.java
@@ -0,0 +1,968 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import static org.mozilla.gecko.db.URLMetadataTable.TILE_COLOR_COLUMN;
+import static org.mozilla.gecko.db.URLMetadataTable.TILE_IMAGE_URL_COLUMN;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Future;
+
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.db.BrowserContract.Thumbnails;
+import org.mozilla.gecko.db.BrowserContract.TopSites;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.gfx.BitmapUtils;
+import org.mozilla.gecko.home.HomeContextMenuInfo.RemoveItemType;
+import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
+import org.mozilla.gecko.home.PinSiteDialog.OnSiteSelectedListener;
+import org.mozilla.gecko.home.TopSitesGridView.OnEditPinnedSiteListener;
+import org.mozilla.gecko.home.TopSitesGridView.TopSitesGridContextMenuInfo;
+import org.mozilla.gecko.icons.IconCallback;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.restrictions.Restrictable;
+import org.mozilla.gecko.restrictions.Restrictions;
+import org.mozilla.gecko.util.StringUtils;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.app.Activity;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.os.Bundle;
+import android.os.SystemClock;
+import android.support.v4.app.FragmentManager;
+import android.support.v4.app.LoaderManager.LoaderCallbacks;
+import android.support.v4.content.AsyncTaskLoader;
+import android.support.v4.content.Loader;
+import android.support.v4.widget.CursorAdapter;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.ContextMenu;
+import android.view.ContextMenu.ContextMenuInfo;
+import android.view.LayoutInflater;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.ListView;
+
+/**
+ * Fragment that displays frecency search results in a ListView.
+ */
+public class TopSitesPanel extends HomeFragment {
+ // Logging tag name
+ private static final String LOGTAG = "GeckoTopSitesPanel";
+
+ // Cursor loader ID for the top sites
+ private static final int LOADER_ID_TOP_SITES = 0;
+
+ // Loader ID for thumbnails
+ private static final int LOADER_ID_THUMBNAILS = 1;
+
+ // Key for thumbnail urls
+ private static final String THUMBNAILS_URLS_KEY = "urls";
+
+ // Adapter for the list of top sites
+ private VisitedAdapter mListAdapter;
+
+ // Adapter for the grid of top sites
+ private TopSitesGridAdapter mGridAdapter;
+
+ // List of top sites
+ private HomeListView mList;
+
+ // Grid of top sites
+ private TopSitesGridView mGrid;
+
+ // Callbacks used for the search and favicon cursor loaders
+ private CursorLoaderCallbacks mCursorLoaderCallbacks;
+
+ // Callback for thumbnail loader
+ private ThumbnailsLoaderCallbacks mThumbnailsLoaderCallbacks;
+
+ // Listener for editing pinned sites.
+ private EditPinnedSiteListener mEditPinnedSiteListener;
+
+ // Max number of entries shown in the grid from the cursor.
+ private int mMaxGridEntries;
+
+ // Time in ms until the Gecko thread is reset to normal priority.
+ private static final long PRIORITY_RESET_TIMEOUT = 10000;
+
+ public static TopSitesPanel newInstance() {
+ return new TopSitesPanel();
+ }
+
+ private static final boolean logDebug = Log.isLoggable(LOGTAG, Log.DEBUG);
+ private static final boolean logVerbose = Log.isLoggable(LOGTAG, Log.VERBOSE);
+
+ private static void debug(final String message) {
+ if (logDebug) {
+ Log.d(LOGTAG, message);
+ }
+ }
+
+ private static void trace(final String message) {
+ if (logVerbose) {
+ Log.v(LOGTAG, message);
+ }
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+
+ mMaxGridEntries = activity.getResources().getInteger(R.integer.number_of_top_sites);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ final View view = inflater.inflate(R.layout.home_top_sites_panel, container, false);
+
+ mList = (HomeListView) view.findViewById(R.id.list);
+
+ mGrid = new TopSitesGridView(getActivity());
+ mList.addHeaderView(mGrid);
+
+ return view;
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ mEditPinnedSiteListener = new EditPinnedSiteListener();
+
+ mList.setTag(HomePager.LIST_TAG_TOP_SITES);
+ mList.setHeaderDividersEnabled(false);
+
+ mList.setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ final ListView list = (ListView) parent;
+ final int headerCount = list.getHeaderViewsCount();
+ if (position < headerCount) {
+ // The click is on a header, don't do anything.
+ return;
+ }
+
+ // Absolute position for the adapter.
+ position += (mGridAdapter.getCount() - headerCount);
+
+ final Cursor c = mListAdapter.getCursor();
+ if (c == null || !c.moveToPosition(position)) {
+ return;
+ }
+
+ final String url = c.getString(c.getColumnIndexOrThrow(TopSites.URL));
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.LIST_ITEM, "top_sites");
+
+ // This item is a TwoLinePageRow, so we allow switch-to-tab.
+ mUrlOpenListener.onUrlOpen(url, EnumSet.of(OnUrlOpenListener.Flags.ALLOW_SWITCH_TO_TAB));
+ }
+ });
+
+ mList.setContextMenuInfoFactory(new HomeContextMenuInfo.Factory() {
+ @Override
+ public HomeContextMenuInfo makeInfoForCursor(View view, int position, long id, Cursor cursor) {
+ final HomeContextMenuInfo info = new HomeContextMenuInfo(view, position, id);
+ info.url = cursor.getString(cursor.getColumnIndexOrThrow(TopSites.URL));
+ info.title = cursor.getString(cursor.getColumnIndexOrThrow(TopSites.TITLE));
+ info.historyId = cursor.getInt(cursor.getColumnIndexOrThrow(TopSites.HISTORY_ID));
+ info.itemType = RemoveItemType.HISTORY;
+ final int bookmarkIdCol = cursor.getColumnIndexOrThrow(TopSites.BOOKMARK_ID);
+ if (cursor.isNull(bookmarkIdCol)) {
+ // If this is a combined cursor, we may get a history item without a
+ // bookmark, in which case the bookmarks ID column value will be null.
+ info.bookmarkId = -1;
+ } else {
+ info.bookmarkId = cursor.getInt(bookmarkIdCol);
+ }
+ return info;
+ }
+ });
+
+ mGrid.setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ TopSitesGridItemView item = (TopSitesGridItemView) view;
+
+ // Decode "user-entered" URLs before loading them.
+ String url = StringUtils.decodeUserEnteredUrl(item.getUrl());
+ int type = item.getType();
+
+ // If the url is empty, the user can pin a site.
+ // If not, navigate to the page given by the url.
+ if (type != TopSites.TYPE_BLANK) {
+ if (mUrlOpenListener != null) {
+ final TelemetryContract.Method method;
+ if (type == TopSites.TYPE_SUGGESTED) {
+ method = TelemetryContract.Method.SUGGESTION;
+ } else {
+ method = TelemetryContract.Method.GRID_ITEM;
+ }
+
+ String extra = Integer.toString(position);
+ if (type == TopSites.TYPE_PINNED) {
+ extra += "-pinned";
+ }
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, method, extra);
+
+ mUrlOpenListener.onUrlOpen(url, EnumSet.of(OnUrlOpenListener.Flags.NO_READER_VIEW));
+ }
+ } else {
+ if (mEditPinnedSiteListener != null) {
+ mEditPinnedSiteListener.onEditPinnedSite(position, "");
+ }
+ }
+ }
+ });
+
+ mGrid.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() {
+ @Override
+ public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
+
+ Cursor cursor = (Cursor) parent.getItemAtPosition(position);
+
+ TopSitesGridItemView item = (TopSitesGridItemView) view;
+ if (cursor == null || item.getType() == TopSites.TYPE_BLANK) {
+ mGrid.setContextMenuInfo(null);
+ return false;
+ }
+
+ TopSitesGridContextMenuInfo contextMenuInfo = new TopSitesGridContextMenuInfo(view, position, id);
+ updateContextMenuFromCursor(contextMenuInfo, cursor);
+ mGrid.setContextMenuInfo(contextMenuInfo);
+ return mGrid.showContextMenuForChild(mGrid);
+ }
+
+ /*
+ * Update the fields of a TopSitesGridContextMenuInfo object
+ * from a cursor.
+ *
+ * @param info context menu info object to be updated
+ * @param cursor used to update the context menu info object
+ */
+ private void updateContextMenuFromCursor(TopSitesGridContextMenuInfo info, Cursor cursor) {
+ info.url = cursor.getString(cursor.getColumnIndexOrThrow(TopSites.URL));
+ info.title = cursor.getString(cursor.getColumnIndexOrThrow(TopSites.TITLE));
+ info.type = cursor.getInt(cursor.getColumnIndexOrThrow(TopSites.TYPE));
+ info.historyId = cursor.getInt(cursor.getColumnIndexOrThrow(TopSites.HISTORY_ID));
+ }
+ });
+
+ registerForContextMenu(mList);
+ registerForContextMenu(mGrid);
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+
+ // Discard any additional item clicks on the list as the
+ // panel is getting destroyed (see bugs 930160 & 1096958).
+ mList.setOnItemClickListener(null);
+ mGrid.setOnItemClickListener(null);
+
+ mList = null;
+ mGrid = null;
+ mListAdapter = null;
+ mGridAdapter = null;
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+
+ final Activity activity = getActivity();
+
+ // Setup the top sites grid adapter.
+ mGridAdapter = new TopSitesGridAdapter(activity, null);
+ mGrid.setAdapter(mGridAdapter);
+
+ // Setup the top sites list adapter.
+ mListAdapter = new VisitedAdapter(activity, null);
+ mList.setAdapter(mListAdapter);
+
+ // Create callbacks before the initial loader is started
+ mCursorLoaderCallbacks = new CursorLoaderCallbacks();
+ mThumbnailsLoaderCallbacks = new ThumbnailsLoaderCallbacks();
+ loadIfVisible();
+ }
+
+ @Override
+ public void onCreateContextMenu(ContextMenu menu, View view, ContextMenuInfo menuInfo) {
+ if (menuInfo == null) {
+ return;
+ }
+
+ if (!(menuInfo instanceof TopSitesGridContextMenuInfo)) {
+ // Long pressed item was not a Top Sites GridView item. Superclass
+ // can handle this.
+ super.onCreateContextMenu(menu, view, menuInfo);
+
+ if (!Restrictions.isAllowed(view.getContext(), Restrictable.CLEAR_HISTORY)) {
+ menu.findItem(R.id.home_remove).setVisible(false);
+ }
+
+ return;
+ }
+
+ final Context context = view.getContext();
+
+ // Long pressed item was a Top Sites GridView item, handle it.
+ MenuInflater inflater = new MenuInflater(context);
+ inflater.inflate(R.menu.home_contextmenu, menu);
+
+ // Hide unused menu items.
+ menu.findItem(R.id.home_edit_bookmark).setVisible(false);
+
+ menu.findItem(R.id.home_remove).setVisible(Restrictions.isAllowed(context, Restrictable.CLEAR_HISTORY));
+
+ TopSitesGridContextMenuInfo info = (TopSitesGridContextMenuInfo) menuInfo;
+ menu.setHeaderTitle(info.getDisplayTitle());
+
+ if (info.type != TopSites.TYPE_BLANK) {
+ if (info.type == TopSites.TYPE_PINNED) {
+ menu.findItem(R.id.top_sites_pin).setVisible(false);
+ } else {
+ menu.findItem(R.id.top_sites_unpin).setVisible(false);
+ }
+ } else {
+ menu.findItem(R.id.home_open_new_tab).setVisible(false);
+ menu.findItem(R.id.home_open_private_tab).setVisible(false);
+ menu.findItem(R.id.top_sites_pin).setVisible(false);
+ menu.findItem(R.id.top_sites_unpin).setVisible(false);
+ }
+
+ if (!StringUtils.isShareableUrl(info.url) || GeckoProfile.get(getActivity()).inGuestMode()) {
+ menu.findItem(R.id.home_share).setVisible(false);
+ }
+
+ if (!Restrictions.isAllowed(context, Restrictable.PRIVATE_BROWSING)) {
+ menu.findItem(R.id.home_open_private_tab).setVisible(false);
+ }
+ }
+
+ @Override
+ public boolean onContextItemSelected(MenuItem item) {
+ if (super.onContextItemSelected(item)) {
+ // HomeFragment was able to handle to selected item.
+ return true;
+ }
+
+ ContextMenuInfo menuInfo = item.getMenuInfo();
+
+ if (!(menuInfo instanceof TopSitesGridContextMenuInfo)) {
+ return false;
+ }
+
+ TopSitesGridContextMenuInfo info = (TopSitesGridContextMenuInfo) menuInfo;
+
+ final int itemId = item.getItemId();
+ final BrowserDB db = BrowserDB.from(getActivity());
+
+ if (itemId == R.id.top_sites_pin) {
+ final String url = info.url;
+ final String title = info.title;
+ final int position = info.position;
+ final Context context = getActivity().getApplicationContext();
+
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ db.pinSite(context.getContentResolver(), url, title, position);
+ }
+ });
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.PIN);
+ return true;
+ }
+
+ if (itemId == R.id.top_sites_unpin) {
+ final int position = info.position;
+ final Context context = getActivity().getApplicationContext();
+
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ db.unpinSite(context.getContentResolver(), position);
+ }
+ });
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.UNPIN);
+
+ return true;
+ }
+
+ if (itemId == R.id.top_sites_edit) {
+ // Decode "user-entered" URLs before showing them.
+ mEditPinnedSiteListener.onEditPinnedSite(info.position,
+ StringUtils.decodeUserEnteredUrl(info.url));
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.EDIT);
+ return true;
+ }
+
+ return false;
+ }
+
+ @Override
+ protected void load() {
+ getLoaderManager().initLoader(LOADER_ID_TOP_SITES, null, mCursorLoaderCallbacks);
+
+ // Since this is the primary fragment that loads whenever about:home is
+ // visited, we want to load it as quickly as possible. Heavy load on
+ // the Gecko thread can slow down the time it takes for thumbnails to
+ // appear, especially during startup (bug 897162). By minimizing the
+ // Gecko thread priority, we ensure that the UI appears quickly. The
+ // priority is reset to normal once thumbnails are loaded.
+ ThreadUtils.reduceGeckoPriority(PRIORITY_RESET_TIMEOUT);
+ }
+
+ /**
+ * Listener for editing pinned sites.
+ */
+ private class EditPinnedSiteListener implements OnEditPinnedSiteListener,
+ OnSiteSelectedListener {
+ // Tag for the PinSiteDialog fragment.
+ private static final String TAG_PIN_SITE = "pin_site";
+
+ // Position of the pin.
+ private int mPosition;
+
+ @Override
+ public void onEditPinnedSite(int position, String searchTerm) {
+ final FragmentManager manager = getChildFragmentManager();
+ PinSiteDialog dialog = (PinSiteDialog) manager.findFragmentByTag(TAG_PIN_SITE);
+ if (dialog == null) {
+ mPosition = position;
+
+ dialog = PinSiteDialog.newInstance();
+ dialog.setOnSiteSelectedListener(this);
+ dialog.setSearchTerm(searchTerm);
+ dialog.show(manager, TAG_PIN_SITE);
+ }
+ }
+
+ @Override
+ public void onSiteSelected(final String url, final String title) {
+ final int position = mPosition;
+ final Context context = getActivity().getApplicationContext();
+ final BrowserDB db = BrowserDB.from(getActivity());
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ db.pinSite(context.getContentResolver(), url, title, position);
+ }
+ });
+ }
+ }
+
+ private void updateUiFromCursor(Cursor c) {
+ mList.setHeaderDividersEnabled(c != null && c.getCount() > mMaxGridEntries);
+ }
+
+ private void updateUiWithThumbnails(Map<String, ThumbnailInfo> thumbnails) {
+ if (mGridAdapter != null) {
+ mGridAdapter.updateThumbnails(thumbnails);
+ }
+
+ // Once thumbnails have finished loading, the UI is ready. Reset
+ // Gecko to normal priority.
+ ThreadUtils.resetGeckoPriority();
+ }
+
+ private static class TopSitesLoader extends SimpleCursorLoader {
+ // Max number of search results.
+ private static final int SEARCH_LIMIT = 30;
+ private static final String TELEMETRY_HISTOGRAM_LOAD_CURSOR = "FENNEC_TOPSITES_LOADER_TIME_MS";
+ private final BrowserDB mDB;
+ private final int mMaxGridEntries;
+
+ public TopSitesLoader(Context context) {
+ super(context);
+ mMaxGridEntries = context.getResources().getInteger(R.integer.number_of_top_sites);
+ mDB = BrowserDB.from(context);
+ }
+
+ @Override
+ public Cursor loadCursor() {
+ final long start = SystemClock.uptimeMillis();
+ final Cursor cursor = mDB.getTopSites(getContext().getContentResolver(), mMaxGridEntries, SEARCH_LIMIT);
+ final long end = SystemClock.uptimeMillis();
+ final long took = end - start;
+ Telemetry.addToHistogram(TELEMETRY_HISTOGRAM_LOAD_CURSOR, (int) Math.min(took, Integer.MAX_VALUE));
+ return cursor;
+ }
+ }
+
+ private class VisitedAdapter extends CursorAdapter {
+ public VisitedAdapter(Context context, Cursor cursor) {
+ super(context, cursor, 0);
+ }
+
+ @Override
+ public int getCount() {
+ return Math.max(0, super.getCount() - mMaxGridEntries);
+ }
+
+ @Override
+ public Object getItem(int position) {
+ return super.getItem(position + mMaxGridEntries);
+ }
+
+ /**
+ * We have to override default getItemId implementation, since for a given position, it returns
+ * value of the _id column. In our case _id is always 0 (see Combined view).
+ */
+ @Override
+ public long getItemId(int position) {
+ final int adjustedPosition = position + mMaxGridEntries;
+ final Cursor cursor = getCursor();
+
+ cursor.moveToPosition(adjustedPosition);
+ return getItemIdForTopSitesCursor(cursor);
+ }
+
+ @Override
+ public void bindView(View view, Context context, Cursor cursor) {
+ final int position = cursor.getPosition();
+ cursor.moveToPosition(position + mMaxGridEntries);
+
+ final TwoLinePageRow row = (TwoLinePageRow) view;
+ row.updateFromCursor(cursor);
+ }
+
+ @Override
+ public View newView(Context context, Cursor cursor, ViewGroup parent) {
+ return LayoutInflater.from(context).inflate(R.layout.bookmark_item_row, parent, false);
+ }
+ }
+
+ public class TopSitesGridAdapter extends CursorAdapter {
+ private final BrowserDB mDB;
+ // Cache to store the thumbnails.
+ // Ensure that this is only accessed from the UI thread.
+ private Map<String, ThumbnailInfo> mThumbnailInfos;
+
+ public TopSitesGridAdapter(Context context, Cursor cursor) {
+ super(context, cursor, 0);
+ mDB = BrowserDB.from(context);
+ }
+
+ @Override
+ public int getCount() {
+ return Math.min(mMaxGridEntries, super.getCount());
+ }
+
+ @Override
+ protected void onContentChanged() {
+ // Don't do anything. We don't want to regenerate every time
+ // our database is updated.
+ return;
+ }
+
+ /**
+ * Update the thumbnails returned by the db.
+ *
+ * @param thumbnails A map of urls and their thumbnail bitmaps.
+ */
+ public void updateThumbnails(Map<String, ThumbnailInfo> thumbnails) {
+ mThumbnailInfos = thumbnails;
+
+ final int count = mGrid.getChildCount();
+ for (int i = 0; i < count; i++) {
+ TopSitesGridItemView gridItem = (TopSitesGridItemView) mGrid.getChildAt(i);
+
+ // All the views have already got their initial state at this point.
+ // This will force each view to load favicons for the missing
+ // thumbnails if necessary.
+ gridItem.markAsDirty();
+ }
+
+ notifyDataSetChanged();
+ }
+
+ /**
+ * We have to override default getItemId implementation, since for a given position, it returns
+ * value of the _id column. In our case _id is always 0 (see Combined view).
+ */
+ @Override
+ public long getItemId(int position) {
+ final Cursor cursor = getCursor();
+ cursor.moveToPosition(position);
+
+ return getItemIdForTopSitesCursor(cursor);
+ }
+
+ @Override
+ public void bindView(View bindView, Context context, Cursor cursor) {
+ final String url = cursor.getString(cursor.getColumnIndexOrThrow(TopSites.URL));
+ final String title = cursor.getString(cursor.getColumnIndexOrThrow(TopSites.TITLE));
+ final int type = cursor.getInt(cursor.getColumnIndexOrThrow(TopSites.TYPE));
+
+ final TopSitesGridItemView view = (TopSitesGridItemView) bindView;
+
+ // If there is no url, then show "add bookmark".
+ if (type == TopSites.TYPE_BLANK) {
+ view.blankOut();
+ return;
+ }
+
+ // Show the thumbnail, if any.
+ ThumbnailInfo thumbnail = (mThumbnailInfos != null ? mThumbnailInfos.get(url) : null);
+
+ // Debounce bindView calls to avoid redundant redraws and favicon
+ // fetches.
+ final boolean updated = view.updateState(title, url, type, thumbnail);
+
+ // Thumbnails are delivered late, so we can't short-circuit any
+ // sooner than this. But we can avoid a duplicate favicon
+ // fetch...
+ if (!updated) {
+ debug("bindView called twice for same values; short-circuiting.");
+ return;
+ }
+
+ // Make sure we query suggested images without the user-entered wrapper.
+ final String decodedUrl = StringUtils.decodeUserEnteredUrl(url);
+
+ // Suggested images have precedence over thumbnails, no need to wait
+ // for them to be loaded. See: CursorLoaderCallbacks.onLoadFinished()
+ final String imageUrl = mDB.getSuggestedImageUrlForUrl(decodedUrl);
+ if (!TextUtils.isEmpty(imageUrl)) {
+ final int bgColor = mDB.getSuggestedBackgroundColorForUrl(decodedUrl);
+ view.displayThumbnail(imageUrl, bgColor);
+ return;
+ }
+
+ // If thumbnails are still being loaded, don't try to load favicons
+ // just yet. If we sent in a thumbnail, we're done now.
+ if (mThumbnailInfos == null || thumbnail != null) {
+ return;
+ }
+
+ view.loadFavicon(url);
+ }
+
+ @Override
+ public View newView(Context context, Cursor cursor, ViewGroup parent) {
+ return new TopSitesGridItemView(context);
+ }
+ }
+
+ private class CursorLoaderCallbacks implements LoaderCallbacks<Cursor> {
+ @Override
+ public Loader<Cursor> onCreateLoader(int id, Bundle args) {
+ trace("Creating TopSitesLoader: " + id);
+ return new TopSitesLoader(getActivity());
+ }
+
+ /**
+ * This method is called *twice* in some circumstances.
+ *
+ * If you try to avoid that through some kind of boolean flag,
+ * sometimes (e.g., returning to the activity) you'll *not* be called
+ * twice, and thus you'll never draw thumbnails.
+ *
+ * The root cause is TopSitesLoader.loadCursor being called twice.
+ * Why that is... dunno.
+ */
+ public void onLoadFinished(Loader<Cursor> loader, Cursor c) {
+ debug("onLoadFinished: " + c.getCount() + " rows.");
+
+ mListAdapter.swapCursor(c);
+ mGridAdapter.swapCursor(c);
+ updateUiFromCursor(c);
+
+ final int col = c.getColumnIndexOrThrow(TopSites.URL);
+
+ // Load the thumbnails.
+ // Even though the cursor we're given is supposed to be fresh,
+ // we getIcon a bad first value unless we reset its position.
+ // Using move(-1) and moveToNext() doesn't work correctly under
+ // rotation, so we use moveToFirst.
+ if (!c.moveToFirst()) {
+ return;
+ }
+
+ final ArrayList<String> urls = new ArrayList<String>();
+ int i = 1;
+ do {
+ final String url = c.getString(col);
+
+ // Only try to fetch thumbnails for non-empty URLs that
+ // don't have an associated suggested image URL.
+ final GeckoProfile profile = GeckoProfile.get(getActivity());
+ if (TextUtils.isEmpty(url) || BrowserDB.from(profile).hasSuggestedImageUrl(url)) {
+ continue;
+ }
+
+ urls.add(url);
+ } while (i++ < mMaxGridEntries && c.moveToNext());
+
+ if (urls.isEmpty()) {
+ // Short-circuit empty results to the UI.
+ updateUiWithThumbnails(new HashMap<String, ThumbnailInfo>());
+ return;
+ }
+
+ Bundle bundle = new Bundle();
+ bundle.putStringArrayList(THUMBNAILS_URLS_KEY, urls);
+ getLoaderManager().restartLoader(LOADER_ID_THUMBNAILS, bundle, mThumbnailsLoaderCallbacks);
+ }
+
+ @Override
+ public void onLoaderReset(Loader<Cursor> loader) {
+ if (mListAdapter != null) {
+ mListAdapter.swapCursor(null);
+ }
+
+ if (mGridAdapter != null) {
+ mGridAdapter.swapCursor(null);
+ }
+ }
+ }
+
+ static class ThumbnailInfo {
+ public final Bitmap bitmap;
+ public final String imageUrl;
+ public final int bgColor;
+
+ public ThumbnailInfo(final Bitmap bitmap) {
+ this.bitmap = bitmap;
+ this.imageUrl = null;
+ this.bgColor = Color.TRANSPARENT;
+ }
+
+ public ThumbnailInfo(final String imageUrl, final int bgColor) {
+ this.bitmap = null;
+ this.imageUrl = imageUrl;
+ this.bgColor = bgColor;
+ }
+
+ public static ThumbnailInfo fromMetadata(final Map<String, Object> data) {
+ if (data == null) {
+ return null;
+ }
+
+ final String imageUrl = (String) data.get(TILE_IMAGE_URL_COLUMN);
+ if (imageUrl == null) {
+ return null;
+ }
+
+ int bgColor = Color.WHITE;
+ final String colorString = (String) data.get(TILE_COLOR_COLUMN);
+ try {
+ bgColor = Color.parseColor(colorString);
+ } catch (Exception ex) {
+ }
+
+ return new ThumbnailInfo(imageUrl, bgColor);
+ }
+ }
+
+ /**
+ * An AsyncTaskLoader to load the thumbnails from a cursor.
+ */
+ static class ThumbnailsLoader extends AsyncTaskLoader<Map<String, ThumbnailInfo>> {
+ private final BrowserDB mDB;
+ private Map<String, ThumbnailInfo> mThumbnailInfos;
+ private final ArrayList<String> mUrls;
+
+ private static final List<String> COLUMNS;
+ static {
+ final ArrayList<String> tempColumns = new ArrayList<>(2);
+ tempColumns.add(TILE_IMAGE_URL_COLUMN);
+ tempColumns.add(TILE_COLOR_COLUMN);
+ COLUMNS = Collections.unmodifiableList(tempColumns);
+ }
+
+ public ThumbnailsLoader(Context context, ArrayList<String> urls) {
+ super(context);
+ mUrls = urls;
+ mDB = BrowserDB.from(context);
+ }
+
+ @Override
+ public Map<String, ThumbnailInfo> loadInBackground() {
+ final Map<String, ThumbnailInfo> thumbnails = new HashMap<String, ThumbnailInfo>();
+ if (mUrls == null || mUrls.size() == 0) {
+ return thumbnails;
+ }
+
+ // We need to query metadata based on the URL without any refs, hence we create a new
+ // mapping and list of these URLs (we need to preserve the original URL for display purposes)
+ final Map<String, String> queryURLs = new HashMap<>();
+ for (final String pageURL : mUrls) {
+ queryURLs.put(pageURL, StringUtils.stripRef(pageURL));
+ }
+
+ // Query the DB for tile images.
+ final ContentResolver cr = getContext().getContentResolver();
+ // Use the stripped URLs for querying the DB
+ final Map<String, Map<String, Object>> metadata = mDB.getURLMetadata().getForURLs(cr, queryURLs.values(), COLUMNS);
+
+ // Keep a list of urls that don't have tiles images. We'll use thumbnails for them instead.
+ final List<String> thumbnailUrls = new ArrayList<String>();
+ for (final String pageURL : mUrls) {
+ final String queryURL = queryURLs.get(pageURL);
+
+ ThumbnailInfo info = ThumbnailInfo.fromMetadata(metadata.get(queryURL));
+ if (info == null) {
+ // If we didn't find metadata, we'll look for a thumbnail for this url.
+ thumbnailUrls.add(pageURL);
+ continue;
+ }
+
+ thumbnails.put(pageURL, info);
+ }
+
+ if (thumbnailUrls.size() == 0) {
+ return thumbnails;
+ }
+
+ // Query the DB for tile thumbnails.
+ final Cursor cursor = mDB.getThumbnailsForUrls(cr, thumbnailUrls);
+ if (cursor == null) {
+ return thumbnails;
+ }
+
+ try {
+ final int urlIndex = cursor.getColumnIndexOrThrow(Thumbnails.URL);
+ final int dataIndex = cursor.getColumnIndexOrThrow(Thumbnails.DATA);
+
+ while (cursor.moveToNext()) {
+ String url = cursor.getString(urlIndex);
+
+ // This should never be null, but if it is...
+ final byte[] b = cursor.getBlob(dataIndex);
+ if (b == null) {
+ continue;
+ }
+
+ final Bitmap bitmap = BitmapUtils.decodeByteArray(b);
+
+ // Our thumbnails are never null, so if we getIcon a null decoded
+ // bitmap, it's because we hit an OOM or some other disaster.
+ // Give up immediately rather than hammering on.
+ if (bitmap == null) {
+ Log.w(LOGTAG, "Aborting thumbnail load; decode failed.");
+ break;
+ }
+
+ thumbnails.put(url, new ThumbnailInfo(bitmap));
+ }
+ } finally {
+ cursor.close();
+ }
+
+ return thumbnails;
+ }
+
+ @Override
+ public void deliverResult(Map<String, ThumbnailInfo> thumbnails) {
+ if (isReset()) {
+ mThumbnailInfos = null;
+ return;
+ }
+
+ mThumbnailInfos = thumbnails;
+
+ if (isStarted()) {
+ super.deliverResult(thumbnails);
+ }
+ }
+
+ @Override
+ protected void onStartLoading() {
+ if (mThumbnailInfos != null) {
+ deliverResult(mThumbnailInfos);
+ }
+
+ if (takeContentChanged() || mThumbnailInfos == null) {
+ forceLoad();
+ }
+ }
+
+ @Override
+ protected void onStopLoading() {
+ cancelLoad();
+ }
+
+ @Override
+ public void onCanceled(Map<String, ThumbnailInfo> thumbnails) {
+ mThumbnailInfos = null;
+ }
+
+ @Override
+ protected void onReset() {
+ super.onReset();
+
+ // Ensure the loader is stopped.
+ onStopLoading();
+
+ mThumbnailInfos = null;
+ }
+ }
+
+ /**
+ * Loader callbacks for the thumbnails on TopSitesGridView.
+ */
+ private class ThumbnailsLoaderCallbacks implements LoaderCallbacks<Map<String, ThumbnailInfo>> {
+ @Override
+ public Loader<Map<String, ThumbnailInfo>> onCreateLoader(int id, Bundle args) {
+ return new ThumbnailsLoader(getActivity(), args.getStringArrayList(THUMBNAILS_URLS_KEY));
+ }
+
+ @Override
+ public void onLoadFinished(Loader<Map<String, ThumbnailInfo>> loader, Map<String, ThumbnailInfo> thumbnails) {
+ updateUiWithThumbnails(thumbnails);
+ }
+
+ @Override
+ public void onLoaderReset(Loader<Map<String, ThumbnailInfo>> loader) {
+ if (mGridAdapter != null) {
+ mGridAdapter.updateThumbnails(null);
+ }
+ }
+ }
+
+ /**
+ * We are trying to return stable IDs so that Android can recycle views appropriately:
+ * - If we have a history ID then we return it
+ * - If we only have a bookmark ID then we negate it and return it. We negate it in order
+ * to avoid clashing/conflicting with history IDs.
+ *
+ * @param cursorInPosition Cursor already moved to position for which we're getting a stable ID
+ * @return Stable ID for a given cursor
+ */
+ private static long getItemIdForTopSitesCursor(final Cursor cursorInPosition) {
+ final int historyIdCol = cursorInPosition.getColumnIndexOrThrow(TopSites.HISTORY_ID);
+ final long historyId = cursorInPosition.getLong(historyIdCol);
+ if (historyId != 0) {
+ return historyId;
+ }
+
+ final int bookmarkIdCol = cursorInPosition.getColumnIndexOrThrow(TopSites.BOOKMARK_ID);
+ final long bookmarkId = cursorInPosition.getLong(bookmarkIdCol);
+ return -1 * bookmarkId;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/TopSitesThumbnailView.java b/mobile/android/base/java/org/mozilla/gecko/home/TopSitesThumbnailView.java
new file mode 100644
index 000000000..dd45014b0
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/TopSitesThumbnailView.java
@@ -0,0 +1,102 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import android.support.v4.content.ContextCompat;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.ThumbnailHelper;
+import org.mozilla.gecko.widget.CropImageView;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.PorterDuff.Mode;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+
+/**
+ * A width constrained ImageView to show thumbnails of top and pinned sites.
+ */
+public class TopSitesThumbnailView extends CropImageView {
+ private static final String LOGTAG = "GeckoTopSitesThumbnailView";
+
+ // 27.34% opacity filter for the dominant color.
+ private static final int COLOR_FILTER = 0x46FFFFFF;
+
+ // Default filter color for "Add a bookmark" views.
+ private final int mDefaultColor = ContextCompat.getColor(getContext(), R.color.top_site_default);
+
+ // Stroke width for the border.
+ private final float mStrokeWidth = getResources().getDisplayMetrics().density * 2;
+
+ // Paint for drawing the border.
+ private final Paint mBorderPaint;
+
+ public TopSitesThumbnailView(Context context) {
+ this(context, null);
+
+ // A border will be drawn if needed.
+ setWillNotDraw(false);
+ }
+
+ public TopSitesThumbnailView(Context context, AttributeSet attrs) {
+ this(context, attrs, R.attr.topSitesThumbnailViewStyle);
+ }
+
+ public TopSitesThumbnailView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+
+ // Initialize the border paint.
+ final Resources res = getResources();
+ mBorderPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ mBorderPaint.setColor(ContextCompat.getColor(context, R.color.top_site_border));
+ mBorderPaint.setStyle(Paint.Style.STROKE);
+ }
+
+ @Override
+ protected float getAspectRatio() {
+ return ThumbnailHelper.TOP_SITES_THUMBNAIL_ASPECT_RATIO;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+
+ if (getBackground() == null) {
+ mBorderPaint.setStrokeWidth(mStrokeWidth);
+ canvas.drawRect(0, 0, getWidth(), getHeight(), mBorderPaint);
+ }
+ }
+
+ /**
+ * Sets the background color with a filter to reduce the color opacity.
+ *
+ * @param color the color filter to apply over the drawable.
+ */
+ public void setBackgroundColorWithOpacityFilter(int color) {
+ setBackgroundColor(color & COLOR_FILTER);
+ }
+
+ /**
+ * Sets the background to a Drawable by applying the specified color as a filter.
+ *
+ * @param color the color filter to apply over the drawable.
+ */
+ @Override
+ public void setBackgroundColor(int color) {
+ if (color == 0) {
+ color = mDefaultColor;
+ }
+
+ Drawable drawable = getResources().getDrawable(R.drawable.top_sites_thumbnail_bg);
+ drawable.setColorFilter(color, Mode.SRC_ATOP);
+ setBackgroundDrawable(drawable);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/TwoLinePageRow.java b/mobile/android/base/java/org/mozilla/gecko/home/TwoLinePageRow.java
new file mode 100644
index 000000000..68eb8daa5
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/TwoLinePageRow.java
@@ -0,0 +1,324 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home;
+
+import java.lang.ref.WeakReference;
+import java.util.concurrent.Future;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import org.mozilla.gecko.AboutPages;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.Tabs;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserContract.Combined;
+import org.mozilla.gecko.db.BrowserContract.URLColumns;
+import org.mozilla.gecko.distribution.PartnerBookmarksProviderProxy;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.reader.ReaderModeUtils;
+import org.mozilla.gecko.reader.SavedReaderViewHelper;
+import org.mozilla.gecko.widget.FaviconView;
+
+public class TwoLinePageRow extends LinearLayout
+ implements Tabs.OnTabsChangedListener {
+
+ protected static final int NO_ICON = 0;
+
+ private final TextView mTitle;
+ private final TextView mUrl;
+ private final ImageView mStatusIcon;
+
+ private int mSwitchToTabIconId;
+
+ private final FaviconView mFavicon;
+ private Future<IconResponse> mOngoingIconLoad;
+
+ private boolean mShowIcons;
+
+ // The URL for the page corresponding to this view.
+ private String mPageUrl;
+
+ private boolean mHasReaderCacheItem;
+
+ public TwoLinePageRow(Context context) {
+ this(context, null);
+ }
+
+ public TwoLinePageRow(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ setGravity(Gravity.CENTER_VERTICAL);
+
+ LayoutInflater.from(context).inflate(R.layout.two_line_page_row, this);
+ // Merge layouts lose their padding, so set it dynamically.
+ setPadding(0, 0, (int) getResources().getDimension(R.dimen.page_row_edge_padding), 0);
+
+ mTitle = (TextView) findViewById(R.id.title);
+ mUrl = (TextView) findViewById(R.id.url);
+ mStatusIcon = (ImageView) findViewById(R.id.status_icon_bookmark);
+
+ mSwitchToTabIconId = NO_ICON;
+ mShowIcons = true;
+
+ mFavicon = (FaviconView) findViewById(R.id.icon);
+ }
+
+ @Override
+ protected void onAttachedToWindow() {
+ super.onAttachedToWindow();
+
+ Tabs.registerOnTabsChangedListener(this);
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+
+ // Tabs' listener array is safe to modify during use: its
+ // iteration pattern is based on snapshots.
+ Tabs.unregisterOnTabsChangedListener(this);
+ }
+
+ /**
+ * Update the row in response to a tab change event.
+ * <p>
+ * This method is always invoked on the UI thread.
+ */
+ @Override
+ public void onTabChanged(final Tab tab, final Tabs.TabEvents msg, final String data) {
+ // Carefully check if this tab event is relevant to this row.
+ final String pageUrl = mPageUrl;
+ if (pageUrl == null) {
+ return;
+ }
+ if (tab == null) {
+ return;
+ }
+
+ // Return early if the page URL doesn't match the current tab URL,
+ // or the old tab URL.
+ // data is an empty String for ADDED/CLOSED, and contains the previous/old URL during
+ // LOCATION_CHANGE (the new URL is retrieved using tab.getURL()).
+ // tabURL and data may be about:reader URLs if the current or old tab page was a reader view
+ // page, however pageUrl will always be a plain URL (i.e. we only add about:reader when opening
+ // a reader view bookmark, at all other times it's a normal bookmark with normal URL).
+ final String tabUrl = tab.getURL();
+ if (!pageUrl.equals(ReaderModeUtils.stripAboutReaderUrl(tabUrl)) &&
+ !pageUrl.equals(ReaderModeUtils.stripAboutReaderUrl(data))) {
+ return;
+ }
+
+ // Note: we *might* need to update the display status (i.e. switch-to-tab icon/label) if
+ // a matching tab has been opened/closed/switched to a different page. updateDisplayedUrl() will
+ // determine the changes (if any) that actually need to be made. A tab change with a matching URL
+ // does not imply that any changes are needed - e.g. if a given URL is already open in one tab, and
+ // is also opened in a second tab, the switch-to-tab status doesn't change, closing 1 of 2 tabs with a URL
+ // similarly doesn't change the switch-to-tab display, etc. (However closing the last tab for
+ // a given URL does require a status change, as does opening the first tab with that URL.)
+ switch (msg) {
+ case ADDED:
+ case CLOSED:
+ case LOCATION_CHANGE:
+ updateDisplayedUrl();
+ break;
+ default:
+ break;
+ }
+ }
+
+ private void setTitle(String text) {
+ mTitle.setText(text);
+ }
+
+ protected void setUrl(String text) {
+ mUrl.setText(text);
+ }
+
+ protected void setUrl(int stringId) {
+ mUrl.setText(stringId);
+ }
+
+ protected String getUrl() {
+ return mPageUrl;
+ }
+
+ protected void setSwitchToTabIcon(int iconId) {
+ if (mSwitchToTabIconId == iconId) {
+ return;
+ }
+
+ mSwitchToTabIconId = iconId;
+ mUrl.setCompoundDrawablesWithIntrinsicBounds(mSwitchToTabIconId, 0, 0, 0);
+ }
+
+ private void updateStatusIcon(boolean isBookmark, boolean isReaderItem) {
+ if (isReaderItem) {
+ mStatusIcon.setImageResource(R.drawable.status_icon_readercache);
+ } else if (isBookmark) {
+ mStatusIcon.setImageResource(R.drawable.star_blue);
+ }
+
+ if (mShowIcons && (isBookmark || isReaderItem)) {
+ mStatusIcon.setVisibility(View.VISIBLE);
+ } else if (mShowIcons) {
+ // We use INVISIBLE to have consistent padding for our items. This means text/URLs
+ // fade consistently in the same location, regardless of them being bookmarked.
+ mStatusIcon.setVisibility(View.INVISIBLE);
+ } else {
+ mStatusIcon.setVisibility(View.GONE);
+ }
+
+ }
+
+ /**
+ * Stores the page URL, so that we can use it to replace "Switch to tab" if the open
+ * tab changes or is closed.
+ */
+ private void updateDisplayedUrl(String url, boolean hasReaderCacheItem) {
+ mPageUrl = url;
+ mHasReaderCacheItem = hasReaderCacheItem;
+ updateDisplayedUrl();
+ }
+
+ /**
+ * Replaces the page URL with "Switch to tab" if there is already a tab open with that URL.
+ * Only looks for tabs that are either private or non-private, depending on the current
+ * selected tab.
+ */
+ protected void updateDisplayedUrl() {
+ final Tab selectedTab = Tabs.getInstance().getSelectedTab();
+ final boolean isPrivate = (selectedTab != null) && (selectedTab.isPrivate());
+
+ // We always want to display the underlying page url, however for readermode pages
+ // we navigate to the about:reader equivalent, hence we need to use that url when finding
+ // existing tabs
+ final String navigationUrl = mHasReaderCacheItem ? ReaderModeUtils.getAboutReaderForUrl(mPageUrl) : mPageUrl;
+ Tab tab = Tabs.getInstance().getFirstTabForUrl(navigationUrl, isPrivate);
+
+
+ if (!mShowIcons || tab == null) {
+ setUrl(mPageUrl);
+ setSwitchToTabIcon(NO_ICON);
+ } else {
+ setUrl(R.string.switch_to_tab);
+ setSwitchToTabIcon(R.drawable.ic_url_bar_tab);
+ }
+ }
+
+ public void setShowIcons(boolean showIcons) {
+ mShowIcons = showIcons;
+ }
+
+ /**
+ * Update the data displayed by this row.
+ * <p>
+ * This method must be invoked on the UI thread.
+ *
+ * @param title to display.
+ * @param url to display.
+ */
+ public void update(String title, String url) {
+ update(title, url, 0, false);
+ }
+
+ protected void update(String title, String url, long bookmarkId, boolean hasReaderCacheItem) {
+ if (mShowIcons) {
+ // The bookmark id will be 0 (null in database) when the url
+ // is not a bookmark and negative for 'fake' bookmarks.
+ final boolean isBookmark = bookmarkId > 0;
+
+ updateStatusIcon(isBookmark, hasReaderCacheItem);
+ } else {
+ updateStatusIcon(false, false);
+ }
+
+ // Use the URL instead of an empty title for consistency with the normal URL
+ // bar view - this is the equivalent of getDisplayTitle() in Tab.java
+ setTitle(TextUtils.isEmpty(title) ? url : title);
+
+ // No point updating the below things if URL has not changed. Prevents evil Favicon flicker.
+ if (url.equals(mPageUrl)) {
+ return;
+ }
+
+ // Blank the Favicon, so we don't show the wrong Favicon if we scroll and miss DB.
+ mFavicon.clearImage();
+
+ if (mOngoingIconLoad != null) {
+ mOngoingIconLoad.cancel(true);
+ }
+
+ // Displayed RecentTabsPanel URLs may refer to pages opened in reader mode, so we
+ // remove the about:reader prefix to ensure the Favicon loads properly.
+ final String pageURL = ReaderModeUtils.stripAboutReaderUrl(url);
+
+ if (TextUtils.isEmpty(pageURL)) {
+ // If url is empty, display the item as-is but do not load an icon if we do not have a page URL (bug 1310622)
+ } else if (bookmarkId < BrowserContract.Bookmarks.FAKE_PARTNER_BOOKMARKS_START) {
+ mOngoingIconLoad = Icons.with(getContext())
+ .pageUrl(pageURL)
+ .skipNetwork()
+ .privileged(true)
+ .icon(IconDescriptor.createGenericIcon(
+ PartnerBookmarksProviderProxy.getUriForIcon(getContext(), bookmarkId).toString()))
+ .build()
+ .execute(mFavicon.createIconCallback());
+ } else {
+ mOngoingIconLoad = Icons.with(getContext())
+ .pageUrl(pageURL)
+ .skipNetwork()
+ .build()
+ .execute(mFavicon.createIconCallback());
+
+ }
+
+ updateDisplayedUrl(url, hasReaderCacheItem);
+ }
+
+ /**
+ * Update the data displayed by this row.
+ * <p>
+ * This method must be invoked on the UI thread.
+ *
+ * @param cursor to extract data from.
+ */
+ public void updateFromCursor(Cursor cursor) {
+ if (cursor == null) {
+ return;
+ }
+
+ int titleIndex = cursor.getColumnIndexOrThrow(URLColumns.TITLE);
+ final String title = cursor.getString(titleIndex);
+
+ int urlIndex = cursor.getColumnIndexOrThrow(URLColumns.URL);
+ final String url = cursor.getString(urlIndex);
+
+ final long bookmarkId;
+ final int bookmarkIdIndex = cursor.getColumnIndex(Combined.BOOKMARK_ID);
+ if (bookmarkIdIndex != -1) {
+ bookmarkId = cursor.getLong(bookmarkIdIndex);
+ } else {
+ bookmarkId = 0;
+ }
+
+ SavedReaderViewHelper rch = SavedReaderViewHelper.getSavedReaderViewHelper(getContext());
+ final boolean hasReaderCacheItem = rch.isURLCached(url);
+
+ update(title, url, bookmarkId, hasReaderCacheItem);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStream.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStream.java
new file mode 100644
index 000000000..ef0c105d3
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStream.java
@@ -0,0 +1,145 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home.activitystream;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.os.Bundle;
+import android.support.v4.app.LoaderManager;
+import android.support.v4.content.ContextCompat;
+import android.support.v4.content.Loader;
+import android.support.v4.graphics.ColorUtils;
+import android.support.v7.widget.LinearLayoutManager;
+import android.support.v7.widget.RecyclerView;
+import android.util.AttributeSet;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.util.TypedValue;
+import android.widget.FrameLayout;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.home.HomePager;
+import org.mozilla.gecko.home.activitystream.topsites.TopSitesPagerAdapter;
+import org.mozilla.gecko.util.ContextUtils;
+import org.mozilla.gecko.widget.RecyclerViewClickSupport;
+
+public class ActivityStream extends FrameLayout {
+ private final StreamRecyclerAdapter adapter;
+
+ private static final int LOADER_ID_HIGHLIGHTS = 0;
+ private static final int LOADER_ID_TOPSITES = 1;
+
+ private static final int MINIMUM_TILES = 4;
+ private static final int MAXIMUM_TILES = 6;
+
+ private int desiredTileWidth;
+ private int desiredTilesHeight;
+ private int tileMargin;
+
+ public ActivityStream(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ setBackgroundColor(ContextCompat.getColor(context, R.color.about_page_header_grey));
+
+ inflate(context, R.layout.as_content, this);
+
+ adapter = new StreamRecyclerAdapter();
+
+ RecyclerView rv = (RecyclerView) findViewById(R.id.activity_stream_main_recyclerview);
+
+ rv.setAdapter(adapter);
+ rv.setLayoutManager(new LinearLayoutManager(getContext()));
+ rv.setHasFixedSize(true);
+
+ RecyclerViewClickSupport.addTo(rv)
+ .setOnItemClickListener(adapter);
+
+ final Resources resources = getResources();
+ desiredTileWidth = resources.getDimensionPixelSize(R.dimen.activity_stream_desired_tile_width);
+ desiredTilesHeight = resources.getDimensionPixelSize(R.dimen.activity_stream_desired_tile_height);
+ tileMargin = resources.getDimensionPixelSize(R.dimen.activity_stream_base_margin);
+ }
+
+ void setOnUrlOpenListeners(HomePager.OnUrlOpenListener onUrlOpenListener, HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener) {
+ adapter.setOnUrlOpenListeners(onUrlOpenListener, onUrlOpenInBackgroundListener);
+ }
+
+ public void load(LoaderManager lm) {
+ CursorLoaderCallbacks callbacks = new CursorLoaderCallbacks();
+
+ lm.initLoader(LOADER_ID_HIGHLIGHTS, null, callbacks);
+ lm.initLoader(LOADER_ID_TOPSITES, null, callbacks);
+ }
+
+ public void unload() {
+ adapter.swapHighlightsCursor(null);
+ adapter.swapTopSitesCursor(null);
+ }
+
+ @Override
+ protected void onSizeChanged(int w, int h, int oldw, int oldh) {
+ super.onSizeChanged(w, h, oldw, oldh);
+
+ int tiles = (w - tileMargin) / (desiredTileWidth + tileMargin);
+
+ if (tiles < MINIMUM_TILES) {
+ tiles = MINIMUM_TILES;
+
+ setPadding(0, 0, 0, 0);
+ } else if (tiles > MAXIMUM_TILES) {
+ tiles = MAXIMUM_TILES;
+
+ // Use the remaining space as padding
+ int needed = tiles * (desiredTileWidth + tileMargin) + tileMargin;
+ int padding = (w - needed) / 2;
+ w = needed;
+
+ setPadding(padding, 0, padding, 0);
+ } else {
+ setPadding(0, 0, 0, 0);
+ }
+
+ final float ratio = (float) desiredTilesHeight / (float) desiredTileWidth;
+ final int tilesWidth = (w - (tiles * tileMargin) - tileMargin) / tiles;
+ final int tilesHeight = (int) (ratio * tilesWidth);
+
+ adapter.setTileSize(tiles, tilesWidth, tilesHeight);
+ }
+
+ private class CursorLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> {
+ @Override
+ public Loader<Cursor> onCreateLoader(int id, Bundle args) {
+ final Context context = getContext();
+ if (id == LOADER_ID_HIGHLIGHTS) {
+ return BrowserDB.from(context).getHighlights(context, 10);
+ } else if (id == LOADER_ID_TOPSITES) {
+ return BrowserDB.from(context).getActivityStreamTopSites(
+ context, TopSitesPagerAdapter.PAGES * MAXIMUM_TILES);
+ } else {
+ throw new IllegalArgumentException("Can't handle loader id " + id);
+ }
+ }
+
+ @Override
+ public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
+ if (loader.getId() == LOADER_ID_HIGHLIGHTS) {
+ adapter.swapHighlightsCursor(data);
+ } else if (loader.getId() == LOADER_ID_TOPSITES) {
+ adapter.swapTopSitesCursor(data);
+ }
+ }
+
+ @Override
+ public void onLoaderReset(Loader<Cursor> loader) {
+ if (loader.getId() == LOADER_ID_HIGHLIGHTS) {
+ adapter.swapHighlightsCursor(null);
+ } else if (loader.getId() == LOADER_ID_TOPSITES) {
+ adapter.swapTopSitesCursor(null);
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStreamHomeFragment.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStreamHomeFragment.java
new file mode 100644
index 000000000..09f6705d7
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStreamHomeFragment.java
@@ -0,0 +1,39 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home.activitystream;
+
+import android.os.Bundle;
+import android.support.annotation.Nullable;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.home.HomeFragment;
+
+/**
+ * Simple wrapper around the ActivityStream view that allows embedding as a HomePager panel.
+ */
+public class ActivityStreamHomeFragment
+ extends HomeFragment {
+ private ActivityStream activityStream;
+
+ @Override
+ protected void load() {
+ activityStream.load(getLoaderManager());
+ }
+
+ @Nullable
+ @Override
+ public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ if (activityStream == null) {
+ activityStream = (ActivityStream) inflater.inflate(R.layout.activity_stream, container, false);
+ activityStream.setOnUrlOpenListeners(mUrlOpenListener, mUrlOpenInBackgroundListener);
+ }
+
+ return activityStream;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStreamHomeScreen.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStreamHomeScreen.java
new file mode 100644
index 000000000..4decc8218
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStreamHomeScreen.java
@@ -0,0 +1,73 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home.activitystream;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.support.v4.app.FragmentManager;
+import android.support.v4.app.LoaderManager;
+import android.util.AttributeSet;
+
+import org.mozilla.gecko.animation.PropertyAnimator;
+import org.mozilla.gecko.home.HomeBanner;
+import org.mozilla.gecko.home.HomeFragment;
+import org.mozilla.gecko.home.HomeScreen;
+
+/**
+ * HomeScreen implementation that displays ActivityStream.
+ */
+public class ActivityStreamHomeScreen
+ extends ActivityStream
+ implements HomeScreen {
+
+ private boolean visible = false;
+
+ public ActivityStreamHomeScreen(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ public boolean isVisible() {
+ return visible;
+ }
+
+ @Override
+ public void onToolbarFocusChange(boolean hasFocus) {
+
+ }
+
+ @Override
+ public void showPanel(String panelId, Bundle restoreData) {
+
+ }
+
+ @Override
+ public void setOnPanelChangeListener(OnPanelChangeListener listener) {
+
+ }
+
+ @Override
+ public void setPanelStateChangeListener(HomeFragment.PanelStateChangeListener listener) {
+
+ }
+
+ @Override
+ public void setBanner(HomeBanner banner) {
+
+ }
+
+ @Override
+ public void load(LoaderManager lm, FragmentManager fm, String panelId, Bundle restoreData,
+ PropertyAnimator animator) {
+ super.load(lm);
+ visible = true;
+ }
+
+ @Override
+ public void unload() {
+ super.unload();
+ visible = false;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamItem.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamItem.java
new file mode 100644
index 000000000..24348dfe0
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamItem.java
@@ -0,0 +1,196 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home.activitystream;
+
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.graphics.Color;
+import android.support.v4.view.ViewPager;
+import android.support.v7.widget.RecyclerView;
+import android.text.TextUtils;
+import android.text.format.DateUtils;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.activitystream.ActivityStream.LabelCallback;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.home.HomePager;
+import org.mozilla.gecko.home.activitystream.menu.ActivityStreamContextMenu;
+import org.mozilla.gecko.home.activitystream.topsites.CirclePageIndicator;
+import org.mozilla.gecko.home.activitystream.topsites.TopSitesPagerAdapter;
+import org.mozilla.gecko.icons.IconCallback;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.util.DrawableUtil;
+import org.mozilla.gecko.util.ViewUtil;
+import org.mozilla.gecko.util.TouchTargetUtil;
+import org.mozilla.gecko.widget.FaviconView;
+
+import java.util.concurrent.Future;
+
+import static org.mozilla.gecko.activitystream.ActivityStream.extractLabel;
+
+public abstract class StreamItem extends RecyclerView.ViewHolder {
+ public StreamItem(View itemView) {
+ super(itemView);
+ }
+
+ public static class HighlightsTitle extends StreamItem {
+ public static final int LAYOUT_ID = R.layout.activity_stream_main_highlightstitle;
+
+ public HighlightsTitle(View itemView) {
+ super(itemView);
+ }
+ }
+
+ public static class TopPanel extends StreamItem {
+ public static final int LAYOUT_ID = R.layout.activity_stream_main_toppanel;
+
+ private final ViewPager topSitesPager;
+
+ public TopPanel(View itemView, HomePager.OnUrlOpenListener onUrlOpenListener, HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener) {
+ super(itemView);
+
+ topSitesPager = (ViewPager) itemView.findViewById(R.id.topsites_pager);
+ topSitesPager.setAdapter(new TopSitesPagerAdapter(itemView.getContext(), onUrlOpenListener, onUrlOpenInBackgroundListener));
+
+ CirclePageIndicator indicator = (CirclePageIndicator) itemView.findViewById(R.id.topsites_indicator);
+ indicator.setViewPager(topSitesPager);
+ }
+
+ public void bind(Cursor cursor, int tiles, int tilesWidth, int tilesHeight) {
+ final TopSitesPagerAdapter adapter = (TopSitesPagerAdapter) topSitesPager.getAdapter();
+ adapter.setTilesSize(tiles, tilesWidth, tilesHeight);
+ adapter.swapCursor(cursor);
+
+ final Resources resources = itemView.getResources();
+ final int tilesMargin = resources.getDimensionPixelSize(R.dimen.activity_stream_base_margin);
+ final int textHeight = resources.getDimensionPixelSize(R.dimen.activity_stream_top_sites_text_height);
+
+ ViewGroup.LayoutParams layoutParams = topSitesPager.getLayoutParams();
+ layoutParams.height = tilesHeight + tilesMargin + textHeight;
+ topSitesPager.setLayoutParams(layoutParams);
+ }
+ }
+
+ public static class HighlightItem extends StreamItem implements IconCallback {
+ public static final int LAYOUT_ID = R.layout.activity_stream_card_history_item;
+
+ String title;
+ String url;
+
+ final FaviconView vIconView;
+ final TextView vLabel;
+ final TextView vTimeSince;
+ final TextView vSourceView;
+ final TextView vPageView;
+ final ImageView vSourceIconView;
+
+ private Future<IconResponse> ongoingIconLoad;
+ private int tilesMargin;
+
+ public HighlightItem(final View itemView,
+ final HomePager.OnUrlOpenListener onUrlOpenListener,
+ final HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener) {
+ super(itemView);
+
+ tilesMargin = itemView.getResources().getDimensionPixelSize(R.dimen.activity_stream_base_margin);
+
+ vLabel = (TextView) itemView.findViewById(R.id.card_history_label);
+ vTimeSince = (TextView) itemView.findViewById(R.id.card_history_time_since);
+ vIconView = (FaviconView) itemView.findViewById(R.id.icon);
+ vSourceView = (TextView) itemView.findViewById(R.id.card_history_source);
+ vPageView = (TextView) itemView.findViewById(R.id.page);
+ vSourceIconView = (ImageView) itemView.findViewById(R.id.source_icon);
+
+ final ImageView menuButton = (ImageView) itemView.findViewById(R.id.menu);
+
+ menuButton.setImageDrawable(
+ DrawableUtil.tintDrawable(menuButton.getContext(), R.drawable.menu, Color.LTGRAY));
+
+ TouchTargetUtil.ensureTargetHitArea(menuButton, itemView);
+
+ menuButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ ActivityStreamContextMenu.show(v.getContext(),
+ menuButton,
+ ActivityStreamContextMenu.MenuMode.HIGHLIGHT,
+ title, url, onUrlOpenListener, onUrlOpenInBackgroundListener,
+ vIconView.getWidth(), vIconView.getHeight());
+ }
+ });
+
+ ViewUtil.enableTouchRipple(menuButton);
+ }
+
+ public void bind(Cursor cursor, int tilesWidth, int tilesHeight) {
+
+ final long time = cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Highlights.DATE));
+ final String ago = DateUtils.getRelativeTimeSpanString(time, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS, 0).toString();
+
+ title = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.History.TITLE));
+ url = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.URL));
+
+ vLabel.setText(title);
+ vTimeSince.setText(ago);
+
+ ViewGroup.LayoutParams layoutParams = vIconView.getLayoutParams();
+ layoutParams.width = tilesWidth - tilesMargin;
+ layoutParams.height = tilesHeight;
+ vIconView.setLayoutParams(layoutParams);
+
+ updateSource(cursor);
+ updatePage(url);
+
+ if (ongoingIconLoad != null) {
+ ongoingIconLoad.cancel(true);
+ }
+
+ ongoingIconLoad = Icons.with(itemView.getContext())
+ .pageUrl(url)
+ .skipNetwork()
+ .build()
+ .execute(this);
+ }
+
+ private void updateSource(final Cursor cursor) {
+ final boolean isBookmark = -1 != cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Combined.BOOKMARK_ID));
+ final boolean isHistory = -1 != cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Combined.HISTORY_ID));
+
+ if (isBookmark) {
+ vSourceView.setText(R.string.activity_stream_highlight_label_bookmarked);
+ vSourceView.setVisibility(View.VISIBLE);
+ vSourceIconView.setImageResource(R.drawable.ic_as_bookmarked);
+ } else if (isHistory) {
+ vSourceView.setText(R.string.activity_stream_highlight_label_visited);
+ vSourceView.setVisibility(View.VISIBLE);
+ vSourceIconView.setImageResource(R.drawable.ic_as_visited);
+ } else {
+ vSourceView.setVisibility(View.INVISIBLE);
+ vSourceIconView.setImageResource(0);
+ }
+
+ vSourceView.setText(vSourceView.getText());
+ }
+
+ private void updatePage(final String url) {
+ extractLabel(itemView.getContext(), url, false, new LabelCallback() {
+ @Override
+ public void onLabelExtracted(String label) {
+ vPageView.setText(TextUtils.isEmpty(label) ? url : label);
+ }
+ });
+ }
+
+ @Override
+ public void onIconResponse(IconResponse response) {
+ vIconView.updateImage(response);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamRecyclerAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamRecyclerAdapter.java
new file mode 100644
index 000000000..f7cda2e7f
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamRecyclerAdapter.java
@@ -0,0 +1,135 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home.activitystream;
+
+import android.database.Cursor;
+import android.support.v7.widget.RecyclerView;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.home.HomePager;
+import org.mozilla.gecko.home.activitystream.StreamItem.HighlightItem;
+import org.mozilla.gecko.home.activitystream.StreamItem.TopPanel;
+import org.mozilla.gecko.widget.RecyclerViewClickSupport;
+
+import java.util.EnumSet;
+
+public class StreamRecyclerAdapter extends RecyclerView.Adapter<StreamItem> implements RecyclerViewClickSupport.OnItemClickListener {
+ private Cursor highlightsCursor;
+ private Cursor topSitesCursor;
+
+ private HomePager.OnUrlOpenListener onUrlOpenListener;
+ private HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener;
+
+ private int tiles;
+ private int tilesWidth;
+ private int tilesHeight;
+
+ void setOnUrlOpenListeners(HomePager.OnUrlOpenListener onUrlOpenListener, HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener) {
+ this.onUrlOpenListener = onUrlOpenListener;
+ this.onUrlOpenInBackgroundListener = onUrlOpenInBackgroundListener;
+ }
+
+ public void setTileSize(int tiles, int tilesWidth, int tilesHeight) {
+ this.tilesWidth = tilesWidth;
+ this.tilesHeight = tilesHeight;
+ this.tiles = tiles;
+
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ if (position == 0) {
+ return TopPanel.LAYOUT_ID;
+ } else if (position == 1) {
+ return StreamItem.HighlightsTitle.LAYOUT_ID;
+ } else {
+ return HighlightItem.LAYOUT_ID;
+ }
+ }
+
+ @Override
+ public StreamItem onCreateViewHolder(ViewGroup parent, final int type) {
+ final LayoutInflater inflater = LayoutInflater.from(parent.getContext());
+
+ if (type == TopPanel.LAYOUT_ID) {
+ return new TopPanel(inflater.inflate(type, parent, false), onUrlOpenListener, onUrlOpenInBackgroundListener);
+ } else if (type == StreamItem.HighlightsTitle.LAYOUT_ID) {
+ return new StreamItem.HighlightsTitle(inflater.inflate(type, parent, false));
+ } else if (type == HighlightItem.LAYOUT_ID) {
+ return new HighlightItem(inflater.inflate(type, parent, false), onUrlOpenListener, onUrlOpenInBackgroundListener);
+ } else {
+ throw new IllegalStateException("Missing inflation for ViewType " + type);
+ }
+ }
+
+ private int translatePositionToCursor(int position) {
+ if (position == 0) {
+ throw new IllegalArgumentException("Requested cursor position for invalid item");
+ }
+
+ // We have two blank panels at the top, hence remove that to obtain the cursor position
+ return position - 2;
+ }
+
+ @Override
+ public void onBindViewHolder(StreamItem holder, int position) {
+ int type = getItemViewType(position);
+
+ if (type == HighlightItem.LAYOUT_ID) {
+ final int cursorPosition = translatePositionToCursor(position);
+
+ highlightsCursor.moveToPosition(cursorPosition);
+ ((HighlightItem) holder).bind(highlightsCursor, tilesWidth, tilesHeight);
+ } else if (type == TopPanel.LAYOUT_ID) {
+ ((TopPanel) holder).bind(topSitesCursor, tiles, tilesWidth, tilesHeight);
+ }
+ }
+
+ @Override
+ public void onItemClicked(RecyclerView recyclerView, int position, View v) {
+ if (position < 1) {
+ // The header contains top sites and has its own click handling.
+ return;
+ }
+
+ highlightsCursor.moveToPosition(
+ translatePositionToCursor(position));
+
+ final String url = highlightsCursor.getString(
+ highlightsCursor.getColumnIndexOrThrow(BrowserContract.Combined.URL));
+
+ onUrlOpenListener.onUrlOpen(url, EnumSet.of(HomePager.OnUrlOpenListener.Flags.ALLOW_SWITCH_TO_TAB));
+ }
+
+ @Override
+ public int getItemCount() {
+ final int highlightsCount;
+
+ if (highlightsCursor != null) {
+ highlightsCount = highlightsCursor.getCount();
+ } else {
+ highlightsCount = 0;
+ }
+
+ return highlightsCount + 2;
+ }
+
+ public void swapHighlightsCursor(Cursor cursor) {
+ highlightsCursor = cursor;
+
+ notifyDataSetChanged();
+ }
+
+ public void swapTopSitesCursor(Cursor cursor) {
+ this.topSitesCursor = cursor;
+
+ notifyItemChanged(0);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/ActivityStreamContextMenu.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/ActivityStreamContextMenu.java
new file mode 100644
index 000000000..525d3b426
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/ActivityStreamContextMenu.java
@@ -0,0 +1,239 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home.activitystream.menu;
+
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.support.annotation.NonNull;
+import android.support.design.widget.NavigationView;
+import android.view.MenuItem;
+import android.view.View;
+
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.IntentHelper;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.home.HomePager;
+import org.mozilla.gecko.util.Clipboard;
+import org.mozilla.gecko.util.HardwareUtils;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.util.UIAsyncTask;
+
+import java.util.EnumSet;
+
+@RobocopTarget
+public abstract class ActivityStreamContextMenu
+ implements NavigationView.OnNavigationItemSelectedListener {
+
+ public enum MenuMode {
+ HIGHLIGHT,
+ TOPSITE
+ }
+
+ final Context context;
+
+ final String title;
+ final String url;
+
+ final HomePager.OnUrlOpenListener onUrlOpenListener;
+ final HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener;
+
+ boolean isAlreadyBookmarked; // default false;
+
+ public abstract MenuItem getItemByID(int id);
+
+ public abstract void show();
+
+ public abstract void dismiss();
+
+ final MenuMode mode;
+
+ /* package-private */ ActivityStreamContextMenu(final Context context,
+ final MenuMode mode,
+ final String title, @NonNull final String url,
+ HomePager.OnUrlOpenListener onUrlOpenListener,
+ HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener) {
+ this.context = context;
+
+ this.mode = mode;
+
+ this.title = title;
+ this.url = url;
+ this.onUrlOpenListener = onUrlOpenListener;
+ this.onUrlOpenInBackgroundListener = onUrlOpenInBackgroundListener;
+ }
+
+ /**
+ * Must be called before the menu is shown.
+ * <p/>
+ * Your implementation must be ready to return items from getItemByID() before postInit() is
+ * called, i.e. you should probably inflate your menu items before this call.
+ */
+ protected void postInit() {
+ // Disable "dismiss" for topsites until we have decided on its behaviour for topsites
+ // (currently "dismiss" adds the URL to a highlights-specific blocklist, which the topsites
+ // query has no knowledge of).
+ if (mode == MenuMode.TOPSITE) {
+ final MenuItem dismissItem = getItemByID(R.id.dismiss);
+ dismissItem.setVisible(false);
+ }
+
+ // Disable the bookmark item until we know its bookmark state
+ final MenuItem bookmarkItem = getItemByID(R.id.bookmark);
+ bookmarkItem.setEnabled(false);
+
+ (new UIAsyncTask.WithoutParams<Void>(ThreadUtils.getBackgroundHandler()) {
+ @Override
+ protected Void doInBackground() {
+ isAlreadyBookmarked = BrowserDB.from(context).isBookmark(context.getContentResolver(), url);
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(Void aVoid) {
+ if (isAlreadyBookmarked) {
+ bookmarkItem.setTitle(R.string.bookmark_remove);
+ }
+
+ bookmarkItem.setEnabled(true);
+ }
+ }).execute();
+
+ // Only show the "remove from history" item if a page actually has history
+ final MenuItem deleteHistoryItem = getItemByID(R.id.delete);
+ deleteHistoryItem.setVisible(false);
+
+ (new UIAsyncTask.WithoutParams<Void>(ThreadUtils.getBackgroundHandler()) {
+ boolean hasHistory;
+
+ @Override
+ protected Void doInBackground() {
+ final Cursor cursor = BrowserDB.from(context).getHistoryForURL(context.getContentResolver(), url);
+ try {
+ if (cursor != null &&
+ cursor.getCount() == 1) {
+ hasHistory = true;
+ } else {
+ hasHistory = false;
+ }
+ } finally {
+ cursor.close();
+ }
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(Void aVoid) {
+ if (hasHistory) {
+ deleteHistoryItem.setVisible(true);
+ }
+ }
+ }).execute();
+ }
+
+
+ @Override
+ public boolean onNavigationItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case R.id.share:
+ Telemetry.sendUIEvent(TelemetryContract.Event.SHARE, TelemetryContract.Method.LIST, "menu");
+ IntentHelper.openUriExternal(url, "text/plain", "", "", Intent.ACTION_SEND, title, false);
+ break;
+
+ case R.id.bookmark:
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ final BrowserDB db = BrowserDB.from(context);
+
+ if (isAlreadyBookmarked) {
+ db.removeBookmarksWithURL(context.getContentResolver(), url);
+ } else {
+ db.addBookmark(context.getContentResolver(), title, url);
+ }
+
+ }
+ });
+ break;
+
+ case R.id.copy_url:
+ Clipboard.setText(url);
+ break;
+
+ case R.id.add_homescreen:
+ GeckoAppShell.createShortcut(title, url);
+ break;
+
+ case R.id.open_new_tab:
+ onUrlOpenInBackgroundListener.onUrlOpenInBackground(url, EnumSet.noneOf(HomePager.OnUrlOpenInBackgroundListener.Flags.class));
+ break;
+
+ case R.id.open_new_private_tab:
+ onUrlOpenInBackgroundListener.onUrlOpenInBackground(url, EnumSet.of(HomePager.OnUrlOpenInBackgroundListener.Flags.PRIVATE));
+ break;
+
+ case R.id.dismiss:
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ BrowserDB.from(context)
+ .blockActivityStreamSite(context.getContentResolver(),
+ url);
+ }
+ });
+ break;
+
+ case R.id.delete:
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ BrowserDB.from(context)
+ .removeHistoryEntry(context.getContentResolver(),
+ url);
+ }
+ });
+ break;
+
+ default:
+ throw new IllegalArgumentException("Menu item with ID=" + item.getItemId() + " not handled");
+ }
+
+ dismiss();
+ return true;
+ }
+
+
+ @RobocopTarget
+ public static ActivityStreamContextMenu show(Context context,
+ View anchor,
+ final MenuMode menuMode,
+ final String title, @NonNull final String url,
+ HomePager.OnUrlOpenListener onUrlOpenListener,
+ HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener,
+ final int tilesWidth, final int tilesHeight) {
+ final ActivityStreamContextMenu menu;
+
+ if (!HardwareUtils.isTablet()) {
+ menu = new BottomSheetContextMenu(context,
+ menuMode,
+ title, url,
+ onUrlOpenListener, onUrlOpenInBackgroundListener,
+ tilesWidth, tilesHeight);
+ } else {
+ menu = new PopupContextMenu(context,
+ anchor,
+ menuMode,
+ title, url,
+ onUrlOpenListener, onUrlOpenInBackgroundListener);
+ }
+
+ menu.show();
+ return menu;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/BottomSheetContextMenu.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/BottomSheetContextMenu.java
new file mode 100644
index 000000000..e95867c36
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/BottomSheetContextMenu.java
@@ -0,0 +1,102 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home.activitystream.menu;
+
+import android.content.Context;
+import android.support.annotation.NonNull;
+import android.support.design.widget.BottomSheetBehavior;
+import android.support.design.widget.BottomSheetDialog;
+import android.support.design.widget.NavigationView;
+import android.view.LayoutInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.activitystream.ActivityStream;
+import org.mozilla.gecko.home.HomePager;
+import org.mozilla.gecko.icons.IconCallback;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.widget.FaviconView;
+
+import static org.mozilla.gecko.activitystream.ActivityStream.extractLabel;
+
+/* package-private */ class BottomSheetContextMenu
+ extends ActivityStreamContextMenu {
+
+
+ private final BottomSheetDialog bottomSheetDialog;
+
+ private final NavigationView navigationView;
+
+ public BottomSheetContextMenu(final Context context,
+ final MenuMode mode,
+ final String title, @NonNull final String url,
+ HomePager.OnUrlOpenListener onUrlOpenListener,
+ HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener,
+ final int tilesWidth, final int tilesHeight) {
+
+ super(context,
+ mode,
+ title,
+ url,
+ onUrlOpenListener,
+ onUrlOpenInBackgroundListener);
+
+ final LayoutInflater inflater = LayoutInflater.from(context);
+ final View content = inflater.inflate(R.layout.activity_stream_contextmenu_bottomsheet, null);
+
+ bottomSheetDialog = new BottomSheetDialog(context);
+ bottomSheetDialog.setContentView(content);
+
+ ((TextView) content.findViewById(R.id.title)).setText(title);
+
+ extractLabel(context, url, false, new ActivityStream.LabelCallback() {
+ public void onLabelExtracted(String label) {
+ ((TextView) content.findViewById(R.id.url)).setText(label);
+ }
+ });
+
+ // Copy layouted parameters from the Highlights / TopSites items to ensure consistency
+ final FaviconView faviconView = (FaviconView) content.findViewById(R.id.icon);
+ ViewGroup.LayoutParams layoutParams = faviconView.getLayoutParams();
+ layoutParams.width = tilesWidth;
+ layoutParams.height = tilesHeight;
+ faviconView.setLayoutParams(layoutParams);
+
+ Icons.with(context)
+ .pageUrl(url)
+ .skipNetwork()
+ .build()
+ .execute(new IconCallback() {
+ @Override
+ public void onIconResponse(IconResponse response) {
+ faviconView.updateImage(response);
+ }
+ });
+
+ navigationView = (NavigationView) content.findViewById(R.id.menu);
+ navigationView.setNavigationItemSelectedListener(this);
+
+ super.postInit();
+ }
+
+ @Override
+ public MenuItem getItemByID(int id) {
+ return navigationView.getMenu().findItem(id);
+ }
+
+ @Override
+ public void show() {
+ bottomSheetDialog.show();
+ }
+
+ public void dismiss() {
+ bottomSheetDialog.dismiss();
+ }
+
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/PopupContextMenu.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/PopupContextMenu.java
new file mode 100644
index 000000000..56615937b
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/PopupContextMenu.java
@@ -0,0 +1,76 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home.activitystream.menu;
+
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Color;
+import android.graphics.drawable.ColorDrawable;
+import android.support.annotation.NonNull;
+import android.support.design.widget.NavigationView;
+import android.view.LayoutInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.PopupWindow;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.home.HomePager;
+
+/* package-private */ class PopupContextMenu
+ extends ActivityStreamContextMenu {
+
+ private final PopupWindow popupWindow;
+ private final NavigationView navigationView;
+
+ private final View anchor;
+
+ public PopupContextMenu(final Context context,
+ View anchor,
+ final MenuMode mode,
+ final String title,
+ @NonNull final String url,
+ HomePager.OnUrlOpenListener onUrlOpenListener,
+ HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener) {
+ super(context,
+ mode,
+ title,
+ url,
+ onUrlOpenListener,
+ onUrlOpenInBackgroundListener);
+
+ this.anchor = anchor;
+
+ final LayoutInflater inflater = LayoutInflater.from(context);
+
+ View card = inflater.inflate(R.layout.activity_stream_contextmenu_popupmenu, null);
+ navigationView = (NavigationView) card.findViewById(R.id.menu);
+ navigationView.setNavigationItemSelectedListener(this);
+
+ popupWindow = new PopupWindow(context);
+ popupWindow.setContentView(card);
+ popupWindow.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
+ popupWindow.setFocusable(true);
+
+ super.postInit();
+ }
+
+ @Override
+ public MenuItem getItemByID(int id) {
+ return navigationView.getMenu().findItem(id);
+ }
+
+ @Override
+ public void show() {
+ // By default popupWindow follows the pre-material convention of displaying the popup
+ // below a View. We need to shift it over the view:
+ popupWindow.showAsDropDown(anchor,
+ 0,
+ -(anchor.getHeight() + anchor.getPaddingBottom()));
+ }
+
+ public void dismiss() {
+ popupWindow.dismiss();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/CirclePageIndicator.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/CirclePageIndicator.java
new file mode 100644
index 000000000..096f0c597
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/CirclePageIndicator.java
@@ -0,0 +1,568 @@
+/*
+ * Copyright (C) 2011 Patrik Akerfeldt
+ * Copyright (C) 2011 Jake Wharton
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mozilla.gecko.home.activitystream.topsites;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Paint.Style;
+import android.graphics.drawable.Drawable;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.support.v4.view.MotionEventCompat;
+import android.support.v4.view.ViewConfigurationCompat;
+import android.support.v4.view.ViewPager;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewConfiguration;
+
+import org.mozilla.gecko.R;
+
+import static android.graphics.Paint.ANTI_ALIAS_FLAG;
+import static android.widget.LinearLayout.HORIZONTAL;
+import static android.widget.LinearLayout.VERTICAL;
+
+/**
+ * Draws circles (one for each view). The current view position is filled and
+ * others are only stroked.
+ *
+ * This file was imported from Jake Wharton's ViewPagerIndicator library:
+ * https://github.com/JakeWharton/ViewPagerIndicator
+ * It was modified to not extend the PageIndicator interface (as we only use one single Indicator)
+ * implementation, and has had some minor appearance related modifications added alter.
+ */
+public class CirclePageIndicator
+ extends View
+ implements ViewPager.OnPageChangeListener {
+
+ /**
+ * Separation between circles, as a factor of the circle radius. By default CirclePageIndicator
+ * shipped with a separation factor of 3, however we want to be able to tweak this for
+ * ActivityStream.
+ *
+ * If/when we reuse this indicator elsewhere, this should probably become a configurable property.
+ */
+ private static final int SEPARATION_FACTOR = 7;
+
+ private static final int INVALID_POINTER = -1;
+
+ private float mRadius;
+ private final Paint mPaintPageFill = new Paint(ANTI_ALIAS_FLAG);
+ private final Paint mPaintStroke = new Paint(ANTI_ALIAS_FLAG);
+ private final Paint mPaintFill = new Paint(ANTI_ALIAS_FLAG);
+ private ViewPager mViewPager;
+ private ViewPager.OnPageChangeListener mListener;
+ private int mCurrentPage;
+ private int mSnapPage;
+ private float mPageOffset;
+ private int mScrollState;
+ private int mOrientation;
+ private boolean mCentered;
+ private boolean mSnap;
+
+ private int mTouchSlop;
+ private float mLastMotionX = -1;
+ private int mActivePointerId = INVALID_POINTER;
+ private boolean mIsDragging;
+
+
+ public CirclePageIndicator(Context context) {
+ this(context, null);
+ }
+
+ public CirclePageIndicator(Context context, AttributeSet attrs) {
+ this(context, attrs, R.attr.vpiCirclePageIndicatorStyle);
+ }
+
+ public CirclePageIndicator(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ if (isInEditMode()) return;
+
+ //Load defaults from resources
+ final Resources res = getResources();
+ final int defaultPageColor = res.getColor(R.color.default_circle_indicator_page_color);
+ final int defaultFillColor = res.getColor(R.color.default_circle_indicator_fill_color);
+ final int defaultOrientation = res.getInteger(R.integer.default_circle_indicator_orientation);
+ final int defaultStrokeColor = res.getColor(R.color.default_circle_indicator_stroke_color);
+ final float defaultStrokeWidth = res.getDimension(R.dimen.default_circle_indicator_stroke_width);
+ final float defaultRadius = res.getDimension(R.dimen.default_circle_indicator_radius);
+ final boolean defaultCentered = res.getBoolean(R.bool.default_circle_indicator_centered);
+ final boolean defaultSnap = res.getBoolean(R.bool.default_circle_indicator_snap);
+
+ //Retrieve styles attributes
+ TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CirclePageIndicator, defStyle, 0);
+
+ mCentered = a.getBoolean(R.styleable.CirclePageIndicator_centered, defaultCentered);
+ mOrientation = a.getInt(R.styleable.CirclePageIndicator_android_orientation, defaultOrientation);
+ mPaintPageFill.setStyle(Style.FILL);
+ mPaintPageFill.setColor(a.getColor(R.styleable.CirclePageIndicator_pageColor, defaultPageColor));
+ mPaintStroke.setStyle(Style.STROKE);
+ mPaintStroke.setColor(a.getColor(R.styleable.CirclePageIndicator_strokeColor, defaultStrokeColor));
+ mPaintStroke.setStrokeWidth(a.getDimension(R.styleable.CirclePageIndicator_strokeWidth, defaultStrokeWidth));
+ mPaintFill.setStyle(Style.FILL);
+ mPaintFill.setColor(a.getColor(R.styleable.CirclePageIndicator_fillColor, defaultFillColor));
+ mRadius = a.getDimension(R.styleable.CirclePageIndicator_radius, defaultRadius);
+ mSnap = a.getBoolean(R.styleable.CirclePageIndicator_snap, defaultSnap);
+
+ Drawable background = a.getDrawable(R.styleable.CirclePageIndicator_android_background);
+ if (background != null) {
+ setBackgroundDrawable(background);
+ }
+
+ a.recycle();
+
+ final ViewConfiguration configuration = ViewConfiguration.get(context);
+ mTouchSlop = ViewConfigurationCompat.getScaledPagingTouchSlop(configuration);
+ }
+
+
+ public void setCentered(boolean centered) {
+ mCentered = centered;
+ invalidate();
+ }
+
+ public boolean isCentered() {
+ return mCentered;
+ }
+
+ public void setPageColor(int pageColor) {
+ mPaintPageFill.setColor(pageColor);
+ invalidate();
+ }
+
+ public int getPageColor() {
+ return mPaintPageFill.getColor();
+ }
+
+ public void setFillColor(int fillColor) {
+ mPaintFill.setColor(fillColor);
+ invalidate();
+ }
+
+ public int getFillColor() {
+ return mPaintFill.getColor();
+ }
+
+ public void setOrientation(int orientation) {
+ switch (orientation) {
+ case HORIZONTAL:
+ case VERTICAL:
+ mOrientation = orientation;
+ requestLayout();
+ break;
+
+ default:
+ throw new IllegalArgumentException("Orientation must be either HORIZONTAL or VERTICAL.");
+ }
+ }
+
+ public int getOrientation() {
+ return mOrientation;
+ }
+
+ public void setStrokeColor(int strokeColor) {
+ mPaintStroke.setColor(strokeColor);
+ invalidate();
+ }
+
+ public int getStrokeColor() {
+ return mPaintStroke.getColor();
+ }
+
+ public void setStrokeWidth(float strokeWidth) {
+ mPaintStroke.setStrokeWidth(strokeWidth);
+ invalidate();
+ }
+
+ public float getStrokeWidth() {
+ return mPaintStroke.getStrokeWidth();
+ }
+
+ public void setRadius(float radius) {
+ mRadius = radius;
+ invalidate();
+ }
+
+ public float getRadius() {
+ return mRadius;
+ }
+
+ public void setSnap(boolean snap) {
+ mSnap = snap;
+ invalidate();
+ }
+
+ public boolean isSnap() {
+ return mSnap;
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+
+ if (mViewPager == null) {
+ return;
+ }
+ final int count = mViewPager.getAdapter().getCount();
+ if (count == 0) {
+ return;
+ }
+
+ if (mCurrentPage >= count) {
+ setCurrentItem(count - 1);
+ return;
+ }
+
+ int longSize;
+ int longPaddingBefore;
+ int longPaddingAfter;
+ int shortPaddingBefore;
+ if (mOrientation == HORIZONTAL) {
+ longSize = getWidth();
+ longPaddingBefore = getPaddingLeft();
+ longPaddingAfter = getPaddingRight();
+ shortPaddingBefore = getPaddingTop();
+ } else {
+ longSize = getHeight();
+ longPaddingBefore = getPaddingTop();
+ longPaddingAfter = getPaddingBottom();
+ shortPaddingBefore = getPaddingLeft();
+ }
+
+ final float threeRadius = mRadius * SEPARATION_FACTOR;
+ final float shortOffset = shortPaddingBefore + mRadius;
+ float longOffset = longPaddingBefore + mRadius;
+ if (mCentered) {
+ longOffset += ((longSize - longPaddingBefore - longPaddingAfter) / 2.0f) - ((count * threeRadius) / 2.0f);
+ }
+
+ float dX;
+ float dY;
+
+ float pageFillRadius = mRadius;
+ if (mPaintStroke.getStrokeWidth() > 0) {
+ pageFillRadius -= mPaintStroke.getStrokeWidth() / 2.0f;
+ }
+
+ //Draw stroked circles
+ for (int iLoop = 0; iLoop < count; iLoop++) {
+ float drawLong = longOffset + (iLoop * threeRadius);
+ if (mOrientation == HORIZONTAL) {
+ dX = drawLong;
+ dY = shortOffset;
+ } else {
+ dX = shortOffset;
+ dY = drawLong;
+ }
+ // Only paint fill if not completely transparent
+ if (mPaintPageFill.getAlpha() > 0) {
+ canvas.drawCircle(dX, dY, pageFillRadius, mPaintPageFill);
+ }
+
+ // Only paint stroke if a stroke width was non-zero
+ if (pageFillRadius != mRadius) {
+ canvas.drawCircle(dX, dY, mRadius, mPaintStroke);
+ }
+ }
+
+ //Draw the filled circle according to the current scroll
+ float cx = (mSnap ? mSnapPage : mCurrentPage) * threeRadius;
+ if (!mSnap) {
+ cx += mPageOffset * threeRadius;
+ }
+ if (mOrientation == HORIZONTAL) {
+ dX = longOffset + cx;
+ dY = shortOffset;
+ } else {
+ dX = shortOffset;
+ dY = longOffset + cx;
+ }
+ canvas.drawCircle(dX, dY, mRadius, mPaintFill);
+ }
+
+ public boolean onTouchEvent(android.view.MotionEvent ev) {
+ if (super.onTouchEvent(ev)) {
+ return true;
+ }
+ if ((mViewPager == null) || (mViewPager.getAdapter().getCount() == 0)) {
+ return false;
+ }
+
+ final int action = ev.getAction() & MotionEventCompat.ACTION_MASK;
+ switch (action) {
+ case MotionEvent.ACTION_DOWN:
+ mActivePointerId = MotionEventCompat.getPointerId(ev, 0);
+ mLastMotionX = ev.getX();
+ break;
+
+ case MotionEvent.ACTION_MOVE: {
+ final int activePointerIndex = MotionEventCompat.findPointerIndex(ev, mActivePointerId);
+ final float x = MotionEventCompat.getX(ev, activePointerIndex);
+ final float deltaX = x - mLastMotionX;
+
+ if (!mIsDragging) {
+ if (Math.abs(deltaX) > mTouchSlop) {
+ mIsDragging = true;
+ }
+ }
+
+ if (mIsDragging) {
+ mLastMotionX = x;
+ if (mViewPager.isFakeDragging() || mViewPager.beginFakeDrag()) {
+ mViewPager.fakeDragBy(deltaX);
+ }
+ }
+
+ break;
+ }
+
+ case MotionEvent.ACTION_CANCEL:
+ case MotionEvent.ACTION_UP:
+ if (!mIsDragging) {
+ final int count = mViewPager.getAdapter().getCount();
+ final int width = getWidth();
+ final float halfWidth = width / 2f;
+ final float sixthWidth = width / 6f;
+
+ if ((mCurrentPage > 0) && (ev.getX() < halfWidth - sixthWidth)) {
+ if (action != MotionEvent.ACTION_CANCEL) {
+ mViewPager.setCurrentItem(mCurrentPage - 1);
+ }
+ return true;
+ } else if ((mCurrentPage < count - 1) && (ev.getX() > halfWidth + sixthWidth)) {
+ if (action != MotionEvent.ACTION_CANCEL) {
+ mViewPager.setCurrentItem(mCurrentPage + 1);
+ }
+ return true;
+ }
+ }
+
+ mIsDragging = false;
+ mActivePointerId = INVALID_POINTER;
+ if (mViewPager.isFakeDragging()) mViewPager.endFakeDrag();
+ break;
+
+ case MotionEventCompat.ACTION_POINTER_DOWN: {
+ final int index = MotionEventCompat.getActionIndex(ev);
+ mLastMotionX = MotionEventCompat.getX(ev, index);
+ mActivePointerId = MotionEventCompat.getPointerId(ev, index);
+ break;
+ }
+
+ case MotionEventCompat.ACTION_POINTER_UP:
+ final int pointerIndex = MotionEventCompat.getActionIndex(ev);
+ final int pointerId = MotionEventCompat.getPointerId(ev, pointerIndex);
+ if (pointerId == mActivePointerId) {
+ final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
+ mActivePointerId = MotionEventCompat.getPointerId(ev, newPointerIndex);
+ }
+ mLastMotionX = MotionEventCompat.getX(ev, MotionEventCompat.findPointerIndex(ev, mActivePointerId));
+ break;
+ }
+
+ return true;
+ }
+
+ public void setViewPager(ViewPager view) {
+ if (mViewPager == view) {
+ return;
+ }
+ if (mViewPager != null) {
+ mViewPager.setOnPageChangeListener(null);
+ }
+ if (view.getAdapter() == null) {
+ throw new IllegalStateException("ViewPager does not have adapter instance.");
+ }
+ mViewPager = view;
+ mViewPager.setOnPageChangeListener(this);
+ invalidate();
+ }
+
+ public void setViewPager(ViewPager view, int initialPosition) {
+ setViewPager(view);
+ setCurrentItem(initialPosition);
+ }
+
+ public void setCurrentItem(int item) {
+ if (mViewPager == null) {
+ throw new IllegalStateException("ViewPager has not been bound.");
+ }
+ mViewPager.setCurrentItem(item);
+ mCurrentPage = item;
+ invalidate();
+ }
+
+ public void notifyDataSetChanged() {
+ invalidate();
+ }
+
+ @Override
+ public void onPageScrollStateChanged(int state) {
+ mScrollState = state;
+
+ if (mListener != null) {
+ mListener.onPageScrollStateChanged(state);
+ }
+ }
+
+ public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
+ mCurrentPage = position;
+ mPageOffset = positionOffset;
+ invalidate();
+
+ if (mListener != null) {
+ mListener.onPageScrolled(position, positionOffset, positionOffsetPixels);
+ }
+ }
+
+ @Override
+ public void onPageSelected(int position) {
+ if (mSnap || mScrollState == ViewPager.SCROLL_STATE_IDLE) {
+ mCurrentPage = position;
+ mSnapPage = position;
+ invalidate();
+ }
+
+ if (mListener != null) {
+ mListener.onPageSelected(position);
+ }
+ }
+
+ public void setOnPageChangeListener(ViewPager.OnPageChangeListener listener) {
+ mListener = listener;
+ }
+
+ /*
+ * (non-Javadoc)
+ *
+ * @see android.view.View#onMeasure(int, int)
+ */
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ if (mOrientation == HORIZONTAL) {
+ setMeasuredDimension(measureLong(widthMeasureSpec), measureShort(heightMeasureSpec));
+ } else {
+ setMeasuredDimension(measureShort(widthMeasureSpec), measureLong(heightMeasureSpec));
+ }
+ }
+
+ /**
+ * Determines the width of this view
+ *
+ * @param measureSpec
+ * A measureSpec packed into an int
+ * @return The width of the view, honoring constraints from measureSpec
+ */
+ private int measureLong(int measureSpec) {
+ int result;
+ int specMode = MeasureSpec.getMode(measureSpec);
+ int specSize = MeasureSpec.getSize(measureSpec);
+
+ if ((specMode == MeasureSpec.EXACTLY) || (mViewPager == null)) {
+ //We were told how big to be
+ result = specSize;
+ } else {
+ //Calculate the width according the views count
+ final int count = mViewPager.getAdapter().getCount();
+ result = (int)(getPaddingLeft() + getPaddingRight()
+ + (count * 2 * mRadius) + (count - 1) * mRadius + 1);
+ //Respect AT_MOST value if that was what is called for by measureSpec
+ if (specMode == MeasureSpec.AT_MOST) {
+ result = Math.min(result, specSize);
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Determines the height of this view
+ *
+ * @param measureSpec
+ * A measureSpec packed into an int
+ * @return The height of the view, honoring constraints from measureSpec
+ */
+ private int measureShort(int measureSpec) {
+ int result;
+ int specMode = MeasureSpec.getMode(measureSpec);
+ int specSize = MeasureSpec.getSize(measureSpec);
+
+ if (specMode == MeasureSpec.EXACTLY) {
+ //We were told how big to be
+ result = specSize;
+ } else {
+ //Measure the height
+ result = (int)(2 * mRadius + getPaddingTop() + getPaddingBottom() + 1);
+ //Respect AT_MOST value if that was what is called for by measureSpec
+ if (specMode == MeasureSpec.AT_MOST) {
+ result = Math.min(result, specSize);
+ }
+ }
+ return result;
+ }
+
+ @Override
+ public void onRestoreInstanceState(Parcelable state) {
+ SavedState savedState = (SavedState)state;
+ super.onRestoreInstanceState(savedState.getSuperState());
+ mCurrentPage = savedState.currentPage;
+ mSnapPage = savedState.currentPage;
+ requestLayout();
+ }
+
+ @Override
+ public Parcelable onSaveInstanceState() {
+ Parcelable superState = super.onSaveInstanceState();
+ SavedState savedState = new SavedState(superState);
+ savedState.currentPage = mCurrentPage;
+ return savedState;
+ }
+
+ static class SavedState extends BaseSavedState {
+ int currentPage;
+
+ public SavedState(Parcelable superState) {
+ super(superState);
+ }
+
+ private SavedState(Parcel in) {
+ super(in);
+ currentPage = in.readInt();
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ super.writeToParcel(dest, flags);
+ dest.writeInt(currentPage);
+ }
+
+ @SuppressWarnings("UnusedDeclaration")
+ public static final Parcelable.Creator<SavedState> CREATOR = new Parcelable.Creator<SavedState>() {
+ @Override
+ public SavedState createFromParcel(Parcel in) {
+ return new SavedState(in);
+ }
+
+ @Override
+ public SavedState[] newArray(int size) {
+ return new SavedState[size];
+ }
+ };
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesCard.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesCard.java
new file mode 100644
index 000000000..b436a466f
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesCard.java
@@ -0,0 +1,105 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home.activitystream.topsites;
+
+import android.graphics.Color;
+import android.support.v7.widget.RecyclerView;
+import android.view.View;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.activitystream.ActivityStream;
+import org.mozilla.gecko.home.HomePager;
+import org.mozilla.gecko.home.activitystream.menu.ActivityStreamContextMenu;
+import org.mozilla.gecko.icons.IconCallback;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.util.DrawableUtil;
+import org.mozilla.gecko.util.ViewUtil;
+import org.mozilla.gecko.util.TouchTargetUtil;
+import org.mozilla.gecko.widget.FaviconView;
+
+import java.util.EnumSet;
+import java.util.concurrent.Future;
+
+class TopSitesCard extends RecyclerView.ViewHolder
+ implements IconCallback, View.OnClickListener {
+ private final FaviconView faviconView;
+
+ private final TextView title;
+ private final ImageView menuButton;
+ private Future<IconResponse> ongoingIconLoad;
+
+ private String url;
+
+ private final HomePager.OnUrlOpenListener onUrlOpenListener;
+ private final HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener;
+
+ public TopSitesCard(FrameLayout card, final HomePager.OnUrlOpenListener onUrlOpenListener, final HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener) {
+ super(card);
+
+ faviconView = (FaviconView) card.findViewById(R.id.favicon);
+
+ title = (TextView) card.findViewById(R.id.title);
+ menuButton = (ImageView) card.findViewById(R.id.menu);
+
+ this.onUrlOpenListener = onUrlOpenListener;
+ this.onUrlOpenInBackgroundListener = onUrlOpenInBackgroundListener;
+
+ card.setOnClickListener(this);
+
+ TouchTargetUtil.ensureTargetHitArea(menuButton, card);
+ menuButton.setOnClickListener(this);
+
+ ViewUtil.enableTouchRipple(menuButton);
+ }
+
+ void bind(final TopSitesPageAdapter.TopSite topSite) {
+ ActivityStream.extractLabel(itemView.getContext(), topSite.url, true, new ActivityStream.LabelCallback() {
+ @Override
+ public void onLabelExtracted(String label) {
+ title.setText(label);
+ }
+ });
+
+ this.url = topSite.url;
+
+ if (ongoingIconLoad != null) {
+ ongoingIconLoad.cancel(true);
+ }
+
+ ongoingIconLoad = Icons.with(itemView.getContext())
+ .pageUrl(topSite.url)
+ .skipNetwork()
+ .build()
+ .execute(this);
+ }
+
+ @Override
+ public void onIconResponse(IconResponse response) {
+ faviconView.updateImage(response);
+
+ final int tintColor = !response.hasColor() || response.getColor() == Color.WHITE ? Color.LTGRAY : Color.WHITE;
+
+ menuButton.setImageDrawable(
+ DrawableUtil.tintDrawable(menuButton.getContext(), R.drawable.menu, tintColor));
+ }
+
+ @Override
+ public void onClick(View clickedView) {
+ if (clickedView == itemView) {
+ onUrlOpenListener.onUrlOpen(url, EnumSet.noneOf(HomePager.OnUrlOpenListener.Flags.class));
+ } else if (clickedView == menuButton) {
+ ActivityStreamContextMenu.show(clickedView.getContext(),
+ menuButton,
+ ActivityStreamContextMenu.MenuMode.TOPSITE,
+ title.getText().toString(), url,
+ onUrlOpenListener, onUrlOpenInBackgroundListener,
+ faviconView.getWidth(), faviconView.getHeight());
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPage.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPage.java
new file mode 100644
index 000000000..45fdc0d1a
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPage.java
@@ -0,0 +1,38 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home.activitystream.topsites;
+
+import android.content.Context;
+import android.support.annotation.Nullable;
+import android.support.v7.widget.GridLayoutManager;
+import android.support.v7.widget.RecyclerView;
+import android.util.AttributeSet;
+import android.view.View;
+
+import org.mozilla.gecko.home.HomePager;
+import org.mozilla.gecko.widget.RecyclerViewClickSupport;
+
+import java.util.EnumSet;
+
+public class TopSitesPage
+ extends RecyclerView {
+ public TopSitesPage(Context context,
+ @Nullable AttributeSet attrs) {
+ super(context, attrs);
+
+ setLayoutManager(new GridLayoutManager(context, 1));
+ }
+
+ public void setTiles(int tiles) {
+ setLayoutManager(new GridLayoutManager(getContext(), tiles));
+ }
+
+ private HomePager.OnUrlOpenListener onUrlOpenListener;
+ private HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener;
+
+ public TopSitesPageAdapter getAdapter() {
+ return (TopSitesPageAdapter) super.getAdapter();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPageAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPageAdapter.java
new file mode 100644
index 000000000..29e6aca3d
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPageAdapter.java
@@ -0,0 +1,117 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home.activitystream.topsites;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.support.annotation.UiThread;
+import android.support.v7.widget.RecyclerView;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.home.HomePager;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class TopSitesPageAdapter extends RecyclerView.Adapter<TopSitesCard> {
+ static final class TopSite {
+ public final long id;
+ public final String url;
+ public final String title;
+
+ TopSite(long id, String url, String title) {
+ this.id = id;
+ this.url = url;
+ this.title = title;
+ }
+ }
+
+ private List<TopSite> topSites;
+ private int tiles;
+ private int tilesWidth;
+ private int tilesHeight;
+ private int textHeight;
+
+ private final HomePager.OnUrlOpenListener onUrlOpenListener;
+ private final HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener;
+
+ public TopSitesPageAdapter(Context context, int tiles, int tilesWidth, int tilesHeight,
+ HomePager.OnUrlOpenListener onUrlOpenListener, HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener) {
+ setHasStableIds(true);
+
+ this.topSites = new ArrayList<>();
+ this.tiles = tiles;
+ this.tilesWidth = tilesWidth;
+ this.tilesHeight = tilesHeight;
+ this.textHeight = context.getResources().getDimensionPixelSize(R.dimen.activity_stream_top_sites_text_height);
+
+ this.onUrlOpenListener = onUrlOpenListener;
+ this.onUrlOpenInBackgroundListener = onUrlOpenInBackgroundListener;
+ }
+
+ /**
+ *
+ * @param cursor
+ * @param startIndex The first item that this topsites group should show. This item, and the following
+ * 3 items will be displayed by this adapter.
+ */
+ public void swapCursor(Cursor cursor, int startIndex) {
+ topSites.clear();
+
+ if (cursor == null) {
+ return;
+ }
+
+ for (int i = 0; i < tiles && startIndex + i < cursor.getCount(); i++) {
+ cursor.moveToPosition(startIndex + i);
+
+ // The Combined View only contains pages that have been visited at least once, i.e. any
+ // page in the TopSites query will contain a HISTORY_ID. _ID however will be 0 for all rows.
+ final long id = cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Combined.HISTORY_ID));
+ final String url = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.URL));
+ final String title = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.TITLE));
+
+ topSites.add(new TopSite(id, url, title));
+ }
+
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public void onBindViewHolder(TopSitesCard holder, int position) {
+ holder.bind(topSites.get(position));
+ }
+
+ @Override
+ public TopSitesCard onCreateViewHolder(ViewGroup parent, int viewType) {
+ final LayoutInflater inflater = LayoutInflater.from(parent.getContext());
+
+ final FrameLayout card = (FrameLayout) inflater.inflate(R.layout.activity_stream_topsites_card, parent, false);
+ final View content = card.findViewById(R.id.content);
+
+ ViewGroup.LayoutParams layoutParams = content.getLayoutParams();
+ layoutParams.width = tilesWidth;
+ layoutParams.height = tilesHeight + textHeight;
+ content.setLayoutParams(layoutParams);
+
+ return new TopSitesCard(card, onUrlOpenListener, onUrlOpenInBackgroundListener);
+ }
+
+ @Override
+ public int getItemCount() {
+ return topSites.size();
+ }
+
+ @Override
+ @UiThread
+ public long getItemId(int position) {
+ return topSites.get(position).id;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPagerAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPagerAdapter.java
new file mode 100644
index 000000000..dc824d902
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPagerAdapter.java
@@ -0,0 +1,124 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.home.activitystream.topsites;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.support.v4.view.PagerAdapter;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.home.HomePager;
+
+import java.util.LinkedList;
+
+/**
+ * The primary / top-level TopSites adapter: it handles the ViewPager, and also handles
+ * all lower-level Adapters that populate the individual topsite items.
+ */
+public class TopSitesPagerAdapter extends PagerAdapter {
+ public static final int PAGES = 4;
+
+ private int tiles;
+ private int tilesWidth;
+ private int tilesHeight;
+
+ private LinkedList<TopSitesPage> pages = new LinkedList<>();
+
+ private final Context context;
+ private final HomePager.OnUrlOpenListener onUrlOpenListener;
+ private final HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener;
+
+ private int count = 0;
+
+ public TopSitesPagerAdapter(Context context,
+ HomePager.OnUrlOpenListener onUrlOpenListener,
+ HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener) {
+ this.context = context;
+ this.onUrlOpenListener = onUrlOpenListener;
+ this.onUrlOpenInBackgroundListener = onUrlOpenInBackgroundListener;
+ }
+
+ public void setTilesSize(int tiles, int tilesWidth, int tilesHeight) {
+ this.tilesWidth = tilesWidth;
+ this.tilesHeight = tilesHeight;
+ this.tiles = tiles;
+ }
+
+ @Override
+ public int getCount() {
+ return Math.min(count, 4);
+ }
+
+ @Override
+ public boolean isViewFromObject(View view, Object object) {
+ return view == object;
+ }
+
+ @Override
+ public Object instantiateItem(ViewGroup container, int position) {
+ TopSitesPage page = pages.get(position);
+
+ container.addView(page);
+
+ return page;
+ }
+
+ @Override
+ public int getItemPosition(Object object) {
+ return PagerAdapter.POSITION_NONE;
+ }
+
+ @Override
+ public void destroyItem(ViewGroup container, int position, Object object) {
+ container.removeView((View) object);
+ }
+
+ public void swapCursor(Cursor cursor) {
+ // Divide while rounding up: 0 items = 0 pages, 1-ITEMS_PER_PAGE items = 1 page, etc.
+ if (cursor != null) {
+ count = (cursor.getCount() - 1) / tiles + 1;
+ } else {
+ count = 0;
+ }
+
+ pages.clear();
+ final int pageDelta = count;
+
+ if (pageDelta > 0) {
+ final LayoutInflater inflater = LayoutInflater.from(context);
+ for (int i = 0; i < pageDelta; i++) {
+ final TopSitesPage page = (TopSitesPage) inflater.inflate(R.layout.activity_stream_topsites_page, null, false);
+
+ page.setTiles(tiles);
+ final TopSitesPageAdapter adapter = new TopSitesPageAdapter(context, tiles, tilesWidth, tilesHeight,
+ onUrlOpenListener, onUrlOpenInBackgroundListener);
+ page.setAdapter(adapter);
+ pages.add(page);
+ }
+ } else if (pageDelta < 0) {
+ for (int i = 0; i > pageDelta; i--) {
+ final TopSitesPage page = pages.getLast();
+
+ // Ensure the page doesn't use the old/invalid cursor anymore
+ page.getAdapter().swapCursor(null, 0);
+
+ pages.removeLast();
+ }
+ } else {
+ // do nothing: we will be updating all the pages below
+ }
+
+ int startIndex = 0;
+ for (TopSitesPage page : pages) {
+ page.getAdapter().swapCursor(cursor, startIndex);
+ startIndex += tiles;
+ }
+
+ notifyDataSetChanged();
+ }
+}