From 5f8de423f190bbb79a62f804151bc24824fa32d8 Mon Sep 17 00:00:00 2001 From: "Matt A. Tobin" Date: Fri, 2 Feb 2018 04:16:08 -0500 Subject: Add m-esr52 at 52.6.0 --- .../org/mozilla/gecko/home/BookmarkFolderView.java | 147 ++ .../mozilla/gecko/home/BookmarkScreenshotRow.java | 67 + .../mozilla/gecko/home/BookmarksListAdapter.java | 352 ++++ .../org/mozilla/gecko/home/BookmarksListView.java | 218 +++ .../org/mozilla/gecko/home/BookmarksPanel.java | 316 ++++ .../java/org/mozilla/gecko/home/BrowserSearch.java | 1316 +++++++++++++++ .../org/mozilla/gecko/home/ClientsAdapter.java | 373 +++++ .../mozilla/gecko/home/CombinedHistoryAdapter.java | 433 +++++ .../mozilla/gecko/home/CombinedHistoryItem.java | 127 ++ .../mozilla/gecko/home/CombinedHistoryPanel.java | 697 ++++++++ .../gecko/home/CombinedHistoryRecyclerView.java | 145 ++ .../java/org/mozilla/gecko/home/DynamicPanel.java | 393 +++++ .../org/mozilla/gecko/home/FramePanelLayout.java | 52 + .../mozilla/gecko/home/HistorySectionsHelper.java | 80 + .../java/org/mozilla/gecko/home/HomeAdapter.java | 224 +++ .../java/org/mozilla/gecko/home/HomeBanner.java | 315 ++++ .../java/org/mozilla/gecko/home/HomeConfig.java | 1694 ++++++++++++++++++++ .../org/mozilla/gecko/home/HomeConfigLoader.java | 83 + .../mozilla/gecko/home/HomeConfigPrefsBackend.java | 663 ++++++++ .../mozilla/gecko/home/HomeContextMenuInfo.java | 82 + .../mozilla/gecko/home/HomeExpandableListView.java | 68 + .../java/org/mozilla/gecko/home/HomeFragment.java | 498 ++++++ .../java/org/mozilla/gecko/home/HomeListView.java | 138 ++ .../java/org/mozilla/gecko/home/HomePager.java | 564 +++++++ .../org/mozilla/gecko/home/HomePanelsManager.java | 368 +++++ .../java/org/mozilla/gecko/home/HomeScreen.java | 57 + .../java/org/mozilla/gecko/home/ImageLoader.java | 164 ++ .../mozilla/gecko/home/MultiTypeCursorAdapter.java | 100 ++ .../org/mozilla/gecko/home/PanelAuthCache.java | 82 + .../org/mozilla/gecko/home/PanelAuthLayout.java | 63 + .../org/mozilla/gecko/home/PanelBackItemView.java | 48 + .../org/mozilla/gecko/home/PanelHeaderView.java | 28 + .../org/mozilla/gecko/home/PanelInfoManager.java | 162 ++ .../java/org/mozilla/gecko/home/PanelItemView.java | 136 ++ .../java/org/mozilla/gecko/home/PanelLayout.java | 747 +++++++++ .../java/org/mozilla/gecko/home/PanelListView.java | 83 + .../org/mozilla/gecko/home/PanelRecyclerView.java | 178 ++ .../gecko/home/PanelRecyclerViewAdapter.java | 137 ++ .../org/mozilla/gecko/home/PanelRefreshLayout.java | 90 ++ .../org/mozilla/gecko/home/PanelViewAdapter.java | 113 ++ .../mozilla/gecko/home/PanelViewItemHandler.java | 59 + .../java/org/mozilla/gecko/home/PinSiteDialog.java | 256 +++ .../org/mozilla/gecko/home/RecentTabsAdapter.java | 454 ++++++ .../gecko/home/RemoteTabsExpandableListState.java | 163 ++ .../java/org/mozilla/gecko/home/SearchEngine.java | 102 ++ .../mozilla/gecko/home/SearchEngineAdapter.java | 122 ++ .../org/mozilla/gecko/home/SearchEngineBar.java | 148 ++ .../org/mozilla/gecko/home/SearchEngineRow.java | 494 ++++++ .../java/org/mozilla/gecko/home/SearchLoader.java | 114 ++ .../org/mozilla/gecko/home/SimpleCursorLoader.java | 147 ++ .../org/mozilla/gecko/home/SpacingDecoration.java | 20 + .../java/org/mozilla/gecko/home/TabMenuStrip.java | 127 ++ .../org/mozilla/gecko/home/TabMenuStripLayout.java | 246 +++ .../mozilla/gecko/home/TopSitesGridItemView.java | 312 ++++ .../org/mozilla/gecko/home/TopSitesGridView.java | 169 ++ .../java/org/mozilla/gecko/home/TopSitesPanel.java | 968 +++++++++++ .../mozilla/gecko/home/TopSitesThumbnailView.java | 102 ++ .../org/mozilla/gecko/home/TwoLinePageRow.java | 324 ++++ .../gecko/home/activitystream/ActivityStream.java | 145 ++ .../activitystream/ActivityStreamHomeFragment.java | 39 + .../activitystream/ActivityStreamHomeScreen.java | 73 + .../gecko/home/activitystream/StreamItem.java | 196 +++ .../home/activitystream/StreamRecyclerAdapter.java | 135 ++ .../menu/ActivityStreamContextMenu.java | 239 +++ .../menu/BottomSheetContextMenu.java | 102 ++ .../home/activitystream/menu/PopupContextMenu.java | 76 + .../topsites/CirclePageIndicator.java | 568 +++++++ .../home/activitystream/topsites/TopSitesCard.java | 105 ++ .../home/activitystream/topsites/TopSitesPage.java | 38 + .../topsites/TopSitesPageAdapter.java | 117 ++ .../topsites/TopSitesPagerAdapter.java | 124 ++ 71 files changed, 17885 insertions(+) create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/BookmarkFolderView.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/BookmarkScreenshotRow.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/BookmarksListAdapter.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/BookmarksListView.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/BookmarksPanel.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/BrowserSearch.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/ClientsAdapter.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryAdapter.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryItem.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryPanel.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryRecyclerView.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/DynamicPanel.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/FramePanelLayout.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/HistorySectionsHelper.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/HomeAdapter.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/HomeBanner.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/HomeConfig.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/HomeConfigLoader.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/HomeConfigPrefsBackend.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/HomeContextMenuInfo.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/HomeExpandableListView.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/HomeFragment.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/HomeListView.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/HomePager.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/HomePanelsManager.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/HomeScreen.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/ImageLoader.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/MultiTypeCursorAdapter.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/PanelAuthCache.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/PanelAuthLayout.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/PanelBackItemView.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/PanelHeaderView.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/PanelInfoManager.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/PanelItemView.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/PanelLayout.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/PanelListView.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/PanelRecyclerView.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/PanelRecyclerViewAdapter.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/PanelRefreshLayout.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/PanelViewAdapter.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/PanelViewItemHandler.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/PinSiteDialog.java create mode 100755 mobile/android/base/java/org/mozilla/gecko/home/RecentTabsAdapter.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/RemoteTabsExpandableListState.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/SearchEngine.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/SearchEngineAdapter.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/SearchEngineBar.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/SearchEngineRow.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/SearchLoader.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/SimpleCursorLoader.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/SpacingDecoration.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/TabMenuStrip.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/TabMenuStripLayout.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/TopSitesGridItemView.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/TopSitesGridView.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/TopSitesPanel.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/TopSitesThumbnailView.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/TwoLinePageRow.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStream.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStreamHomeFragment.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStreamHomeScreen.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamItem.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamRecyclerAdapter.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/ActivityStreamContextMenu.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/BottomSheetContextMenu.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/PopupContextMenu.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/CirclePageIndicator.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesCard.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPage.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPageAdapter.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPagerAdapter.java (limited to 'mobile/android/base/java/org/mozilla/gecko/home') 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 FOLDERS_WITH_COUNT; + + static { + final Set 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 { + private final WeakReference mTextViewReference; + private final int mFolderID; + + public ItemCountUpdateTask(final WeakReference 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 subTitleReference = new WeakReference(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 CREATOR = new Creator() { + @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 CREATOR = new Creator() { + @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 mParentStack; + + // Refresh folder listener. + private OnRefreshFolderListener mListener; + + private FolderType openFolderType = FolderType.BOOKMARKS; + + public BookmarksListAdapter(Context context, Cursor cursor, List parentStack) { + // Initializing with a null cursor. + super(context, cursor, VIEW_TYPES, LAYOUT_TYPES); + + if (parentStack == null) { + mParentStack = new LinkedList(); + } else { + mParentStack = new LinkedList(parentStack); + } + } + + public void restoreData(List parentStack) { + mParentStack = new LinkedList(parentStack); + notifyDataSetChanged(); + } + + public List 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 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 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 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 stack = data.getParcelableArrayList("parentStack"); + if (stack == null) { + return; + } + + if (mListAdapter == null) { + mSavedParentStack = new LinkedList(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 { + @Override + public Loader 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 loader, Cursor c) { + BookmarksLoader bl = (BookmarksLoader) loader; + mListAdapter.swapCursor(c, bl.getFolderInfo(), bl.getRefreshType()); + + if (mPanelStateChangeListener != null) { + final List 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(parentStack)); + + mPanelStateChangeListener.onStateChanged(bundle); + } + updateUiFromCursor(c); + } + + @Override + public void onLoaderReset(Loader 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 mSearchEngines; + + // Search history suggestions + private ArrayList 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(); + 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 domains = null; + private LinkedHashSet getDomains() { + if (domains == null) { + domains = new LinkedHashSet(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 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 savedSuggestions) { + ThreadUtils.assertOnUiThread(); + + mSearchHistorySuggestions = savedSuggestions; + mAdapter.notifyDataSetChanged(); + } + + private boolean shouldUpdateSearchEngine(ArrayList 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 searchEngines = new ArrayList(); + 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> { + protected final String mSearchTerm; + private ArrayList mSuggestions; + + public SuggestionAsyncLoader(Context context, String searchTerm) { + super(context); + mSearchTerm = searchTerm; + } + + @Override + public void deliverResult(ArrayList 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 loadInBackground() { + return mSuggestClient.query(mSearchTerm); + } + } + + private static class SearchHistorySuggestionAsyncLoader extends SuggestionAsyncLoader { + public SearchHistorySuggestionAsyncLoader(Context context, String searchTerm) { + super(context, searchTerm); + } + + @Override + public ArrayList 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 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 { + @Override + public Loader onCreateLoader(int id, Bundle args) { + return SearchLoader.createInstance(getActivity(), args); + } + + @Override + public void onLoadFinished(Loader 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 loader) { + if (mAdapter != null) { + mAdapter.swapCursor(null); + } + } + } + + private class SearchEngineSuggestionLoaderCallbacks implements LoaderCallbacks> { + @Override + public Loader> 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> loader, ArrayList suggestions) { + setSuggestions(suggestions); + } + + @Override + public void onLoaderReset(Loader> loader) { + setSuggestions(new ArrayList()); + } + } + + private class SearchHistorySuggestionLoaderCallbacks implements LoaderCallbacks> { + @Override + public Loader> 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> loader, ArrayList suggestions) { + setSavedSuggestions(suggestions); + } + + @Override + public void onLoaderReset(Loader> loader) { + setSavedSuggestions(new ArrayList()); + } + } + + 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 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> adapterList = new LinkedList<>(); + + // List of hidden remote clients. + // Only accessed from the UI thread. + protected final List hiddenClients = new ArrayList<>(); + private Map 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 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 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 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 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> getVisibleItems(RemoteClient client) { + List> 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 getHiddenClients() { + return hiddenClients; + } + + public void toggleClient(int position) { + final Pair 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 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 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 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 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 getItemTypeForPosition, + * 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 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 { + private BrowserDB mDB; // Pseudo-final: set in onCreateLoader. + + @Override + public Loader 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 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 clients = mDB.getTabsAccessor().getClientsFromCursor(c); + mHistoryAdapter.getDeviceUpdateHandler().onDeviceCountUpdated(clients.size()); + mClientsAdapter.setClients(clients); + updateEmptyView(CHILD_SYNC); + break; + } + + updateButtonFromLevel(); + } + + @Override + public void onLoaderReset(Loader loader) { + mClientsAdapter.setClients(Collections.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 FORMAT_S1 and + * FORMAT_S2. + * + * @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 { + void createAndShowDialog(List items); + } + + protected class HiddenClientsHelper implements DialogBuilder { + @Override + public void createAndShowDialog(List 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 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 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 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 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(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 { + @Override + public Loader 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 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 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 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 mPanelInfos; + private final Map mPanels; + private final Map 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 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 CREATOR = new Creator() { + @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 mViews; + private final AuthConfig mAuthConfig; + private final EnumSet 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(); + + 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(); + in.readTypedList(mViews, ViewConfig.CREATOR); + + mAuthConfig = (AuthConfig) in.readParcelable(getClass().getClassLoader()); + + mFlags = (EnumSet) 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(); + List 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) { + this(type, title, id, null, null, null, flags, -1); + } + + public PanelConfig(PanelType type, String title, String id, LayoutType layoutType, + List views, AuthConfig authConfig, EnumSet 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 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 CREATOR = new Creator() { + @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 CREATOR = new Creator() { + @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 CREATOR = new Creator() { + @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 CREATOR = new Creator() { + @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 CREATOR = new Creator() { + @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 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) 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 CREATOR = new Creator() { + @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 CREATOR = new Creator() { + @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 CREATOR = new Creator() { + @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 CREATOR = new Creator() { + @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 { + private HomeConfig mHomeConfig; + private final List mPanelConfigs; + private final boolean mIsDefault; + + State(List panelConfigs, boolean isDefault) { + this(null, panelConfigs, isDefault); + } + + private State(HomeConfig homeConfig, List 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 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 { + private final HomeConfig mHomeConfig; + private final Map mConfigMap; + private final List 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> 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(); + mConfigOrder = new LinkedList(); + 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 makeOrderedCopy(boolean deepCopy) { + final List copiedList = new ArrayList(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( + "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("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> 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> notifications) { + for (Pair p : notifications) { + GeckoAppShell.notifyObservers(p.first, p.second); + } + } + + private class EditorIterator implements Iterator { + private final Iterator 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 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 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 { + 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 panelConfigs = new ArrayList(); + + 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 historyFlags = null; + EnumSet 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 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 panelConfigs = new ArrayList(); + + 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; + +/** + * HomeExpandableListView is a custom extension of + * ExpandableListView, that packs a HomeContextMenuInfo + * when any of its rows is long pressed. + *

+ * This is the ExpandableListView equivalent of + * HomeListView. + */ +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. + *

+ * The containing activity must 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 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. + *

+ * 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 { + 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 position. + */ + 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 { + 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); + } + + /** + * Interface for requesting a new tab be opened in the background. + *

+ * This is the HomeFragment 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); + } + + /** + * 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 enabledPanels = new ArrayList(); + + 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 { + @Override + public Loader onCreateLoader(int id, Bundle args) { + return new HomeConfigLoader(mContext, mConfig); + } + + @Override + public void onLoadFinished(Loader loader, HomeConfig.State configState) { + mLoadState = LoadState.LOADED; + updateUiFromConfigState(configState); + } + + @Override + public void onLoaderReset(Loader 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 mPendingChanges = new ConcurrentLinkedQueue(); + 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 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 ids = new HashSet(); + for (PanelConfig panelConfig : editor) { + ids.add(panelConfig.getId()); + } + + final Object panelRequestLock = new Object(); + final List latestPanelInfos = new ArrayList(); + + final PanelInfoManager pm = new PanelInfoManager(); + pm.requestPanelsById(ids, new RequestCallback() { + @Override + public void onComplete(List 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:/// + * + * Which will look for the following file in the distribution: + * + * ///.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 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 panelInfos); + } + + private static final AtomicInteger sRequestId = new AtomicInteger(0); + + // Stores set of pending request callbacks. + private static final SparseArray sCallbacks = new SparseArray(); + + /** + * 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 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 panelInfos = new ArrayList(); + + 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 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 CREATOR = new Creator() { + @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 CREATOR = new Creator() { + @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(); + 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 mView; + private SoftReference mEmptyView; + private LinkedList mFilterStack; + + public ViewState(ViewConfig viewConfig) { + mViewConfig = viewConfig; + mView = new SoftReference(null); + mEmptyView = new SoftReference(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); + } + + public View getEmptyView() { + return mEmptyView.get(); + } + + public void setEmptyView(View view) { + mEmptyView = new SoftReference(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(); + + // 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 CREATOR = new Creator() { + @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 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 { + 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 { + @Override + public Loader onCreateLoader(int id, Bundle args) { + return SearchLoader.createInstance(getActivity(), args); + } + + @Override + public void onLoadFinished(Loader loader, Cursor c) { + mAdapter.swapCursor(c); + } + + @Override + public void onLoaderReset(Loader 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 + 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 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 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 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 dataList, ClosedTab[] closedTabs) { + for (ClosedTab closedTab : closedTabs) { + dataList.add(closedTab.data); + } + } + + private static void restoreSessionWithHistory(List 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. + *

+ * 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. + *

+ * 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 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 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. + *

+ * 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 getStringSet(String pref) { + final Set loaded = PrefUtils.getStringSet(sharedPrefs, pref, null); + if (loaded != null) { + return new HashSet(loaded); + } else { + return new HashSet(); + } + } + + /** + * 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 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 suggestions = new ArrayList(); // 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 getSuggestions() { + return this.suggestions; + } + + public void setSuggestions(List suggestions) { + if (suggestions == null) { + this.suggestions = new ArrayList(); + 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 { + + 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 mSearchEngines = Collections.emptyList(); + + public void setSearchEngines(List 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 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 mOccurrences = new ArrayList(); + + 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 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 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 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 searchHistorySuggestions = (rawSearchHistorySuggestions != null) ? rawSearchHistorySuggestions : new ArrayList(); + + // Filter out URLs and long search suggestions + Iterator searchHistoryIterator = searchHistorySuggestions.iterator(); + while (searchHistoryIterator.hasNext()) { + final String currentSearchHistory = searchHistoryIterator.next(); + + if (currentSearchHistory.length() > 50 || Pattern.matches("^(https?|ftp|file)://.*", currentSearchHistory)) { + searchHistoryIterator.remove(); + } + } + + + List searchEngineSuggestions = new ArrayList(); + 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 createInstance(Context context, Bundle args) { + if (args != null) { + final String searchTerm = args.getString(KEY_SEARCH_TERM); + final EnumSet flags = + (EnumSet) 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 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 callbacks, String searchTerm) { + init(manager, loaderId, callbacks, searchTerm, EnumSet.noneOf(FilterFlags.class)); + } + + public static void init(LoaderManager manager, int loaderId, + LoaderCallbacks callbacks, String searchTerm, + EnumSet flags) { + final Bundle args = createArgs(searchTerm, flags); + manager.initLoader(loaderId, args, callbacks); + } + + public static void restart(LoaderManager manager, int loaderId, + LoaderCallbacks callbacks, String searchTerm) { + restart(manager, loaderId, callbacks, searchTerm, EnumSet.noneOf(FilterFlags.class)); + } + + public static void restart(LoaderManager manager, int loaderId, + LoaderCallbacks callbacks, String searchTerm, + EnumSet 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 mFlags; + private final GeckoProfile mProfile; + + public SearchCursorLoader(Context context, String searchTerm, EnumSet 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 { + 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 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 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 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 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 { + @Override + public Loader 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 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 urls = new ArrayList(); + 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()); + return; + } + + Bundle bundle = new Bundle(); + bundle.putStringArrayList(THUMBNAILS_URLS_KEY, urls); + getLoaderManager().restartLoader(LOADER_ID_THUMBNAILS, bundle, mThumbnailsLoaderCallbacks); + } + + @Override + public void onLoaderReset(Loader 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 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> { + private final BrowserDB mDB; + private Map mThumbnailInfos; + private final ArrayList mUrls; + + private static final List COLUMNS; + static { + final ArrayList tempColumns = new ArrayList<>(2); + tempColumns.add(TILE_IMAGE_URL_COLUMN); + tempColumns.add(TILE_COLOR_COLUMN); + COLUMNS = Collections.unmodifiableList(tempColumns); + } + + public ThumbnailsLoader(Context context, ArrayList urls) { + super(context); + mUrls = urls; + mDB = BrowserDB.from(context); + } + + @Override + public Map loadInBackground() { + final Map thumbnails = new HashMap(); + 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 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> 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 thumbnailUrls = new ArrayList(); + 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 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 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> { + @Override + public Loader> onCreateLoader(int id, Bundle args) { + return new ThumbnailsLoader(getActivity(), args.getStringArrayList(THUMBNAILS_URLS_KEY)); + } + + @Override + public void onLoadFinished(Loader> loader, Map thumbnails) { + updateUiWithThumbnails(thumbnails); + } + + @Override + public void onLoaderReset(Loader> 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 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. + *

+ * 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. + *

+ * 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. + *

+ * 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 { + @Override + public Loader 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 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 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 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 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. + *

+ * 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(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(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 CREATOR = new Parcelable.Creator() { + @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 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 { + 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 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 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(); + } +} -- cgit v1.2.3