1 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
2  * This Source Code Form is subject to the terms of the Mozilla Public
3  * License, v. 2.0. If a copy of the MPL was not distributed with this
4  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 
6 package org.mozilla.gecko;
7 
8 import java.util.HashMap;
9 import java.util.Iterator;
10 import java.util.List;
11 import java.util.concurrent.CopyOnWriteArrayList;
12 import java.util.concurrent.atomic.AtomicInteger;
13 
14 import android.support.annotation.Nullable;
15 import org.json.JSONException;
16 import org.json.JSONObject;
17 
18 import org.mozilla.gecko.annotation.JNITarget;
19 import org.mozilla.gecko.annotation.RobocopTarget;
20 import org.mozilla.gecko.AppConstants.Versions;
21 import org.mozilla.gecko.db.BrowserDB;
22 import org.mozilla.gecko.gfx.LayerView;
23 import org.mozilla.gecko.mozglue.SafeIntent;
24 import org.mozilla.gecko.notifications.WhatsNewReceiver;
25 import org.mozilla.gecko.reader.ReaderModeUtils;
26 import org.mozilla.gecko.util.GeckoEventListener;
27 import org.mozilla.gecko.util.ThreadUtils;
28 
SessionTab(String title, String url, boolean isSelected, JSONObject tabObject)29 import android.accounts.Account;
30 import android.accounts.AccountManager;
31 import android.accounts.OnAccountsUpdateListener;
32 import android.content.ContentResolver;
33 import android.content.Context;
34 import android.database.ContentObserver;
35 import android.database.sqlite.SQLiteException;
getTitle()36 import android.graphics.Color;
37 import android.net.Uri;
38 import android.os.Handler;
39 import android.provider.Browser;
getUrl()40 import android.support.v4.content.ContextCompat;
41 import android.util.Log;
42 
43 public class Tabs implements GeckoEventListener {
isSelected()44     private static final String LOGTAG = "GeckoTabs";
45 
46     // mOrder and mTabs are always of the same cardinality, and contain the same values.
47     private final CopyOnWriteArrayList<Tab> mOrder = new CopyOnWriteArrayList<Tab>();
getTabObject()48 
49     // All writes to mSelectedTab must be synchronized on the Tabs instance.
50     // In general, it's preferred to always use selectTab()).
51     private volatile Tab mSelectedTab;
52 
53     // All accesses to mTabs must be synchronized on the Tabs instance.
54     private final HashMap<Integer, Tab> mTabs = new HashMap<Integer, Tab>();
isAboutHomeWithoutHistory()55 
56     private AccountManager mAccountManager;
57     private OnAccountsUpdateListener mAccountListener;
58 
59     public static final int LOADURL_NONE         = 0;
60     public static final int LOADURL_NEW_TAB      = 1 << 0;
onTabRead(SessionTab tab)61     public static final int LOADURL_USER_ENTERED = 1 << 1;
62     public static final int LOADURL_PRIVATE      = 1 << 2;
63     public static final int LOADURL_PINNED       = 1 << 3;
64     public static final int LOADURL_DELAY_LOAD   = 1 << 4;
65     public static final int LOADURL_DESKTOP      = 1 << 5;
66     public static final int LOADURL_BACKGROUND   = 1 << 6;
67     /** Indicates the url has been specified by a source external to the app. */
68     public static final int LOADURL_EXTERNAL     = 1 << 7;
onClosedTabsRead(final JSONArray closedTabs)69     /** Indicates the tab is the first shown after Firefox is hidden and restored. */
70     public static final int LOADURL_FIRST_AFTER_ACTIVITY_UNHIDDEN = 1 << 8;
71 
72     private static final long PERSIST_TABS_AFTER_MILLISECONDS = 1000 * 2;
73 
74     public static final int INVALID_TAB_ID = -1;
75 
76     private static final AtomicInteger sTabId = new AtomicInteger(0);
77     private volatile boolean mInitialTabsAdded;
parse(String... sessionStrings)78 
79     private Context mAppContext;
80     private LayerView mLayerView;
81     private ContentObserver mBookmarksContentObserver;
82     private PersistTabsRunnable mPersistTabsRunnable;
83     private int mPrivateClearColor;
84 
85     private static class PersistTabsRunnable implements Runnable {
86         private final BrowserDB db;
87         private final Context context;
88         private final Iterable<Tab> tabs;
89 
90         public PersistTabsRunnable(final Context context, Iterable<Tab> tabsInOrder) {
91             this.context = context;
92             this.db = BrowserDB.from(context);
93             this.tabs = tabsInOrder;
94         }
95 
96         @Override
97         public void run() {
98             try {
99                 db.getTabsAccessor().persistLocalTabs(context.getContentResolver(), tabs);
100             } catch (SQLiteException e) {
101                 Log.w(LOGTAG, "Error persisting local tabs", e);
102             }
103         }
104     };
105 
106     private Tabs() {
107         EventDispatcher.getInstance().registerGeckoThreadListener(this,
108             "Tab:Added",
109             "Tab:Close",
110             "Tab:Select",
111             "Content:LocationChange",
112             "Content:SecurityChange",
113             "Content:StateChange",
114             "Content:LoadError",
115             "Content:PageShow",
116             "DOMTitleChanged",
117             "Link:Favicon",
118             "Link:Touchicon",
119             "Link:Feed",
120             "Link:OpenSearch",
121             "DesktopMode:Changed",
122             "Tab:StreamStart",
123             "Tab:StreamStop",
124             "Tab:AudioPlayingChange",
125             "Tab:MediaPlaybackChange");
126 
127         mPrivateClearColor = Color.RED;
128 
129     }
130 
131     public synchronized void attachToContext(Context context, LayerView layerView) {
132         final Context appContext = context.getApplicationContext();
133         if (mAppContext == appContext) {
134             return;
135         }
136 
137         if (mAppContext != null) {
138             // This should never happen.
139             Log.w(LOGTAG, "The application context has changed!");
140         }
141 
142         mAppContext = appContext;
143         mLayerView = layerView;
144         mPrivateClearColor = ContextCompat.getColor(context, R.color.tabs_tray_grey_pressed);
145         mAccountManager = AccountManager.get(appContext);
146 
147         mAccountListener = new OnAccountsUpdateListener() {
148             @Override
149             public void onAccountsUpdated(Account[] accounts) {
150                 queuePersistAllTabs();
151             }
152         };
153 
154         // The listener will run on the background thread (see 2nd argument).
155         mAccountManager.addOnAccountsUpdatedListener(mAccountListener, ThreadUtils.getBackgroundHandler(), false);
156 
157         if (mBookmarksContentObserver != null) {
158             // It's safe to use the db here since we aren't doing any I/O.
159             final GeckoProfile profile = GeckoProfile.get(context);
160             BrowserDB.from(profile).registerBookmarkObserver(getContentResolver(), mBookmarksContentObserver);
161         }
162     }
163 
164     /**
165      * Gets the tab count corresponding to the private state of the selected
166      * tab.
167      *
168      * If the selected tab is a non-private tab, this will return the number of
169      * non-private tabs; likewise, if this is a private tab, this will return
170      * the number of private tabs.
171      *
172      * @return the number of tabs in the current private state
173      */
174     public synchronized int getDisplayCount() {
175         // Once mSelectedTab is non-null, it cannot be null for the remainder
176         // of the object's lifetime.
177         boolean getPrivate = mSelectedTab != null && mSelectedTab.isPrivate();
178         int count = 0;
179         for (Tab tab : mOrder) {
180             if (tab.isPrivate() == getPrivate) {
181                 count++;
182             }
183         }
184         return count;
185     }
186 
187     public int isOpen(String url) {
188         for (Tab tab : mOrder) {
189             if (tab.getURL().equals(url)) {
190                 return tab.getId();
191             }
192         }
193         return -1;
194     }
195 
196     // Must be synchronized to avoid racing on mBookmarksContentObserver.
197     private void lazyRegisterBookmarkObserver() {
198         if (mBookmarksContentObserver == null) {
199             mBookmarksContentObserver = new ContentObserver(null) {
200                 @Override
201                 public void onChange(boolean selfChange) {
202                     for (Tab tab : mOrder) {
203                         tab.updateBookmark();
204                     }
205                 }
206             };
207 
208             // It's safe to use the db here since we aren't doing any I/O.
209             final GeckoProfile profile = GeckoProfile.get(mAppContext);
210             BrowserDB.from(profile).registerBookmarkObserver(getContentResolver(), mBookmarksContentObserver);
211         }
212     }
213 
214     private Tab addTab(int id, String url, boolean external, int parentId, String title, boolean isPrivate, int tabIndex) {
215         final Tab tab = isPrivate ? new PrivateTab(mAppContext, id, url, external, parentId, title) :
216                                     new Tab(mAppContext, id, url, external, parentId, title);
217         synchronized (this) {
218             lazyRegisterBookmarkObserver();
219             mTabs.put(id, tab);
220 
221             if (tabIndex > -1) {
222                 mOrder.add(tabIndex, tab);
223             } else {
224                 mOrder.add(tab);
225             }
226         }
227 
228         // Suppress the ADDED event to prevent animation of tabs created via session restore.
229         if (mInitialTabsAdded) {
230             notifyListeners(tab, TabEvents.ADDED,
231                     Integer.toString(getPrivacySpecificTabIndex(tabIndex, isPrivate)));
232         }
233 
234         return tab;
235     }
236 
237     // Return the index, among those tabs whose privacy setting matches isPrivate, of the tab at
238     // position index in mOrder.  Returns -1, for "new last tab", when index is -1.
239     private int getPrivacySpecificTabIndex(int index, boolean isPrivate) {
240         int privacySpecificIndex = -1;
241         for (int i = 0; i <= index; i++) {
242             final Tab tab = mOrder.get(i);
243             if (tab.isPrivate() == isPrivate) {
244                 privacySpecificIndex++;
245             }
246         }
247         return privacySpecificIndex;
248     }
249 
250     public synchronized void removeTab(int id) {
251         if (mTabs.containsKey(id)) {
252             Tab tab = getTab(id);
253             mOrder.remove(tab);
254             mTabs.remove(id);
255         }
256     }
257 
258     public synchronized Tab selectTab(int id) {
259         if (!mTabs.containsKey(id))
260             return null;
261 
262         final Tab oldTab = getSelectedTab();
263         final Tab tab = mTabs.get(id);
264 
265         // This avoids a NPE below, but callers need to be careful to
266         // handle this case.
267         if (tab == null || oldTab == tab) {
268             return tab;
269         }
270 
271         mSelectedTab = tab;
272         notifyListeners(tab, TabEvents.SELECTED);
273 
274         if (mLayerView != null) {
275             mLayerView.setClearColor(getTabColor(tab));
276         }
277 
278         if (oldTab != null) {
279             notifyListeners(oldTab, TabEvents.UNSELECTED);
280         }
281 
282         // Pass a message to Gecko to update tab state in BrowserApp.
283         GeckoAppShell.notifyObservers("Tab:Selected", String.valueOf(tab.getId()));
284         return tab;
285     }
286 
287     public synchronized boolean selectLastTab() {
288         if (mOrder.isEmpty()) {
289             return false;
290         }
291 
292         selectTab(mOrder.get(mOrder.size() - 1).getId());
293         return true;
294     }
295 
296     private int getIndexOf(Tab tab) {
297         return mOrder.lastIndexOf(tab);
298     }
299 
300     private Tab getNextTabFrom(Tab tab, boolean getPrivate) {
301         int numTabs = mOrder.size();
302         int index = getIndexOf(tab);
303         for (int i = index + 1; i < numTabs; i++) {
304             Tab next = mOrder.get(i);
305             if (next.isPrivate() == getPrivate) {
306                 return next;
307             }
308         }
309         return null;
310     }
311 
312     private Tab getPreviousTabFrom(Tab tab, boolean getPrivate) {
313         int index = getIndexOf(tab);
314         for (int i = index - 1; i >= 0; i--) {
315             Tab prev = mOrder.get(i);
316             if (prev.isPrivate() == getPrivate) {
317                 return prev;
318             }
319         }
320         return null;
321     }
322 
323     /**
324      * Gets the selected tab.
325      *
326      * The selected tab can be null if we're doing a session restore after a
327      * crash and Gecko isn't ready yet.
328      *
329      * @return the selected tab, or null if no tabs exist
330      */
331     @Nullable
332     public Tab getSelectedTab() {
333         return mSelectedTab;
334     }
335 
336     public boolean isSelectedTab(Tab tab) {
337         return tab != null && tab == mSelectedTab;
338     }
339 
340     public boolean isSelectedTabId(int tabId) {
341         final Tab selected = mSelectedTab;
342         return selected != null && selected.getId() == tabId;
343     }
344 
345     @RobocopTarget
346     public synchronized Tab getTab(int id) {
347         if (id == -1)
348             return null;
349 
350         if (mTabs.size() == 0)
351             return null;
352 
353         if (!mTabs.containsKey(id))
354            return null;
355 
356         return mTabs.get(id);
357     }
358 
359     public synchronized Tab getTabForApplicationId(final String applicationId) {
360         if (applicationId == null) {
361             return null;
362         }
363 
364         for (final Tab tab : mOrder) {
365             if (applicationId.equals(tab.getApplicationId())) {
366                 return tab;
367             }
368         }
369 
370         return null;
371     }
372 
373     /** Close tab and then select the default next tab */
374     @RobocopTarget
375     public synchronized void closeTab(Tab tab) {
376         closeTab(tab, getNextTab(tab));
377     }
378 
379     public synchronized void closeTab(Tab tab, Tab nextTab) {
380         closeTab(tab, nextTab, false);
381     }
382 
383     public synchronized void closeTab(Tab tab, boolean showUndoToast) {
384         closeTab(tab, getNextTab(tab), showUndoToast);
385     }
386 
387     /** Close tab and then select nextTab */
388     public synchronized void closeTab(final Tab tab, Tab nextTab, boolean showUndoToast) {
389         if (tab == null)
390             return;
391 
392         int tabId = tab.getId();
393         removeTab(tabId);
394 
395         if (nextTab == null) {
396             nextTab = loadUrl(AboutPages.HOME, LOADURL_NEW_TAB);
397         }
398 
399         selectTab(nextTab.getId());
400 
401         tab.onDestroy();
402 
403         final JSONObject args = new JSONObject();
404         try {
405             args.put("tabId", String.valueOf(tabId));
406             args.put("showUndoToast", showUndoToast);
407         } catch (JSONException e) {
408             Log.e(LOGTAG, "Error building Tab:Closed arguments: " + e);
409         }
410 
411         // Pass a message to Gecko to update tab state in BrowserApp
412         GeckoAppShell.notifyObservers("Tab:Closed", args.toString());
413     }
414 
415     /** Return the tab that will be selected by default after this one is closed */
416     public Tab getNextTab(Tab tab) {
417         Tab selectedTab = getSelectedTab();
418         if (selectedTab != tab)
419             return selectedTab;
420 
421         boolean getPrivate = tab.isPrivate();
422         Tab nextTab = getNextTabFrom(tab, getPrivate);
423         if (nextTab == null)
424             nextTab = getPreviousTabFrom(tab, getPrivate);
425         if (nextTab == null && getPrivate) {
426             // If there are no private tabs remaining, get the last normal tab
427             Tab lastTab = mOrder.get(mOrder.size() - 1);
428             if (!lastTab.isPrivate()) {
429                 nextTab = lastTab;
430             } else {
431                 nextTab = getPreviousTabFrom(lastTab, false);
432             }
433         }
434 
435         Tab parent = getTab(tab.getParentId());
436         if (parent != null) {
437             // If the next tab is a sibling, switch to it. Otherwise go back to the parent.
438             if (nextTab != null && nextTab.getParentId() == tab.getParentId())
439                 return nextTab;
440             else
441                 return parent;
442         }
443         return nextTab;
444     }
445 
446     public Iterable<Tab> getTabsInOrder() {
447         return mOrder;
448     }
449 
450     /**
451      * @return the current GeckoApp instance, or throws if
452      *         we aren't correctly initialized.
453      */
454     private synchronized Context getAppContext() {
455         if (mAppContext == null) {
456             throw new IllegalStateException("Tabs not initialized with a GeckoApp instance.");
457         }
458         return mAppContext;
459     }
460 
461     public ContentResolver getContentResolver() {
462         return getAppContext().getContentResolver();
463     }
464 
465     // Make Tabs a singleton class.
466     private static class TabsInstanceHolder {
467         private static final Tabs INSTANCE = new Tabs();
468     }
469 
470     @RobocopTarget
471     public static Tabs getInstance() {
472        return Tabs.TabsInstanceHolder.INSTANCE;
473     }
474 
475     // GeckoEventListener implementation
476     @Override
477     public void handleMessage(String event, JSONObject message) {
478         Log.d(LOGTAG, "handleMessage: " + event);
479         try {
480             // All other events handled below should contain a tabID property
481             int id = message.getInt("tabID");
482             Tab tab = getTab(id);
483 
484             // "Tab:Added" is a special case because tab will be null if the tab was just added
485             if (event.equals("Tab:Added")) {
486                 String url = message.isNull("uri") ? null : message.getString("uri");
487 
488                 if (message.getBoolean("cancelEditMode")) {
489                     final Tab oldTab = getSelectedTab();
490                     if (oldTab != null) {
491                         oldTab.setIsEditing(false);
492                     }
493                 }
494 
495                 if (message.getBoolean("stub")) {
496                     if (tab == null) {
497                         // Tab was already closed; abort
498                         return;
499                     }
500                 } else {
501                     tab = addTab(id, url, message.getBoolean("external"),
502                                           message.getInt("parentId"),
503                                           message.getString("title"),
504                                           message.getBoolean("isPrivate"),
505                                           message.getInt("tabIndex"));
506                     // If we added the tab as a stub, we should have already
507                     // selected it, so ignore this flag for stubbed tabs.
508                     if (message.getBoolean("selected"))
509                         selectTab(id);
510                 }
511 
512                 if (message.getBoolean("delayLoad"))
513                     tab.setState(Tab.STATE_DELAYED);
514                 if (message.getBoolean("desktopMode"))
515                     tab.setDesktopMode(true);
516                 return;
517             }
518 
519             // Tab was already closed; abort
520             if (tab == null)
521                 return;
522 
523             if (event.equals("Tab:Close")) {
524                 closeTab(tab);
525             } else if (event.equals("Tab:Select")) {
526                 selectTab(tab.getId());
527             } else if (event.equals("Content:LocationChange")) {
528                 tab.handleLocationChange(message);
529             } else if (event.equals("Content:SecurityChange")) {
530                 tab.updateIdentityData(message.getJSONObject("identity"));
531                 notifyListeners(tab, TabEvents.SECURITY_CHANGE);
532             } else if (event.equals("Content:StateChange")) {
533                 int state = message.getInt("state");
534                 if ((state & GeckoAppShell.WPL_STATE_IS_NETWORK) != 0) {
535                     if ((state & GeckoAppShell.WPL_STATE_START) != 0) {
536                         boolean restoring = message.getBoolean("restoring");
537                         tab.handleDocumentStart(restoring, message.getString("uri"));
538                         notifyListeners(tab, Tabs.TabEvents.START);
539                     } else if ((state & GeckoAppShell.WPL_STATE_STOP) != 0) {
540                         tab.handleDocumentStop(message.getBoolean("success"));
541                         notifyListeners(tab, Tabs.TabEvents.STOP);
542                     }
543                 }
544             } else if (event.equals("Content:LoadError")) {
545                 tab.handleContentLoaded();
546                 notifyListeners(tab, Tabs.TabEvents.LOAD_ERROR);
547             } else if (event.equals("Content:PageShow")) {
548                 tab.setLoadedFromCache(message.getBoolean("fromCache"));
549                 tab.updateUserRequested(message.getString("userRequested"));
550                 notifyListeners(tab, TabEvents.PAGE_SHOW);
551             } else if (event.equals("DOMTitleChanged")) {
552                 tab.updateTitle(message.getString("title"));
553             } else if (event.equals("Link:Favicon")) {
554                 // Add the favicon to the set of available icons for this tab.
555 
556                 tab.addFavicon(message.getString("href"), message.getInt("size"), message.getString("mime"));
557 
558                 // Load the favicon. If the tab is still loading, we actually do the load once the
559                 // page has loaded, in an attempt to prevent the favicon load from having a
560                 // detrimental effect on page load time.
561                 if (tab.getState() != Tab.STATE_LOADING) {
562                     tab.loadFavicon();
563                 }
564             } else if (event.equals("Link:Touchicon")) {
565                 tab.addTouchicon(message.getString("href"), message.getInt("size"), message.getString("mime"));
566             } else if (event.equals("Link:Feed")) {
567                 tab.setHasFeeds(true);
568                 notifyListeners(tab, TabEvents.LINK_FEED);
569             } else if (event.equals("Link:OpenSearch")) {
570                 boolean visible = message.getBoolean("visible");
571                 tab.setHasOpenSearch(visible);
572             } else if (event.equals("DesktopMode:Changed")) {
573                 tab.setDesktopMode(message.getBoolean("desktopMode"));
574                 notifyListeners(tab, TabEvents.DESKTOP_MODE_CHANGE);
575             } else if (event.equals("Tab:StreamStart")) {
576                 tab.setRecording(true);
577                 notifyListeners(tab, TabEvents.RECORDING_CHANGE);
578             } else if (event.equals("Tab:StreamStop")) {
579                 tab.setRecording(false);
580                 notifyListeners(tab, TabEvents.RECORDING_CHANGE);
581             } else if (event.equals("Tab:AudioPlayingChange")) {
582                 tab.setIsAudioPlaying(message.getBoolean("isAudioPlaying"));
583                 notifyListeners(tab, TabEvents.AUDIO_PLAYING_CHANGE);
584             } else if (event.equals("Tab:MediaPlaybackChange")) {
585                 final String status = message.getString("status");
586                 if (status.equals("resume")) {
587                     notifyListeners(tab, TabEvents.MEDIA_PLAYING_RESUME);
588                 } else {
589                     tab.setIsMediaPlaying(status.equals("start"));
590                     notifyListeners(tab, TabEvents.MEDIA_PLAYING_CHANGE);
591                 }
592             }
593 
594         } catch (Exception e) {
595             Log.w(LOGTAG, "handleMessage threw for " + event, e);
596         }
597     }
598 
599     public void refreshThumbnails() {
600         final BrowserDB db = BrowserDB.from(mAppContext);
601         ThreadUtils.postToBackgroundThread(new Runnable() {
602             @Override
603             public void run() {
604                 for (final Tab tab : mOrder) {
605                     if (tab.getThumbnail() == null) {
606                         tab.loadThumbnailFromDB(db);
607                     }
608                 }
609             }
610         });
611     }
612 
613     public interface OnTabsChangedListener {
614         void onTabChanged(Tab tab, TabEvents msg, String data);
615     }
616 
617     private static final List<OnTabsChangedListener> TABS_CHANGED_LISTENERS = new CopyOnWriteArrayList<OnTabsChangedListener>();
618 
619     public static void registerOnTabsChangedListener(OnTabsChangedListener listener) {
620         TABS_CHANGED_LISTENERS.add(listener);
621     }
622 
623     public static void unregisterOnTabsChangedListener(OnTabsChangedListener listener) {
624         TABS_CHANGED_LISTENERS.remove(listener);
625     }
626 
627     public enum TabEvents {
628         CLOSED,
629         START,
630         LOADED,
631         LOAD_ERROR,
632         STOP,
633         FAVICON,
634         THUMBNAIL,
635         TITLE,
636         SELECTED,
637         UNSELECTED,
638         ADDED,
639         RESTORED,
640         LOCATION_CHANGE,
641         MENU_UPDATED,
642         PAGE_SHOW,
643         LINK_FEED,
644         SECURITY_CHANGE,
645         DESKTOP_MODE_CHANGE,
646         RECORDING_CHANGE,
647         BOOKMARK_ADDED,
648         BOOKMARK_REMOVED,
649         AUDIO_PLAYING_CHANGE,
650         OPENED_FROM_TABS_TRAY,
651         MEDIA_PLAYING_CHANGE,
652         MEDIA_PLAYING_RESUME
653     }
654 
655     public void notifyListeners(Tab tab, TabEvents msg) {
656         notifyListeners(tab, msg, "");
657     }
658 
659     public void notifyListeners(final Tab tab, final TabEvents msg, final String data) {
660         if (tab == null &&
661             msg != TabEvents.RESTORED) {
662             throw new IllegalArgumentException("onTabChanged:" + msg + " must specify a tab.");
663         }
664 
665         ThreadUtils.postToUiThread(new Runnable() {
666             @Override
667             public void run() {
668                 onTabChanged(tab, msg, data);
669 
670                 if (TABS_CHANGED_LISTENERS.isEmpty()) {
671                     return;
672                 }
673 
674                 Iterator<OnTabsChangedListener> items = TABS_CHANGED_LISTENERS.iterator();
675                 while (items.hasNext()) {
676                     items.next().onTabChanged(tab, msg, data);
677                 }
678             }
679         });
680     }
681 
682     private void onTabChanged(Tab tab, Tabs.TabEvents msg, Object data) {
683         switch (msg) {
684             // We want the tab record to have an accurate favicon, so queue
685             // the persisting of tabs when it changes.
686             case FAVICON:
687             case LOCATION_CHANGE:
688                 queuePersistAllTabs();
689                 break;
690             case RESTORED:
691                 mInitialTabsAdded = true;
692                 break;
693 
694             // When one tab is deselected, another one is always selected, so only
695             // queue a single persist operation. When tabs are added/closed, they
696             // are also selected/unselected, so it would be redundant to also listen
697             // for ADDED/CLOSED events.
698             case SELECTED:
699                 if (mLayerView != null) {
700                     mLayerView.setSurfaceBackgroundColor(getTabColor(tab));
701                     mLayerView.setPaintState(LayerView.PAINT_START);
702                 }
703                 queuePersistAllTabs();
704             case UNSELECTED:
705                 tab.onChange();
706                 break;
707             default:
708                 break;
709         }
710     }
711 
712     /**
713      * Queues a request to persist tabs after PERSIST_TABS_AFTER_MILLISECONDS
714      * milliseconds have elapsed. If any existing requests are already queued then
715      * those requests are removed.
716      */
717     private void queuePersistAllTabs() {
718         final Handler backgroundHandler = ThreadUtils.getBackgroundHandler();
719 
720         // Note: Its safe to modify the runnable here because all of the callers are on the same thread.
721         if (mPersistTabsRunnable != null) {
722             backgroundHandler.removeCallbacks(mPersistTabsRunnable);
723             mPersistTabsRunnable = null;
724         }
725 
726         mPersistTabsRunnable = new PersistTabsRunnable(mAppContext, getTabsInOrder());
727         backgroundHandler.postDelayed(mPersistTabsRunnable, PERSIST_TABS_AFTER_MILLISECONDS);
728     }
729 
730     /**
731      * Looks for an open tab with the given URL.
732      * @param url       the URL of the tab we're looking for
733      *
734      * @return first Tab with the given URL, or null if there is no such tab.
735      */
736     public Tab getFirstTabForUrl(String url) {
737         return getFirstTabForUrlHelper(url, null);
738     }
739 
740     /**
741      * Looks for an open tab with the given URL and private state.
742      * @param url       the URL of the tab we're looking for
743      * @param isPrivate if true, only look for tabs that are private. if false,
744      *                  only look for tabs that are non-private.
745      *
746      * @return first Tab with the given URL, or null if there is no such tab.
747      */
748     public Tab getFirstTabForUrl(String url, boolean isPrivate) {
749         return getFirstTabForUrlHelper(url, isPrivate);
750     }
751 
752     private Tab getFirstTabForUrlHelper(String url, Boolean isPrivate) {
753         if (url == null) {
754             return null;
755         }
756 
757         for (Tab tab : mOrder) {
758             if (isPrivate != null && isPrivate != tab.isPrivate()) {
759                 continue;
760             }
761             if (url.equals(tab.getURL())) {
762                 return tab;
763             }
764         }
765 
766         return null;
767     }
768 
769     /**
770      * Looks for a reader mode enabled open tab with the given URL and private
771      * state.
772      *
773      * @param url
774      *            The URL of the tab we're looking for. The url parameter can be
775      *            the actual article URL or the reader mode article URL.
776      * @param isPrivate
777      *            If true, only look for tabs that are private. If false, only
778      *            look for tabs that are not private.
779      *
780      * @return The first Tab with the given URL, or null if there is no such
781      *         tab.
782      */
783     public Tab getFirstReaderTabForUrl(String url, boolean isPrivate) {
784         if (url == null) {
785             return null;
786         }
787 
788         url = ReaderModeUtils.stripAboutReaderUrl(url);
789 
790         for (Tab tab : mOrder) {
791             if (isPrivate != tab.isPrivate()) {
792                 continue;
793             }
794             String tabUrl = tab.getURL();
795             if (AboutPages.isAboutReader(tabUrl)) {
796                 tabUrl = ReaderModeUtils.stripAboutReaderUrl(tabUrl);
797                 if (url.equals(tabUrl)) {
798                     return tab;
799                 }
800             }
801         }
802 
803         return null;
804     }
805 
806     /**
807      * Loads a tab with the given URL in the currently selected tab.
808      *
809      * @param url URL of page to load, or search term used if searchEngine is given
810      */
811     @RobocopTarget
812     public Tab loadUrl(String url) {
813         return loadUrl(url, LOADURL_NONE);
814     }
815 
816     /**
817      * Loads a tab with the given URL.
818      *
819      * @param url   URL of page to load, or search term used if searchEngine is given
820      * @param flags flags used to load tab
821      *
822      * @return      the Tab if a new one was created; null otherwise
823      */
824     @RobocopTarget
825     public Tab loadUrl(String url, int flags) {
826         return loadUrl(url, null, -1, null, flags);
827     }
828 
829     public Tab loadUrlWithIntentExtras(final String url, final SafeIntent intent, final int flags) {
830         // We can't directly create a listener to tell when the user taps on the "What's new"
831         // notification, so we use this intent handling as a signal that they tapped the notification.
832         if (intent.getBooleanExtra(WhatsNewReceiver.EXTRA_WHATSNEW_NOTIFICATION, false)) {
833             Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.NOTIFICATION,
834                     WhatsNewReceiver.EXTRA_WHATSNEW_NOTIFICATION);
835         }
836 
837         // Note: we don't get the URL from the intent so the calling
838         // method has the opportunity to change the URL if applicable.
839         return loadUrl(url, null, -1, intent, flags);
840     }
841 
842     public Tab loadUrl(final String url, final String searchEngine, final int parentId, final int flags) {
843         return loadUrl(url, searchEngine, parentId, null, flags);
844     }
845 
846     /**
847      * Loads a tab with the given URL.
848      *
849      * @param url          URL of page to load, or search term used if searchEngine is given
850      * @param searchEngine if given, the search engine with this name is used
851      *                     to search for the url string; if null, the URL is loaded directly
852      * @param parentId     ID of this tab's parent, or -1 if it has no parent
853      * @param intent       an intent whose extras are used to modify the request
854      * @param flags        flags used to load tab
855      *
856      * @return             the Tab if a new one was created; null otherwise
857      */
858     public Tab loadUrl(final String url, final String searchEngine, final int parentId,
859                    final SafeIntent intent, final int flags) {
860         JSONObject args = new JSONObject();
861         Tab tabToSelect = null;
862         boolean delayLoad = (flags & LOADURL_DELAY_LOAD) != 0;
863 
864         // delayLoad implies background tab
865         boolean background = delayLoad || (flags & LOADURL_BACKGROUND) != 0;
866 
867         try {
868             boolean isPrivate = (flags & LOADURL_PRIVATE) != 0;
869             boolean userEntered = (flags & LOADURL_USER_ENTERED) != 0;
870             boolean desktopMode = (flags & LOADURL_DESKTOP) != 0;
871             boolean external = (flags & LOADURL_EXTERNAL) != 0;
872             final boolean isFirstShownAfterActivityUnhidden = (flags & LOADURL_FIRST_AFTER_ACTIVITY_UNHIDDEN) != 0;
873 
874             args.put("url", url);
875             args.put("engine", searchEngine);
876             args.put("parentId", parentId);
877             args.put("userEntered", userEntered);
878             args.put("isPrivate", isPrivate);
879             args.put("pinned", (flags & LOADURL_PINNED) != 0);
880             args.put("desktopMode", desktopMode);
881 
882             final boolean needsNewTab;
883             final String applicationId = (intent == null) ? null :
884                     intent.getStringExtra(Browser.EXTRA_APPLICATION_ID);
885             if (applicationId == null) {
886                 needsNewTab = (flags & LOADURL_NEW_TAB) != 0;
887             } else {
888                 // If you modify this code, be careful that intent != null.
889                 final boolean extraCreateNewTab = intent.getBooleanExtra(Browser.EXTRA_CREATE_NEW_TAB, false);
890                 final Tab applicationTab = getTabForApplicationId(applicationId);
891                 if (applicationTab == null || extraCreateNewTab) {
892                     needsNewTab = true;
893                 } else {
894                     needsNewTab = false;
895                     delayLoad = false;
896                     background = false;
897 
898                     tabToSelect = applicationTab;
899                     final int tabToSelectId = tabToSelect.getId();
900                     args.put("tabID", tabToSelectId);
901 
902                     // This must be called before the "Tab:Load" event is sent. I think addTab gets
903                     // away with it because having "newTab" == true causes the selected tab to be
904                     // updated in JS for the "Tab:Load" event but "newTab" is false in our case.
905                     // This makes me think the other selectTab is not necessary (bug 1160673).
906                     //
907                     // Note: that makes the later call redundant but selectTab exits early so I'm
908                     // fine not adding the complex logic to avoid calling it again.
909                     selectTab(tabToSelect.getId());
910                 }
911             }
912 
913             args.put("newTab", needsNewTab);
914             args.put("delayLoad", delayLoad);
915             args.put("selected", !background);
916 
917             if (needsNewTab) {
918                 int tabId = getNextTabId();
919                 args.put("tabID", tabId);
920 
921                 // The URL is updated for the tab once Gecko responds with the
922                 // Tab:Added message. We can preliminarily set the tab's URL as
923                 // long as it's a valid URI.
924                 String tabUrl = (url != null && Uri.parse(url).getScheme() != null) ? url : null;
925 
926                 // Add the new tab to the end of the tab order.
927                 final int tabIndex = -1;
928 
929                 tabToSelect = addTab(tabId, tabUrl, external, parentId, url, isPrivate, tabIndex);
930                 tabToSelect.setDesktopMode(desktopMode);
931                 tabToSelect.setApplicationId(applicationId);
932                 if (isFirstShownAfterActivityUnhidden) {
933                     // We just opened Firefox so we want to show
934                     // the toolbar but not animate it to avoid jank.
935                     tabToSelect.setShouldShowToolbarWithoutAnimationOnFirstSelection(true);
936                 }
937             }
938         } catch (Exception e) {
939             Log.w(LOGTAG, "Error building JSON arguments for loadUrl.", e);
940         }
941 
942         GeckoAppShell.notifyObservers("Tab:Load", args.toString());
943 
944         if (tabToSelect == null) {
945             return null;
946         }
947 
948         if (!delayLoad && !background) {
949             selectTab(tabToSelect.getId());
950         }
951 
952         // Load favicon instantly for about:home page because it's already cached
953         if (AboutPages.isBuiltinIconPage(url)) {
954             tabToSelect.loadFavicon();
955         }
956 
957         return tabToSelect;
958     }
959 
960     public Tab addTab() {
961         return loadUrl(AboutPages.HOME, Tabs.LOADURL_NEW_TAB);
962     }
963 
964     public Tab addPrivateTab() {
965         return loadUrl(AboutPages.PRIVATEBROWSING, Tabs.LOADURL_NEW_TAB | Tabs.LOADURL_PRIVATE);
966     }
967 
968     /**
969      * Open the url as a new tab, and mark the selected tab as its "parent".
970      *
971      * If the url is already open in a tab, the existing tab is selected.
972      * Use this for tabs opened by the browser chrome, so users can press the
973      * "Back" button to return to the previous tab.
974      *
975      * This method will open a new private tab if the currently selected tab
976      * is also private.
977      *
978      * @param url URL of page to load
979      */
980     public void loadUrlInTab(String url) {
981         Iterable<Tab> tabs = getTabsInOrder();
982         for (Tab tab : tabs) {
983             if (url.equals(tab.getURL())) {
984                 selectTab(tab.getId());
985                 return;
986             }
987         }
988 
989         // getSelectedTab() can return null if no tab has been created yet
990         // (i.e., we're restoring a session after a crash). In these cases,
991         // don't mark any tabs as a parent.
992         int parentId = -1;
993         int flags = LOADURL_NEW_TAB;
994 
995         final Tab selectedTab = getSelectedTab();
996         if (selectedTab != null) {
997             parentId = selectedTab.getId();
998             if (selectedTab.isPrivate()) {
999                 flags = flags | LOADURL_PRIVATE;
1000             }
1001         }
1002 
1003         loadUrl(url, null, parentId, flags);
1004     }
1005 
1006     /**
1007      * Gets the next tab ID.
1008      */
1009     @JNITarget
1010     public static int getNextTabId() {
1011         return sTabId.getAndIncrement();
1012     }
1013 
1014     private int getTabColor(Tab tab) {
1015         if (tab != null) {
1016             return tab.isPrivate() ? mPrivateClearColor : Color.WHITE;
1017         }
1018 
1019         return Color.WHITE;
1020     }
1021 }
1022