1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
|
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.gecko.home;
import android.content.res.Resources;
import android.support.annotation.UiThread;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.database.Cursor;
import android.util.SparseArray;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import org.mozilla.gecko.R;
import org.mozilla.gecko.db.BrowserContract;
import org.mozilla.gecko.util.ThreadUtils;
public class CombinedHistoryAdapter extends RecyclerView.Adapter<CombinedHistoryItem> implements CombinedHistoryRecyclerView.AdapterContextMenuBuilder {
private static final int RECENT_TABS_SMARTFOLDER_INDEX = 0;
// Array for the time ranges in milliseconds covered by each section.
static final HistorySectionsHelper.SectionDateRange[] sectionDateRangeArray = new HistorySectionsHelper.SectionDateRange[SectionHeader.values().length];
// Semantic names for the time covered by each section
public enum SectionHeader {
TODAY,
YESTERDAY,
WEEK,
THIS_MONTH,
MONTH_AGO,
TWO_MONTHS_AGO,
THREE_MONTHS_AGO,
FOUR_MONTHS_AGO,
FIVE_MONTHS_AGO,
OLDER_THAN_SIX_MONTHS
}
private HomeFragment.PanelStateChangeListener panelStateChangeListener;
private Cursor historyCursor;
private DevicesUpdateHandler devicesUpdateHandler;
private int deviceCount = 0;
private RecentTabsUpdateHandler recentTabsUpdateHandler;
private int recentTabsCount = 0;
private LinearLayoutManager linearLayoutManager; // Only used on the UI thread, so no need to be volatile.
// We use a sparse array to store each section header's position in the panel [more cheaply than a HashMap].
private final SparseArray<SectionHeader> sectionHeaders;
public CombinedHistoryAdapter(Resources resources, int cachedRecentTabsCount) {
super();
recentTabsCount = cachedRecentTabsCount;
sectionHeaders = new SparseArray<>();
HistorySectionsHelper.updateRecentSectionOffset(resources, sectionDateRangeArray);
this.setHasStableIds(true);
}
public void setPanelStateChangeListener(
HomeFragment.PanelStateChangeListener panelStateChangeListener) {
this.panelStateChangeListener = panelStateChangeListener;
}
@UiThread
public void setLinearLayoutManager(LinearLayoutManager linearLayoutManager) {
this.linearLayoutManager = linearLayoutManager;
}
public void setHistory(Cursor history) {
historyCursor = history;
populateSectionHeaders(historyCursor, sectionHeaders);
notifyDataSetChanged();
}
public interface DevicesUpdateHandler {
void onDeviceCountUpdated(int count);
}
public DevicesUpdateHandler getDeviceUpdateHandler() {
if (devicesUpdateHandler == null) {
devicesUpdateHandler = new DevicesUpdateHandler() {
@Override
public void onDeviceCountUpdated(int count) {
deviceCount = count;
notifyItemChanged(getSyncedDevicesSmartFolderIndex());
}
};
}
return devicesUpdateHandler;
}
public interface RecentTabsUpdateHandler {
void onRecentTabsCountUpdated(int count, boolean countReliable);
}
public RecentTabsUpdateHandler getRecentTabsUpdateHandler() {
if (recentTabsUpdateHandler != null) {
return recentTabsUpdateHandler;
}
recentTabsUpdateHandler = new RecentTabsUpdateHandler() {
@Override
public void onRecentTabsCountUpdated(final int count, final boolean countReliable) {
// Now that other items can move around depending on the visibility of the
// Recent Tabs folder, only update the recentTabsCount on the UI thread.
ThreadUtils.postToUiThread(new Runnable() {
@UiThread
@Override
public void run() {
if (!countReliable && count <= recentTabsCount) {
// The final tab count (where countReliable = true) is normally >= than
// previous values with countReliable = false. Hence we only want to
// update the displayed tab count with a preliminary value if it's larger
// than the previous count, so as to avoid the displayed count jumping
// downwards and then back up, as well as unnecessary folder animations.
return;
}
final boolean prevFolderVisibility = isRecentTabsFolderVisible();
recentTabsCount = count;
final boolean folderVisible = isRecentTabsFolderVisible();
if (prevFolderVisibility == folderVisible) {
if (prevFolderVisibility) {
notifyItemChanged(RECENT_TABS_SMARTFOLDER_INDEX);
}
return;
}
// If the Recent Tabs smart folder has become hidden/unhidden,
// we need to recalculate the history section header positions.
populateSectionHeaders(historyCursor, sectionHeaders);
if (folderVisible) {
int scrollPos = -1;
if (linearLayoutManager != null) {
scrollPos = linearLayoutManager.findFirstCompletelyVisibleItemPosition();
}
notifyItemInserted(RECENT_TABS_SMARTFOLDER_INDEX);
// If the list exceeds the display height and we want to show the new
// item inserted at position 0, we need to scroll up manually
// (see https://code.google.com/p/android/issues/detail?id=174227#c2).
// However we only do this if our current scroll position is at the
// top of the list.
if (linearLayoutManager != null && scrollPos == 0) {
linearLayoutManager.scrollToPosition(0);
}
} else {
notifyItemRemoved(RECENT_TABS_SMARTFOLDER_INDEX);
}
if (countReliable && panelStateChangeListener != null) {
panelStateChangeListener.setCachedRecentTabsCount(recentTabsCount);
}
}
});
}
};
return recentTabsUpdateHandler;
}
@UiThread
private boolean isRecentTabsFolderVisible() {
return recentTabsCount > 0;
}
@UiThread
// Number of smart folders for determining practical empty state.
public int getNumVisibleSmartFolders() {
int visibleFolders = 1; // Synced devices folder is always visible.
if (isRecentTabsFolderVisible()) {
visibleFolders += 1;
}
return visibleFolders;
}
@UiThread
private int getSyncedDevicesSmartFolderIndex() {
return isRecentTabsFolderVisible() ?
RECENT_TABS_SMARTFOLDER_INDEX + 1 :
RECENT_TABS_SMARTFOLDER_INDEX;
}
@Override
public CombinedHistoryItem onCreateViewHolder(ViewGroup viewGroup, int viewType) {
final LayoutInflater inflater = LayoutInflater.from(viewGroup.getContext());
final View view;
final CombinedHistoryItem.ItemType itemType = CombinedHistoryItem.ItemType.viewTypeToItemType(viewType);
switch (itemType) {
case RECENT_TABS:
case SYNCED_DEVICES:
view = inflater.inflate(R.layout.home_smartfolder, viewGroup, false);
return new CombinedHistoryItem.SmartFolder(view);
case SECTION_HEADER:
view = inflater.inflate(R.layout.home_header_row, viewGroup, false);
return new CombinedHistoryItem.BasicItem(view);
case HISTORY:
view = inflater.inflate(R.layout.home_item_row, viewGroup, false);
return new CombinedHistoryItem.HistoryItem(view);
default:
throw new IllegalArgumentException("Unexpected Home Panel item type");
}
}
@Override
public void onBindViewHolder(CombinedHistoryItem viewHolder, int position) {
final CombinedHistoryItem.ItemType itemType = getItemTypeForPosition(position);
final int localPosition = transformAdapterPositionForDataStructure(itemType, position);
switch (itemType) {
case RECENT_TABS:
((CombinedHistoryItem.SmartFolder) viewHolder).bind(R.drawable.icon_recent, R.string.home_closed_tabs_title2, R.string.home_closed_tabs_one, R.string.home_closed_tabs_number, recentTabsCount);
break;
case SYNCED_DEVICES:
((CombinedHistoryItem.SmartFolder) viewHolder).bind(R.drawable.cloud, R.string.home_synced_devices_smartfolder, R.string.home_synced_devices_one, R.string.home_synced_devices_number, deviceCount);
break;
case SECTION_HEADER:
((TextView) viewHolder.itemView).setText(getSectionHeaderTitle(sectionHeaders.get(localPosition)));
break;
case HISTORY:
if (historyCursor == null || !historyCursor.moveToPosition(localPosition)) {
throw new IllegalStateException("Couldn't move cursor to position " + localPosition);
}
((CombinedHistoryItem.HistoryItem) viewHolder).bind(historyCursor);
break;
}
}
/**
* Transform an adapter position to the position for the data structure backing the item type.
*
* The type is not strictly necessary and could be fetched from <code>getItemTypeForPosition</code>,
* but is used for explicitness.
*
* @param type ItemType of the item
* @param position position in the adapter
* @return position of the item in the data structure
*/
@UiThread
private int transformAdapterPositionForDataStructure(CombinedHistoryItem.ItemType type, int position) {
if (type == CombinedHistoryItem.ItemType.SECTION_HEADER) {
return position;
} else if (type == CombinedHistoryItem.ItemType.HISTORY) {
return position - getHeadersBefore(position) - getNumVisibleSmartFolders();
} else {
return position;
}
}
@UiThread
private CombinedHistoryItem.ItemType getItemTypeForPosition(int position) {
if (position == RECENT_TABS_SMARTFOLDER_INDEX && isRecentTabsFolderVisible()) {
return CombinedHistoryItem.ItemType.RECENT_TABS;
}
if (position == getSyncedDevicesSmartFolderIndex()) {
return CombinedHistoryItem.ItemType.SYNCED_DEVICES;
}
final int sectionPosition = transformAdapterPositionForDataStructure(CombinedHistoryItem.ItemType.SECTION_HEADER, position);
if (sectionHeaders.get(sectionPosition) != null) {
return CombinedHistoryItem.ItemType.SECTION_HEADER;
}
return CombinedHistoryItem.ItemType.HISTORY;
}
@UiThread
@Override
public int getItemViewType(int position) {
return CombinedHistoryItem.ItemType.itemTypeToViewType(getItemTypeForPosition(position));
}
@UiThread
@Override
public int getItemCount() {
final int historySize = historyCursor == null ? 0 : historyCursor.getCount();
return historySize + sectionHeaders.size() + getNumVisibleSmartFolders();
}
/**
* Returns stable ID for each position. Data behind historyCursor is a sorted Combined view.
*
* @param position view item position for which to generate a stable ID
* @return stable ID for given position
*/
@UiThread
@Override
public long getItemId(int position) {
// Two randomly selected large primes used to generate non-clashing IDs.
final long PRIME_BOOKMARKS = 32416189867L;
final long PRIME_SECTION_HEADERS = 32416187737L;
// RecyclerView.NO_ID is -1, so let's start from -2 for our hard-coded IDs.
final int RECENT_TABS_ID = -2;
final int SYNCED_DEVICES_ID = -3;
switch (getItemTypeForPosition(position)) {
case RECENT_TABS:
return RECENT_TABS_ID;
case SYNCED_DEVICES:
return SYNCED_DEVICES_ID;
case SECTION_HEADER:
// We might have multiple section headers, so we try get unique IDs for them.
return position * PRIME_SECTION_HEADERS;
case HISTORY:
final int historyPosition = transformAdapterPositionForDataStructure(
CombinedHistoryItem.ItemType.HISTORY, position);
if (!historyCursor.moveToPosition(historyPosition)) {
return RecyclerView.NO_ID;
}
final int historyIdCol = historyCursor.getColumnIndexOrThrow(BrowserContract.Combined.HISTORY_ID);
final long historyId = historyCursor.getLong(historyIdCol);
if (historyId != -1) {
return historyId;
}
final int bookmarkIdCol = historyCursor.getColumnIndexOrThrow(BrowserContract.Combined.BOOKMARK_ID);
final long bookmarkId = historyCursor.getLong(bookmarkIdCol);
// Avoid clashing with historyId.
return bookmarkId * PRIME_BOOKMARKS;
default:
throw new IllegalStateException("Unexpected Home Panel item type");
}
}
/**
* Add only the SectionHeaders that have history items within their range to a SparseArray, where the
* array index is the position of the header in the history-only (no clients) ordering.
* @param c data Cursor
* @param sparseArray SparseArray to populate
*/
@UiThread
private void populateSectionHeaders(Cursor c, SparseArray<SectionHeader> sparseArray) {
ThreadUtils.assertOnUiThread();
sparseArray.clear();
if (c == null || !c.moveToFirst()) {
return;
}
SectionHeader section = null;
do {
final int historyPosition = c.getPosition();
final long visitTime = c.getLong(c.getColumnIndexOrThrow(BrowserContract.History.DATE_LAST_VISITED));
final SectionHeader itemSection = getSectionFromTime(visitTime);
if (section != itemSection) {
section = itemSection;
sparseArray.append(historyPosition + sparseArray.size() + getNumVisibleSmartFolders(), section);
}
if (section == SectionHeader.OLDER_THAN_SIX_MONTHS) {
break;
}
} while (c.moveToNext());
}
private static String getSectionHeaderTitle(SectionHeader section) {
return sectionDateRangeArray[section.ordinal()].displayName;
}
private static SectionHeader getSectionFromTime(long time) {
for (int i = 0; i < SectionHeader.OLDER_THAN_SIX_MONTHS.ordinal(); i++) {
if (time > sectionDateRangeArray[i].start) {
return SectionHeader.values()[i];
}
}
return SectionHeader.OLDER_THAN_SIX_MONTHS;
}
/**
* Returns the number of section headers before the given history item at the adapter position.
* @param position position in the adapter
*/
private int getHeadersBefore(int position) {
// Skip the first header case because there will always be a header.
for (int i = 1; i < sectionHeaders.size(); i++) {
// If the position of the header is greater than the history position,
// return the number of headers tested.
if (sectionHeaders.keyAt(i) > position) {
return i;
}
}
return sectionHeaders.size();
}
@Override
public HomeContextMenuInfo makeContextMenuInfoFromPosition(View view, int position) {
final CombinedHistoryItem.ItemType itemType = getItemTypeForPosition(position);
if (itemType == CombinedHistoryItem.ItemType.HISTORY) {
final HomeContextMenuInfo info = new HomeContextMenuInfo(view, position, -1);
historyCursor.moveToPosition(transformAdapterPositionForDataStructure(CombinedHistoryItem.ItemType.HISTORY, position));
return populateHistoryInfoFromCursor(info, historyCursor);
}
return null;
}
protected static HomeContextMenuInfo populateHistoryInfoFromCursor(HomeContextMenuInfo info, Cursor cursor) {
info.url = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.URL));
info.title = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.TITLE));
info.historyId = cursor.getInt(cursor.getColumnIndexOrThrow(BrowserContract.Combined.HISTORY_ID));
info.itemType = HomeContextMenuInfo.RemoveItemType.HISTORY;
final int bookmarkIdCol = cursor.getColumnIndexOrThrow(BrowserContract.Combined.BOOKMARK_ID);
if (cursor.isNull(bookmarkIdCol)) {
// If this is a combined cursor, we may get a history item without a
// bookmark, in which case the bookmarks ID column value will be null.
info.bookmarkId = -1;
} else {
info.bookmarkId = cursor.getInt(bookmarkIdCol);
}
return info;
}
}
|