1 // Copyright 2019 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
4 
5 package org.chromium.chrome.browser.tasks;
6 
7 import android.content.Context;
8 import android.content.SharedPreferences;
9 import android.os.Handler;
10 
11 import androidx.annotation.NonNull;
12 import androidx.annotation.VisibleForTesting;
13 
14 import org.chromium.base.ContextUtils;
15 import org.chromium.base.metrics.RecordHistogram;
16 import org.chromium.base.task.AsyncTask;
17 import org.chromium.chrome.browser.compositor.layouts.EmptyOverviewModeObserver;
18 import org.chromium.chrome.browser.compositor.layouts.OverviewModeBehavior;
19 import org.chromium.chrome.browser.lifecycle.ActivityLifecycleDispatcher;
20 import org.chromium.chrome.browser.lifecycle.Destroyable;
21 import org.chromium.chrome.browser.tab.Tab;
22 import org.chromium.chrome.browser.tab.TabHidingType;
23 import org.chromium.chrome.browser.tab.TabObserver;
24 import org.chromium.chrome.browser.tab.TabSelectionType;
25 import org.chromium.chrome.browser.tabmodel.TabModelObserver;
26 import org.chromium.chrome.browser.tabmodel.TabModelSelector;
27 import org.chromium.chrome.browser.tabmodel.TabModelSelectorTabModelObserver;
28 import org.chromium.chrome.browser.tabmodel.TabModelSelectorTabObserver;
29 import org.chromium.chrome.browser.version.ChromeVersionInfo;
30 import org.chromium.content_public.browser.LoadUrlParams;
31 import org.chromium.content_public.browser.NavigationHandle;
32 import org.chromium.ui.base.PageTransition;
33 
34 import java.util.HashMap;
35 import java.util.Map;
36 import java.util.concurrent.TimeUnit;
37 
38 /**
39  * Manages Journey related signals, specifically those related to tab engagement.
40  */
41 public class JourneyManager implements Destroyable {
42     @VisibleForTesting
43     static final String PREFS_FILE = "last_engagement_for_tab_id_pref";
44 
45     @VisibleForTesting
46     static final String TAB_REVISIT_METRIC = "Tabs.TimeSinceLastView.OnTabView";
47 
48     @VisibleForTesting
49     static final String TAB_CLOSE_METRIC = "Tabs.TimeSinceLastView.OnTabClose";
50 
51     @VisibleForTesting
52     static final String TAB_CLOBBER_METRIC = "Tabs.TimeSinceLastView.OnTabClobber";
53 
54     private static final long INVALID_TIME = -1;
55 
56     // We track this in seconds because UMA can only handle 32-bit signed integers, which 45 days
57     // will overflow.
58     private static final int MAX_ENGAGEMENT_TIME_S = (int) TimeUnit.DAYS.toSeconds(45);
59 
60     private final TabModelSelectorTabObserver mTabModelSelectorTabObserver;
61     private final TabModelSelectorTabModelObserver mTabModelSelectorTabModelObserver;
62     private final OverviewModeBehavior.OverviewModeObserver mOverviewModeObserver;
63     private final ActivityLifecycleDispatcher mLifecycleDispatcher;
64     private final OverviewModeBehavior mOverviewModeBehavior;
65     private final EngagementTimeUtil mEngagementTimeUtil;
66 
67     private Map<Integer, Boolean> mDidFirstPaintPerTab = new HashMap<>();
68     private Map<Integer, Runnable> mPendingRevisits = new HashMap<>();
69     private final Handler mHandler = new Handler();
70     private Tab mCurrentTab;
71 
JourneyManager(TabModelSelector selector, @NonNull ActivityLifecycleDispatcher dispatcher, @NonNull OverviewModeBehavior overviewModeBehavior, EngagementTimeUtil engagementTimeUtil)72     public JourneyManager(TabModelSelector selector,
73             @NonNull ActivityLifecycleDispatcher dispatcher,
74             @NonNull OverviewModeBehavior overviewModeBehavior,
75             EngagementTimeUtil engagementTimeUtil) {
76         if (!ChromeVersionInfo.isLocalBuild() && !ChromeVersionInfo.isCanaryBuild()
77                 && !ChromeVersionInfo.isDevBuild()) {
78             // We do not want this in beta/stable until it's no longer backed by SharedPreferences.
79             mTabModelSelectorTabObserver = null;
80             mTabModelSelectorTabModelObserver = null;
81             mOverviewModeObserver = null;
82             mLifecycleDispatcher = null;
83             mEngagementTimeUtil = null;
84             mOverviewModeBehavior = null;
85             return;
86         }
87 
88         mTabModelSelectorTabObserver = new TabModelSelectorTabObserver(selector) {
89             @Override
90             public void onShown(Tab tab, @TabSelectionType int type) {
91                 if (type != TabSelectionType.FROM_USER) return;
92 
93                 mCurrentTab = tab;
94 
95                 recordDeferredEngagementMetric(tab);
96             }
97 
98             @Override
99             public void onHidden(Tab tab, @TabHidingType int reason) {
100                 handleTabEngagementStopped(tab);
101             }
102 
103             @Override
104             public void onClosingStateChanged(Tab tab, boolean closing) {
105                 if (!closing) return;
106 
107                 mCurrentTab = null;
108 
109                 recordTabCloseMetric(tab);
110             }
111 
112             @Override
113             public void onDidStartNavigation(Tab tab, NavigationHandle navigationHandle) {
114                 if (!navigationHandle.isInMainFrame() || navigationHandle.isSameDocument()) return;
115 
116                 mDidFirstPaintPerTab.put(tab.getId(), false);
117             }
118 
119             @Override
120             public void didFirstVisuallyNonEmptyPaint(Tab tab) {
121                 if (!mDidFirstPaintPerTab.containsKey(tab.getId())) {
122                     // If this is the first paint of this Tab in the current app lifetime, record.
123                     // e.g. First load of the tab after cold start.
124                     recordDeferredEngagementMetric(tab);
125                 }
126 
127                 mCurrentTab = tab;
128 
129                 mDidFirstPaintPerTab.put(tab.getId(), true);
130 
131                 handleTabEngagementStarted(tab);
132             }
133 
134             @Override
135             public void onLoadUrl(Tab tab, LoadUrlParams params, int loadType) {
136                 // The transition source (e.g. FROM_ADDRESS_BAR = 0x02000000) is bitwise OR'ed with
137                 // the transition method (e.g. TYPED = 0x01) and we are only interested in whether
138                 // a navigation happened from the address bar.
139                 if ((params.getTransitionType() & PageTransition.FROM_ADDRESS_BAR) == 0) return;
140 
141                 int tabId = tab.getId();
142 
143                 if (!mPendingRevisits.containsKey(tabId)) {
144                     return;
145                 }
146 
147                 mHandler.removeCallbacks(mPendingRevisits.get(tabId));
148                 mPendingRevisits.remove(tabId);
149 
150                 recordTabClobberMetric(tab, params.getInputStartTimestamp());
151             }
152         };
153 
154         mTabModelSelectorTabModelObserver = new TabModelSelectorTabModelObserver(selector) {
155             @Override
156             public void tabClosureCommitted(Tab tab) {
157                 getPrefs().edit().remove(String.valueOf(tab.getId())).apply();
158             }
159         };
160 
161         mOverviewModeBehavior = overviewModeBehavior;
162         mOverviewModeObserver = new EmptyOverviewModeObserver() {
163             @Override
164             public void onOverviewModeStartedShowing(boolean showToolbar) {
165                 handleTabEngagementStopped(mCurrentTab);
166             }
167         };
168         mOverviewModeBehavior.addOverviewModeObserver(mOverviewModeObserver);
169 
170         mLifecycleDispatcher = dispatcher;
171         mLifecycleDispatcher.register(this);
172 
173         mEngagementTimeUtil = engagementTimeUtil;
174     }
175 
176     @Override
destroy()177     public void destroy() {
178         mTabModelSelectorTabObserver.destroy();
179         mTabModelSelectorTabModelObserver.destroy();
180         mOverviewModeBehavior.removeOverviewModeObserver(mOverviewModeObserver);
181         mLifecycleDispatcher.unregister(this);
182     }
183 
handleTabEngagementStarted(Tab tab)184     private void handleTabEngagementStarted(Tab tab) {
185         if (tab == null) return;
186 
187         Boolean didFirstPaint = mDidFirstPaintPerTab.get(tab.getId());
188         if (didFirstPaint == null || !didFirstPaint) return;
189 
190         storeLastEngagement(tab.getId(), mEngagementTimeUtil.currentTime());
191     }
192 
handleTabEngagementStopped(Tab tab)193     private void handleTabEngagementStopped(Tab tab) {
194         if (tab == null) return;
195 
196         if (mPendingRevisits.containsKey(tab.getId())) {
197             mHandler.removeCallbacks(mPendingRevisits.get(tab.getId()));
198             mPendingRevisits.remove(tab.getId());
199         }
200 
201         long lastEngagementMs = mEngagementTimeUtil.currentTime();
202 
203         Boolean didFirstPaint = mDidFirstPaintPerTab.get(tab.getId());
204         if (didFirstPaint == null || !didFirstPaint) {
205             return;
206         }
207 
208         storeLastEngagement(tab.getId(), lastEngagementMs);
209     }
210 
storeLastEngagement(int tabId, long lastEngagementTimestampMs)211     private void storeLastEngagement(int tabId, long lastEngagementTimestampMs) {
212         new AsyncTask<Void>() {
213             @Override
214             protected Void doInBackground() {
215                 getPrefs().edit().putLong(String.valueOf(tabId), lastEngagementTimestampMs).apply();
216                 return null;
217             }
218 
219             @Override
220             protected void onPostExecute(Void result) {}
221         }.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR);
222     }
223 
getPrefs()224     private SharedPreferences getPrefs() {
225         // TODO(mattsimmons): Add a native counterpart to this class and don't write directly to
226         //  shared prefs.
227         return ContextUtils.getApplicationContext().getSharedPreferences(
228                 PREFS_FILE, Context.MODE_PRIVATE);
229     }
230 
getLastEngagementTimestamp(Tab tab)231     private long getLastEngagementTimestamp(Tab tab) {
232         return getPrefs().getLong(String.valueOf(tab.getId()), INVALID_TIME);
233     }
234 
recordDeferredEngagementMetric(Tab tab)235     private void recordDeferredEngagementMetric(Tab tab) {
236         final long currentEngagementTimeMs = mEngagementTimeUtil.currentTime();
237 
238         assert (!mPendingRevisits.containsKey(tab.getId()));
239 
240         Runnable viewMetricTask = () -> recordViewMetric(tab, currentEngagementTimeMs);
241         mPendingRevisits.put(tab.getId(), viewMetricTask);
242         mHandler.postDelayed(viewMetricTask, mEngagementTimeUtil.tabClobberThresholdMillis());
243     }
244 
recordViewMetric(Tab tab, long viewTimeMs)245     private void recordViewMetric(Tab tab, long viewTimeMs) {
246         mPendingRevisits.remove(tab.getId());
247 
248         long lastEngagement = getLastEngagementTimestamp(tab);
249 
250         if (lastEngagement == INVALID_TIME) return;
251 
252         long elapsedMs = mEngagementTimeUtil.timeSinceLastEngagement(lastEngagement, viewTimeMs);
253 
254         recordEngagementMetric(TAB_REVISIT_METRIC, elapsedMs);
255 
256         handleTabEngagementStarted(tab);
257     }
258 
recordTabCloseMetric(Tab tab)259     private void recordTabCloseMetric(Tab tab) {
260         long lastEngagement = getLastEngagementTimestamp(tab);
261 
262         if (lastEngagement == INVALID_TIME) return;
263 
264         long elapsedMs = mEngagementTimeUtil.timeSinceLastEngagement(lastEngagement);
265 
266         recordEngagementMetric(TAB_CLOSE_METRIC, elapsedMs);
267     }
268 
recordTabClobberMetric(Tab tab, long inputStartTimeTicksMs)269     private void recordTabClobberMetric(Tab tab, long inputStartTimeTicksMs) {
270         long lastEngagement = getLastEngagementTimestamp(tab);
271 
272         if (lastEngagement == INVALID_TIME) return;
273 
274         long elapsedMs = mEngagementTimeUtil.timeSinceLastEngagementFromTimeTicksMs(
275                 lastEngagement, inputStartTimeTicksMs);
276 
277         recordEngagementMetric(TAB_CLOBBER_METRIC, elapsedMs);
278     }
279 
recordEngagementMetric(String name, long elapsedMs)280     private void recordEngagementMetric(String name, long elapsedMs) {
281         if (elapsedMs == INVALID_TIME) return;
282 
283         int elapsedSeconds = (int) TimeUnit.MILLISECONDS.toSeconds(elapsedMs);
284 
285         RecordHistogram.recordCustomCountHistogram(
286                 name, elapsedSeconds, 1, MAX_ENGAGEMENT_TIME_S, 50);
287     }
288 
289     @VisibleForTesting
getTabModelSelectorTabObserver()290     public TabObserver getTabModelSelectorTabObserver() {
291         return mTabModelSelectorTabObserver;
292     }
293 
294     @VisibleForTesting
getTabModelSelectorTabModelObserver()295     public TabModelObserver getTabModelSelectorTabModelObserver() {
296         return mTabModelSelectorTabModelObserver;
297     }
298 
299     @VisibleForTesting
getOverviewModeObserver()300     public OverviewModeBehavior.OverviewModeObserver getOverviewModeObserver() {
301         return mOverviewModeObserver;
302     }
303 }
304