Bug 1220928 - Add section headers. r=sebastian
☠☠ backed out by 0bc1a886196e ☠ ☠
authorChenxia Liu <liuche@mozilla.com>
Thu, 17 Mar 2016 00:18:37 -0700
changeset 290832 671ce80172a02706f96b033d188d308e9e369b14
parent 290831 4fc2017aee7551aa582577445d9448462f4f7837
child 290833 b2828872f3e78911798083fde9b196e7206aaf19
push id19656
push usergwagner@mozilla.com
push dateMon, 04 Apr 2016 13:43:23 +0000
treeherderb2g-inbound@e99061fde28a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerssebastian
bugs1220928
milestone48.0a1
Bug 1220928 - Add section headers. r=sebastian MozReview-Commit-ID: ItbC1yEJY4D
mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryAdapter.java
mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryItem.java
mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryPanel.java
mobile/android/base/java/org/mozilla/gecko/home/HistorySectionsHelper.java
mobile/android/base/moz.build
--- a/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryAdapter.java
+++ b/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryAdapter.java
@@ -4,65 +4,75 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 package org.mozilla.gecko.home;
 
 import android.support.v7.widget.RecyclerView;
 
 import android.content.Context;
 import android.database.Cursor;
 import android.util.Log;
+import android.util.SparseArray;
 import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
+import android.widget.TextView;
 import org.json.JSONArray;
 import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.BrowserContract;
 import org.mozilla.gecko.db.RemoteClient;
 import org.mozilla.gecko.db.RemoteTab;
+import org.mozilla.gecko.home.CombinedHistoryPanel.SectionHeader;
 
 import java.util.Collections;
 import java.util.ArrayList;
 import java.util.List;
 
 public class CombinedHistoryAdapter extends RecyclerView.Adapter<CombinedHistoryItem> {
     private static final String LOGTAG = "GeckoCombinedHistAdapt";
 
     public enum ItemType {
-        CLIENT, HISTORY, NAVIGATION_BACK, CHILD;
+        CLIENT, SECTION_HEADER, HISTORY, NAVIGATION_BACK, CHILD;
 
         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();
         }
     }
 
     private List<RemoteClient> remoteClients = Collections.emptyList();
     private List<RemoteTab> clientChildren;
     private Cursor historyCursor;
+
+    // We use a sparse array to store each section header's position in the panel [more cheaply than a HashMap].
+    private final SparseArray<CombinedHistoryPanel.SectionHeader> sectionHeaders;
+
     private final Context context;
 
     private boolean inChildView = false;
 
     public CombinedHistoryAdapter(Context context) {
         super();
         this.context = context;
+        sectionHeaders = new SparseArray<>();
     }
 
     public void setClients(List<RemoteClient> clients) {
         remoteClients = clients;
         notifyDataSetChanged();
     }
 
     public void setHistory(Cursor history) {
         historyCursor = history;
+        populateSectionHeaders(historyCursor, sectionHeaders);
         notifyDataSetChanged();
     }
 
     public JSONArray getCurrentChildTabs() {
         if (clientChildren != null) {
             final JSONArray urls = new JSONArray();
             for (int i = 1; i < clientChildren.size(); i++) {
                 urls.put(clientChildren.get(i).url);
@@ -87,18 +97,20 @@ public class CombinedHistoryAdapter exte
         inChildView = false;
         clientChildren.clear();
         notifyDataSetChanged();
     }
 
     private int transformPosition(ItemType type, int position) {
         if (type == ItemType.CLIENT) {
             return position;
+        } else if (type == ItemType.SECTION_HEADER) {
+            return position - remoteClients.size();
         } else if (type == ItemType.HISTORY){
-            return position - remoteClients.size();
+            return position - remoteClients.size() - getHeadersBefore(position);
         } else {
             return position;
         }
     }
 
     @Override
     public CombinedHistoryItem onCreateViewHolder(ViewGroup viewGroup, int viewType) {
         final LayoutInflater inflater = LayoutInflater.from(viewGroup.getContext());
@@ -110,51 +122,94 @@ public class CombinedHistoryAdapter exte
             case CLIENT:
                 view = inflater.inflate(R.layout.home_remote_tabs_group, viewGroup, false);
                 return new CombinedHistoryItem.ClientItem(view);
 
             case NAVIGATION_BACK:
                 view = inflater.inflate(R.layout.home_combined_back_item, viewGroup, false);
                 return new CombinedHistoryItem.HistoryItem(view);
 
+            case SECTION_HEADER:
+                view = inflater.inflate(R.layout.home_header_row, viewGroup, false);
+                return new CombinedHistoryItem.SectionItem(view);
+
             case CHILD:
             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 int getItemViewType(int position) {
         if (inChildView) {
             if (position == 0) {
                 return ItemType.itemTypeToViewType(ItemType.NAVIGATION_BACK);
-            } else {
-                return ItemType.itemTypeToViewType(ItemType.CHILD);
             }
+            return ItemType.itemTypeToViewType(ItemType.CHILD);
         } else {
             final int numClients = remoteClients.size();
-            return (position < numClients) ? ItemType.itemTypeToViewType(ItemType.CLIENT) : ItemType.itemTypeToViewType(ItemType.HISTORY);
+            if (position < numClients) {
+                return ItemType.itemTypeToViewType(ItemType.CLIENT);
+            }
+
+            final int sectionPosition = transformPosition(ItemType.SECTION_HEADER, position);
+            if (sectionHeaders.get(sectionPosition) != null) {
+                return ItemType.itemTypeToViewType(ItemType.SECTION_HEADER);
+            }
+
+            return ItemType.itemTypeToViewType(ItemType.HISTORY);
         }
     }
 
     @Override
     public int getItemCount() {
-
         if (inChildView) {
             return (clientChildren == null) ? 0 : clientChildren.size();
         } else {
             final int remoteSize = remoteClients.size();
             final int historySize = historyCursor == null ? 0 : historyCursor.getCount();
-            return remoteSize + historySize;
+            return remoteSize + historySize + sectionHeaders.size();
         }
     }
 
+    /**
+     * 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
+     */
+    private static void populateSectionHeaders(Cursor c, SparseArray<SectionHeader> sparseArray) {
+        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 = CombinedHistoryPanel.getSectionFromTime(visitTime);
+
+            if (section != itemSection) {
+                section = itemSection;
+                sparseArray.append(historyPosition + sparseArray.size(), section);
+            }
+
+            if (section == SectionHeader.OLDER_THAN_SIX_MONTHS) {
+                break;
+            }
+        } while (c.moveToNext());
+    }
+
+
     public boolean containsHistory() {
         if (historyCursor == null) {
             return false;
         }
         return (historyCursor.getCount() > 0);
     }
 
     @Override
@@ -169,17 +224,38 @@ public class CombinedHistoryAdapter exte
                 clientItem.bind(client, context);
                 break;
 
             case CHILD:
                 RemoteTab remoteTab = clientChildren.get(position);
                 ((CombinedHistoryItem.HistoryItem) viewHolder).bind(remoteTab);
                 break;
 
+            case SECTION_HEADER:
+                ((TextView) viewHolder.itemView).setText(CombinedHistoryPanel.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;
         }
     }
+
+    /**
+     * 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) {
+        final int adjustedPosition = position - remoteClients.size();
+        // 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) > adjustedPosition) {
+                return i;
+            }
+        }
+        return sectionHeaders.size();
+    }
 }
--- a/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryItem.java
+++ b/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryItem.java
@@ -16,16 +16,22 @@ import org.mozilla.gecko.RemoteTabsExpan
 import org.mozilla.gecko.db.RemoteClient;
 import org.mozilla.gecko.db.RemoteTab;
 
 public abstract class CombinedHistoryItem extends RecyclerView.ViewHolder {
     public CombinedHistoryItem(View view) {
         super(view);
     }
 
+    public static class SectionItem extends CombinedHistoryItem {
+        public SectionItem(View view) {
+            super(view);
+        }
+    }
+
     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);
--- a/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryPanel.java
+++ b/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryPanel.java
@@ -34,26 +34,44 @@ import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.GeckoEvent;
 import org.mozilla.gecko.GeckoProfile;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.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.home.HistorySectionsHelper.SectionDateRange;
 import org.mozilla.gecko.restrictions.Restrictable;
 import org.mozilla.gecko.widget.DividerItemDecoration;
 
 import java.util.List;
 
 public class CombinedHistoryPanel extends HomeFragment {
     private static final String LOGTAG = "GeckoCombinedHistoryPnl";
     private final int LOADER_ID_HISTORY = 0;
     private final int LOADER_ID_REMOTE = 1;
 
+    // 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
+    }
+
+    // Array for the time ranges in milliseconds covered by each section.
+    private static final SectionDateRange[] sectionDateRangeArray = new SectionDateRange[SectionHeader.values().length];
+
     // 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 mAdapter;
     private CursorLoaderCallbacks mCursorLoaderCallbacks;
 
@@ -83,18 +101,16 @@ public class CombinedHistoryPanel extend
         mAdapter = new CombinedHistoryAdapter(getContext());
         mRecyclerView.setAdapter(mAdapter);
         mRecyclerView.setItemAnimator(new DefaultItemAnimator());
         mRecyclerView.addItemDecoration(new DividerItemDecoration(getContext()));
         mRecyclerView.setOnHistoryClickedListener(mUrlOpenListener);
         mRecyclerView.setOnPanelLevelChangeListener(new OnLevelChangeListener());
         mPanelFooterButton = (Button) view.findViewById(R.id.clear_history_button);
         mPanelFooterButton.setOnClickListener(new OnFooterButtonClickListener());
-
-        // TODO: Handle date headers.
     }
 
     @Override
     public void onActivityCreated(Bundle savedInstanceState) {
         super.onActivityCreated(savedInstanceState);
         mCursorLoaderCallbacks = new CursorLoaderCallbacks();
     }
 
@@ -126,21 +142,35 @@ public class CombinedHistoryPanel extend
         public HistoryCursorLoader(Context context) {
             super(context);
             mDB = GeckoProfile.get(context).getDB();
         }
 
         @Override
         public Cursor loadCursor() {
             final ContentResolver cr = getContext().getContentResolver();
-            // TODO: Handle time bracketing by fetching date ranges from cursor
+            HistorySectionsHelper.updateRecentSectionOffset(getContext().getResources(), sectionDateRangeArray);
             return mDB.getRecentHistory(cr, HISTORY_LIMIT);
         }
     }
 
+    protected static String getSectionHeaderTitle(SectionHeader section) {
+        return sectionDateRangeArray[section.ordinal()].displayName;
+    }
+
+    protected 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;
+    }
+
     private class CursorLoaderCallbacks extends TransitionAwareCursorLoaderCallbacks {
         private BrowserDB mDB;    // Pseudo-final: set in onCreateLoader.
 
         @Override
         public Loader<Cursor> onCreateLoader(int id, Bundle args) {
             if (mDB == null) {
                 mDB = GeckoProfile.get(getActivity()).getDB();
             }
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/HistorySectionsHelper.java
@@ -0,0 +1,79 @@
+/* -*- 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.CombinedHistoryPanel.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));
+    }
+}
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -383,16 +383,17 @@ gbjar.sources += ['java/org/mozilla/geck
     'home/CombinedHistoryItem.java',
     'home/CombinedHistoryPanel.java',
     'home/CombinedHistoryRecyclerView.java',
     'home/DynamicPanel.java',
     'home/FramePanelLayout.java',
     'home/HistoryHeaderListCursorAdapter.java',
     'home/HistoryItemAdapter.java',
     'home/HistoryPanel.java',
+    'home/HistorySectionsHelper.java',
     'home/HomeAdapter.java',
     'home/HomeBanner.java',
     'home/HomeConfig.java',
     'home/HomeConfigLoader.java',
     'home/HomeConfigPrefsBackend.java',
     'home/HomeContextMenuInfo.java',
     'home/HomeExpandableListView.java',
     'home/HomeFragment.java',