// Copyright 2016 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.offlinepages;
import android.app.Activity;
import androidx.annotation.VisibleForTesting;
import org.chromium.base.ActivityState;
import org.chromium.base.ApplicationStatus;
import org.chromium.base.ApplicationStatus.ActivityStateListener;
import org.chromium.base.Log;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.chrome.browser.tab.EmptyTabObserver;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabHidingType;
import org.chromium.chrome.browser.tab.TabSelectionType;
import org.chromium.chrome.browser.tabmodel.TabModelObserver;
import org.chromium.chrome.browser.tabmodel.TabModelSelector;
import org.chromium.chrome.browser.tabmodel.TabModelSelectorSupplier;
import org.chromium.chrome.browser.tabmodel.TabModelSelectorTabModelObserver;
import org.chromium.chrome.browser.ui.messages.snackbar.SnackbarManager;
import org.chromium.chrome.browser.ui.messages.snackbar.SnackbarManager.SnackbarController;
import org.chromium.chrome.browser.ui.messages.snackbar.SnackbarManagerProvider;
import org.chromium.net.NetworkChangeNotifier;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.url.GURL;
import java.util.HashMap;
import java.util.Map;
/**
* A class that observes events for a tab which has an associated offline page. It is created when
* the first offline page is loaded in any tab. When additional offline pages are opened, they are
* all watched by the same observer. This observer will decide when to show a reload snackbar for
* those tabs. The following conditions need to be met to show the snackbar:
* <ul>
* <li>Tab has to be shown,</li>
* <li>Offline page has to be loaded,</li>
* <li>Chrome is connected to the web,</li>
* <li>Unless triggering condition is change in network, snackbar hasn't been shown for that
* tab.</li>
* </ul>
* When the last tab with offline page is closed or navigated away from, this observer stops
* listening to network changes.
*/
public class OfflinePageTabObserver extends EmptyTabObserver
implements NetworkChangeNotifier.ConnectionTypeObserver {
private static final String TAG = "OfflinePageTO";
/** Class for keeping the state of observed tabs. */
private static class TabState {
/** Whether content in a tab finished loading. */
public boolean isLoaded;
/** Whether a snackbar was shown for the tab. */
public boolean wasSnackbarSeen;
public TabState(boolean isLoaded) {
this.isLoaded = isLoaded;
this.wasSnackbarSeen = false;
}
}
private static Map<Activity, OfflinePageTabObserver> sObservers;
private final SnackbarManager mSnackbarManager;
private final SnackbarController mSnackbarController;
private final TabModelSelector mTabModelSelector;
private final TabModelSelectorTabModelObserver mTabModelObserver;
/** Map of observed tabs. */
private final Map<Integer, TabState> mObservedTabs = new HashMap<>();
private boolean mIsObservingNetworkChanges;
/** Current tab, kept track of for the network change notification. */
private Tab mCurrentTab;
private static OfflinePageTabObserver getObserverForWindowAndroid(WindowAndroid windowAndroid) {
ensureObserverMapInitialized();
Activity activity = windowAndroid.getActivity().get();
OfflinePageTabObserver observer = sObservers.get(activity);
if (observer == null) {
TabModelSelector tabModelSelector = TabModelSelectorSupplier.from(windowAndroid).get();
observer =
new OfflinePageTabObserver(
tabModelSelector,
SnackbarManagerProvider.from(windowAndroid),
createReloadSnackbarController(tabModelSelector));
sObservers.put(activity, observer);
}
return observer;
}
private static void ensureObserverMapInitialized() {
if (sObservers != null) return;
sObservers = new HashMap<>();
ApplicationStatus.registerStateListenerForAllActivities(
new ActivityStateListener() {
@Override
public void onActivityStateChange(Activity activity, int newState) {
if (newState != ActivityState.DESTROYED) return;
OfflinePageTabObserver observer = sObservers.remove(activity);
if (observer == null) return;
observer.destroy();
}
});
}
static void setObserverForTesting(Activity activity, OfflinePageTabObserver observer) {
ensureObserverMapInitialized();
sObservers.put(activity, observer);
}
/**
* Create and attach a tab observer if we don't already have one, otherwise update it.
* @param tab The tab we are adding an observer for.
*/
public static void addObserverForTab(Tab tab) {
OfflinePageTabObserver observer = getObserverForWindowAndroid(tab.getWindowAndroid());
observer.startObservingTab(tab);
observer.maybeShowReloadSnackbar(tab, false);
}
/**
* Builds a new OfflinePageTabObserver.
* @param tabModelSelector Tab model selector for the activity.
* @param snackbarManager The snackbar manager to show and dismiss snackbars.
* @param snackbarController Controller to use to build the snackbar.
*/
OfflinePageTabObserver(
TabModelSelector tabModelSelector,
SnackbarManager snackbarManager,
SnackbarController snackbarController) {
mSnackbarManager = snackbarManager;
mSnackbarController = snackbarController;
mTabModelSelector = tabModelSelector;
mTabModelObserver =
new TabModelSelectorTabModelObserver(tabModelSelector) {
@Override
public void tabRemoved(Tab tab) {
Log.d(TAG, "tabRemoved");
stopObservingTab(tab);
mSnackbarManager.dismissSnackbars(mSnackbarController);
}
};
// The first time observer is created snackbar has net yet been shown.
mIsObservingNetworkChanges = false;
}
// Methods from EmptyTabObserver
@Override
public void onPageLoadFinished(Tab tab, GURL url) {
Log.d(TAG, "onPageLoadFinished");
if (isObservingTab(tab)) {
mObservedTabs.get(tab.getId()).isLoaded = true;
maybeShowReloadSnackbar(tab, false);
}
}
@Override
public void onShown(Tab tab, @TabSelectionType int type) {
Log.d(TAG, "onShow");
maybeShowReloadSnackbar(tab, false);
mCurrentTab = tab;
}
@Override
public void onHidden(Tab hiddenTab, @TabHidingType int type) {
Log.d(TAG, "onHidden");
mCurrentTab = null;
mSnackbarManager.dismissSnackbars(mSnackbarController);
}
@Override
public void onDestroyed(Tab tab) {
Log.d(TAG, "onDestroyed");
stopObservingTab(tab);
mSnackbarManager.dismissSnackbars(mSnackbarController);
}
@Override
public void onUrlUpdated(Tab tab) {
Log.d(TAG, "onUrlUpdated");
if (!OfflinePageUtils.isOfflinePage(tab)) {
stopObservingTab(tab);
} else if (isObservingTab(tab)) {
mObservedTabs.get(tab.getId()).isLoaded = false;
mObservedTabs.get(tab.getId()).wasSnackbarSeen = false;
}
// In case any snackbars are showing, dismiss them before we navigate away.
mSnackbarManager.dismissSnackbars(mSnackbarController);
}
void startObservingTab(Tab tab) {
assert tab.isInitialized();
boolean isOfflinePage = OfflinePageUtils.isOfflinePage(tab);
// Cache the offline state of the tab so we don't have to go to native every time we want to
// check this.
OfflinePageTabData offlinePageTabData = OfflinePageTabData.from(tab);
offlinePageTabData.setIsTabShowingOfflinePage(isOfflinePage);
offlinePageTabData.setIsTabShowingTrustedOfflinePage(
OfflinePageUtils.isShowingTrustedOfflinePage(tab.getWebContents()));
if (!isOfflinePage) return;
mCurrentTab = tab;
// If we are not observing the tab yet, let's.
if (!isObservingTab(tab)) {
// Adding a tab happens from inside of onPageLoadFinished, therefore if this is the time
// we start observing the tab, the page inside of it is already loaded.
mObservedTabs.put(tab.getId(), new TabState(true));
tab.addObserver(this);
}
// If we are not observing network changes yet, let's.
if (!isObservingNetworkChanges()) {
startObservingNetworkChanges();
mIsObservingNetworkChanges = true;
}
}
/**
* Removes the observer for a tab with the specified tabId.
* @param tab tab that was observed.
*/
void stopObservingTab(Tab tab) {
// If we are observing the tab, stop.
if (isObservingTab(tab)) {
assert tab.isInitialized();
// Reset the cached offline state of the tab so we don't have to go to native every time
// we want to check this.
OfflinePageTabData offlinePageTabData = OfflinePageTabData.from(tab);
offlinePageTabData.setIsTabShowingOfflinePage(false);
offlinePageTabData.setIsTabShowingTrustedOfflinePage(false);
mObservedTabs.remove(tab.getId());
tab.removeObserver(this);
}
// If there are not longer any tabs being observed, stop listening for network changes.
if (mObservedTabs.isEmpty() && isObservingNetworkChanges()) {
stopObservingNetworkChanges();
mIsObservingNetworkChanges = false;
}
}
private void destroy() {
mTabModelObserver.destroy();
if (!mObservedTabs.isEmpty()) {
for (Integer tabId : mObservedTabs.keySet()) {
Tab tab = mTabModelSelector.getTabById(tabId);
if (tab == null) continue;
tab.removeObserver(this);
}
mObservedTabs.clear();
}
if (isObservingNetworkChanges()) {
stopObservingNetworkChanges();
mIsObservingNetworkChanges = false;
}
}
// Methods from ConnectionTypeObserver.
@Override
public void onConnectionTypeChanged(int connectionType) {
Log.d(
TAG,
"Got connectivity event, connectionType: "
+ connectionType
+ ", is connected: "
+ OfflinePageUtils.isConnected()
+ ", controller: "
+ mSnackbarController);
maybeShowReloadSnackbar(mCurrentTab, true);
// Since we are loosing the connection, next time we connect, we still want to show a
// snackbar. This works in event that onConnectionTypeChanged happens, while Chrome is not
// visible. Making it visible after that would not trigger the snackbar, even though
// connection state changed. See http://crbug.com/651410
if (!OfflinePageUtils.isConnected()) {
for (TabState tabState : mObservedTabs.values()) {
tabState.wasSnackbarSeen = false;
}
}
}
@VisibleForTesting
boolean isObservingTab(Tab tab) {
return mObservedTabs.containsKey(tab.getId());
}
@VisibleForTesting
boolean isLoadedTab(Tab tab) {
return isObservingTab(tab) && mObservedTabs.get(tab.getId()).isLoaded;
}
@VisibleForTesting
boolean wasSnackbarSeen(Tab tab) {
return isObservingTab(tab) && mObservedTabs.get(tab.getId()).wasSnackbarSeen;
}
@VisibleForTesting
boolean isObservingNetworkChanges() {
return mIsObservingNetworkChanges;
}
void maybeShowReloadSnackbar(Tab tab, boolean isNetworkEvent) {
// Exclude Offline Previews, as there is a seperate UI for previews.
if (tab == null
|| tab.isFrozen()
|| tab.isHidden()
|| !OfflinePageUtils.isOfflinePage(tab)
|| OfflinePageUtils.isShowingOfflinePreview(tab)
|| !OfflinePageUtils.isConnected()
|| !isLoadedTab(tab)
|| (wasSnackbarSeen(tab) && !isNetworkEvent)) {
// Conditions to show a snackbar are not met.
return;
}
showReloadSnackbar(tab);
mObservedTabs.get(tab.getId()).wasSnackbarSeen = true;
}
@VisibleForTesting
void showReloadSnackbar(Tab tab) {
OfflinePageUtils.showReloadSnackbar(
tab.getContext(), mSnackbarManager, mSnackbarController, tab.getId());
}
@VisibleForTesting
void startObservingNetworkChanges() {
NetworkChangeNotifier.addConnectionTypeObserver(this);
}
@VisibleForTesting
void stopObservingNetworkChanges() {
NetworkChangeNotifier.removeConnectionTypeObserver(this);
}
@VisibleForTesting
TabModelObserver getTabModelObserver() {
return mTabModelObserver;
}
/**
* Gets a snackbar controller that we can use to show our snackbar.
* @param tabModelSelector used to retrieve a tab by ID
*/
private static SnackbarController createReloadSnackbarController(
final TabModelSelector tabModelSelector) {
Log.d(TAG, "building snackbar controller");
return new SnackbarController() {
@Override
public void onAction(Object actionData) {
assert actionData != null;
int tabId = (int) actionData;
RecordUserAction.record("OfflinePages.ReloadButtonClicked");
Tab foundTab = tabModelSelector.getTabById(tabId);
if (foundTab == null) return;
if (!OfflinePageUtils.isShowingTrustedOfflinePage(foundTab.getWebContents())) {
RecordUserAction.record("OfflinePages.ReloadButtonClickedViewingUntrustedPage");
}
// Delegates to Tab to reload the page. Tab will send the correct header in order to
// load the right page.
foundTab.reload();
}
@Override
public void onDismissNoAction(Object actionData) {
RecordUserAction.record("OfflinePages.ReloadButtonNotClicked");
}
};
}
}