mobile/android/base/health/BrowserHealthRecorder.java

Wed, 31 Dec 2014 07:22:50 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Wed, 31 Dec 2014 07:22:50 +0100
branch
TOR_BUG_3246
changeset 4
fc2d59ddac77
permissions
-rw-r--r--

Correct previous dual key logic pending first delivery installment.

     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/. */
     6 package org.mozilla.gecko.health;
     8 import java.io.File;
     9 import java.io.FileOutputStream;
    10 import java.io.OutputStreamWriter;
    11 import java.nio.charset.Charset;
    12 import java.util.ArrayList;
    13 import java.util.Arrays;
    14 import java.util.Collections;
    15 import java.util.HashSet;
    16 import java.util.Iterator;
    17 import java.util.List;
    18 import java.util.Scanner;
    19 import java.util.Set;
    20 import java.util.concurrent.atomic.AtomicBoolean;
    22 import org.json.JSONException;
    23 import org.json.JSONObject;
    24 import org.mozilla.gecko.AppConstants;
    25 import org.mozilla.gecko.Distribution;
    26 import org.mozilla.gecko.Distribution.DistributionDescriptor;
    27 import org.mozilla.gecko.EventDispatcher;
    28 import org.mozilla.gecko.GeckoAppShell;
    29 import org.mozilla.gecko.GeckoEvent;
    30 import org.mozilla.gecko.background.healthreport.EnvironmentBuilder;
    31 import org.mozilla.gecko.background.healthreport.HealthReportDatabaseStorage;
    32 import org.mozilla.gecko.background.healthreport.HealthReportStorage.Field;
    33 import org.mozilla.gecko.background.healthreport.HealthReportStorage.MeasurementFields;
    34 import org.mozilla.gecko.background.healthreport.ProfileInformationCache;
    35 import org.mozilla.gecko.util.GeckoEventListener;
    36 import org.mozilla.gecko.util.ThreadUtils;
    38 import android.content.ContentProviderClient;
    39 import android.content.Context;
    40 import android.content.SharedPreferences;
    41 import android.util.Log;
    43 /**
    44  * BrowserHealthRecorder is the browser's interface to the Firefox Health
    45  * Report storage system. It manages environments (a collection of attributes
    46  * that are tracked longitudinally) on the browser's behalf, exposing a simpler
    47  * interface for recording changes.
    48  *
    49  * Keep an instance of this class around.
    50  *
    51  * Tell it when an environment attribute has changed: call {@link
    52  * #onAppLocaleChanged(String)} followed by {@link
    53  * #onEnvironmentChanged()}.
    54  *
    55  * Use it to record events: {@link #recordSearch(String, String)}.
    56  *
    57  * Shut it down when you're done being a browser: {@link #close()}.
    58  */
    59 public class BrowserHealthRecorder implements HealthRecorder, GeckoEventListener {
    60     private static final String LOG_TAG = "GeckoHealthRec";
    61     private static final String PREF_ACCEPT_LANG = "intl.accept_languages";
    62     private static final String PREF_BLOCKLIST_ENABLED = "extensions.blocklist.enabled";
    63     private static final String EVENT_SNAPSHOT = "HealthReport:Snapshot";
    64     private static final String EVENT_ADDONS_CHANGE = "Addons:Change";
    65     private static final String EVENT_ADDONS_UNINSTALLING = "Addons:Uninstalling";
    66     private static final String EVENT_PREF_CHANGE = "Pref:Change";
    68     // This is raised from Gecko and signifies a search via the URL bar (not a bookmarks keyword
    69     // search). Using this event (rather than passing the invocation location as an arg) avoids
    70     // browser.js having to know about the invocation location.
    71     public static final String EVENT_KEYWORD_SEARCH = "Search:Keyword";
    73     // This is raised from Java. We include the location in the message.
    74     public static final String EVENT_SEARCH = "Search:Event";
    76     public enum State {
    77         NOT_INITIALIZED,
    78         INITIALIZING,
    79         INITIALIZED,
    80         INITIALIZATION_FAILED,
    81         CLOSED
    82     }
    84     protected volatile State state = State.NOT_INITIALIZED;
    86     private final AtomicBoolean orphanChecked = new AtomicBoolean(false);
    87     private volatile int env = -1;
    89     private ContentProviderClient client;
    90     private volatile HealthReportDatabaseStorage storage;
    91     private final ProfileInformationCache profileCache;
    92     private final EventDispatcher dispatcher;
    93     private final SharedPreferences prefs;
    95     // We track previousSession to avoid order-of-initialization confusion. We
    96     // accept it in the constructor, and process it after init.
    97     private final SessionInformation previousSession;
    98     private volatile SessionInformation session = null;
   100     public void setCurrentSession(SessionInformation session) {
   101         this.session = session;
   102     }
   104     public void recordGeckoStartupTime(long duration) {
   105         if (this.session == null) {
   106             return;
   107         }
   108         this.session.setTimedGeckoStartup(duration);
   109     }
   110     public void recordJavaStartupTime(long duration) {
   111         if (this.session == null) {
   112             return;
   113         }
   114         this.session.setTimedJavaStartup(duration);
   115     }
   117     /**
   118      * This constructor does IO. Run it on a background thread.
   119      *
   120      * appLocale can be null, which indicates that it will be provided later.
   121      */
   122     public BrowserHealthRecorder(final Context context,
   123                                  final SharedPreferences appPrefs,
   124                                  final String profilePath,
   125                                  final EventDispatcher dispatcher,
   126                                  final String osLocale,
   127                                  final String appLocale,
   128                                  SessionInformation previousSession) {
   129         Log.d(LOG_TAG, "Initializing. Dispatcher is " + dispatcher);
   130         this.dispatcher = dispatcher;
   131         this.previousSession = previousSession;
   133         this.client = EnvironmentBuilder.getContentProviderClient(context);
   134         if (this.client == null) {
   135             throw new IllegalStateException("Could not fetch Health Report content provider.");
   136         }
   138         this.storage = EnvironmentBuilder.getStorage(this.client, profilePath);
   139         if (this.storage == null) {
   140             // Stick around even if we don't have storage: eventually we'll
   141             // want to report total failures of FHR storage itself, and this
   142             // way callers don't need to worry about whether their health
   143             // recorder didn't initialize.
   144             this.client.release();
   145             this.client = null;
   146         }
   148         // Note that the PIC is not necessarily fully initialized at this point:
   149         // we haven't set the app locale. This must be done before an environment
   150         // is recorded.
   151         this.profileCache = new ProfileInformationCache(profilePath);
   152         try {
   153             this.initialize(context, profilePath, osLocale, appLocale);
   154         } catch (Exception e) {
   155             Log.e(LOG_TAG, "Exception initializing.", e);
   156         }
   158         this.prefs = appPrefs;
   159     }
   161     public boolean isEnabled() {
   162         return true;
   163     }
   165     /**
   166      * Shut down database connections, unregister event listeners, and perform
   167      * provider-specific uninitialization.
   168      */
   169     public synchronized void close() {
   170         switch (this.state) {
   171             case CLOSED:
   172                 Log.w(LOG_TAG, "Ignoring attempt to double-close closed BrowserHealthRecorder.");
   173                 return;
   174             case INITIALIZED:
   175                 Log.i(LOG_TAG, "Closing Health Report client.");
   176                 break;
   177             default:
   178                 Log.i(LOG_TAG, "Closing incompletely initialized BrowserHealthRecorder.");
   179         }
   181         this.state = State.CLOSED;
   182         this.unregisterEventListeners();
   184         // Add any necessary provider uninitialization here.
   185         this.storage = null;
   186         if (this.client != null) {
   187             this.client.release();
   188             this.client = null;
   189         }
   190     }
   192     private void unregisterEventListeners() {
   193         if (state != State.INITIALIZED) {
   194             return;
   195         }
   196         this.dispatcher.unregisterEventListener(EVENT_SNAPSHOT, this);
   197         this.dispatcher.unregisterEventListener(EVENT_ADDONS_CHANGE, this);
   198         this.dispatcher.unregisterEventListener(EVENT_ADDONS_UNINSTALLING, this);
   199         this.dispatcher.unregisterEventListener(EVENT_PREF_CHANGE, this);
   200         this.dispatcher.unregisterEventListener(EVENT_KEYWORD_SEARCH, this);
   201         this.dispatcher.unregisterEventListener(EVENT_SEARCH, this);
   202     }
   204     public void onAppLocaleChanged(String to) {
   205         Log.d(LOG_TAG, "Setting health recorder app locale to " + to);
   206         this.profileCache.beginInitialization();
   207         this.profileCache.setAppLocale(to);
   208     }
   210     public void onAddonChanged(String id, JSONObject json) {
   211         this.profileCache.beginInitialization();
   212         try {
   213             this.profileCache.updateJSONForAddon(id, json);
   214         } catch (IllegalStateException e) {
   215             Log.w(LOG_TAG, "Attempted to update add-on cache prior to full init.", e);
   216         }
   217     }
   219     public void onAddonUninstalling(String id) {
   220         this.profileCache.beginInitialization();
   221         try {
   222             this.profileCache.removeAddon(id);
   223         } catch (IllegalStateException e) {
   224             Log.w(LOG_TAG, "Attempted to update add-on cache prior to full init.", e);
   225         }
   226     }
   228     /**
   229      * Call this when a material change might have occurred in the running
   230      * environment, such that a new environment should be computed and prepared
   231      * for use in future events.
   232      *
   233      * Invoke this method after calls that mutate the environment.
   234      *
   235      * If this change resulted in a transition between two environments, {@link
   236      * #onEnvironmentTransition(int, int, boolean, String)} will be invoked on the background
   237      * thread.
   238      */
   239     public synchronized void onEnvironmentChanged() {
   240         onEnvironmentChanged(true, "E");
   241     }
   243     /**
   244      * If `startNewSession` is false, it means no new session should begin
   245      * (e.g., because we're about to restart, and we don't want to create
   246      * an orphan).
   247      */
   248     public synchronized void onEnvironmentChanged(final boolean startNewSession, final String sessionEndReason) {
   249         final int previousEnv = this.env;
   250         this.env = -1;
   251         try {
   252             profileCache.completeInitialization();
   253         } catch (java.io.IOException e) {
   254             Log.e(LOG_TAG, "Error completing profile cache initialization.", e);
   255             this.state = State.INITIALIZATION_FAILED;
   256             return;
   257         }
   259         final int updatedEnv = ensureEnvironment();
   261         if (updatedEnv == -1 ||
   262             updatedEnv == previousEnv) {
   263             Log.v(LOG_TAG, "Environment didn't change.");
   264             return;
   265         }
   266         ThreadUtils.postToBackgroundThread(new Runnable() {
   267             @Override
   268             public void run() {
   269                 try {
   270                     onEnvironmentTransition(previousEnv, updatedEnv, startNewSession, sessionEndReason);
   271                 } catch (Exception e) {
   272                     Log.w(LOG_TAG, "Could not record environment transition.", e);
   273                 }
   274             }
   275         });
   276     }
   278     protected synchronized int ensureEnvironment() {
   279         if (!(state == State.INITIALIZING ||
   280               state == State.INITIALIZED)) {
   281             throw new IllegalStateException("Not initialized.");
   282         }
   284         if (this.env != -1) {
   285             return this.env;
   286         }
   287         if (this.storage == null) {
   288             // Oh well.
   289             return -1;
   290         }
   291         return this.env = EnvironmentBuilder.registerCurrentEnvironment(this.storage,
   292                                                                         this.profileCache);
   293     }
   295     private static final String getTimesPath(final String profilePath) {
   296         return profilePath + File.separator + "times.json";
   297     }
   299     /**
   300      * Retrieve the stored profile creation time from the profile directory.
   301      *
   302      * @return the <code>created</code> value from the times.json file, or -1 on failure.
   303      */
   304     protected static long getProfileInitTimeFromFile(final String profilePath) {
   305         final File times = new File(getTimesPath(profilePath));
   306         Log.d(LOG_TAG, "Looking for " + times.getAbsolutePath());
   307         if (!times.exists()) {
   308             return -1;
   309         }
   311         Log.d(LOG_TAG, "Using times.json for profile creation time.");
   312         Scanner scanner = null;
   313         try {
   314             scanner = new Scanner(times, "UTF-8");
   315             final String contents = scanner.useDelimiter("\\A").next();
   316             return new JSONObject(contents).getLong("created");
   317         } catch (Exception e) {
   318             // There are assorted reasons why this might occur, but we
   319             // don't care. Move on.
   320             Log.w(LOG_TAG, "Failed to read times.json.", e);
   321         } finally {
   322             if (scanner != null) {
   323                 scanner.close();
   324             }
   325         }
   326         return -1;
   327     }
   329     /**
   330      * Only works on API 9 and up.
   331      *
   332      * @return the package install time, or -1 if an error occurred.
   333      */
   334     protected static long getPackageInstallTime(final Context context) {
   335         if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.GINGERBREAD) {
   336             return -1;
   337         }
   339         try {
   340             return context.getPackageManager().getPackageInfo(AppConstants.ANDROID_PACKAGE_NAME, 0).firstInstallTime;
   341         } catch (android.content.pm.PackageManager.NameNotFoundException e) {
   342             Log.e(LOG_TAG, "Unable to fetch our own package info. This should never occur.", e);
   343         }
   344         return -1;
   345     }
   347     private static long getProfileInitTimeHeuristic(final Context context, final String profilePath) {
   348         // As a pretty good shortcut, settle for installation time.
   349         // In all recent Firefox profiles, times.json should exist.
   350         final long time = getPackageInstallTime(context);
   351         if (time != -1) {
   352             return time;
   353         }
   355         // Otherwise, fall back to the filesystem.
   356         // We'll settle for the modification time of the profile directory.
   357         Log.d(LOG_TAG, "Using profile directory modified time as proxy for profile creation time.");
   358         return new File(profilePath).lastModified();
   359     }
   361     private static long getAndPersistProfileInitTime(final Context context, final String profilePath) {
   362         // Let's look in the profile.
   363         long time = getProfileInitTimeFromFile(profilePath);
   364         if (time > 0) {
   365             Log.d(LOG_TAG, "Incorporating environment: times.json profile creation = " + time);
   366             return time;
   367         }
   369         // Otherwise, we need to compute a valid creation time and write it out.
   370         time = getProfileInitTimeHeuristic(context, profilePath);
   372         if (time > 0) {
   373             // Write out a stub times.json.
   374             try {
   375                 FileOutputStream stream = new FileOutputStream(getTimesPath(profilePath));
   376                 OutputStreamWriter writer = new OutputStreamWriter(stream, Charset.forName("UTF-8"));
   377                 try {
   378                     writer.append("{\"created\": " + time + "}\n");
   379                 } finally {
   380                     writer.close();
   381                 }
   382             } catch (Exception e) {
   383                 // Best-effort.
   384                 Log.w(LOG_TAG, "Couldn't write times.json.", e);
   385             }
   386         }
   388         Log.d(LOG_TAG, "Incorporating environment: profile creation = " + time);
   389         return time;
   390     }
   392     private void onPrefMessage(final String pref, final JSONObject message) {
   393         Log.d(LOG_TAG, "Incorporating environment: " + pref);
   394         if (PREF_ACCEPT_LANG.equals(pref)) {
   395             // We only record whether this is user-set.
   396             try {
   397                 this.profileCache.beginInitialization();
   398                 this.profileCache.setAcceptLangUserSet(message.getBoolean("isUserSet"));
   399             } catch (JSONException ex) {
   400                 Log.w(LOG_TAG, "Unexpected JSONException fetching isUserSet for " + pref);
   401             }
   402             return;
   403         }
   405         // (We only handle boolean prefs right now.)
   406         try {
   407             boolean value = message.getBoolean("value");
   409             if (AppConstants.TELEMETRY_PREF_NAME.equals(pref)) {
   410                 this.profileCache.beginInitialization();
   411                 this.profileCache.setTelemetryEnabled(value);
   412                 return;
   413             }
   415             if (PREF_BLOCKLIST_ENABLED.equals(pref)) {
   416                 this.profileCache.beginInitialization();
   417                 this.profileCache.setBlocklistEnabled(value);
   418                 return;
   419             }
   420         } catch (JSONException ex) {
   421             Log.w(LOG_TAG, "Unexpected JSONException fetching boolean value for " + pref);
   422             return;
   423         }
   424         Log.w(LOG_TAG, "Unexpected pref: " + pref);
   425     }
   427     /**
   428      * Background init helper.
   429      */
   430     private void initializeStorage() {
   431         Log.d(LOG_TAG, "Done initializing profile cache. Beginning storage init.");
   433         final BrowserHealthRecorder self = this;
   434         ThreadUtils.postToBackgroundThread(new Runnable() {
   435             @Override
   436             public void run() {
   437                 synchronized (self) {
   438                     if (state != State.INITIALIZING) {
   439                         Log.w(LOG_TAG, "Unexpected state during init: " + state);
   440                         return;
   441                     }
   443                     // Belt and braces.
   444                     if (storage == null) {
   445                         Log.w(LOG_TAG, "Storage is null during init; shutting down?");
   446                         if (state == State.INITIALIZING) {
   447                             state = State.INITIALIZATION_FAILED;
   448                         }
   449                         return;
   450                     }
   452                     try {
   453                         storage.beginInitialization();
   454                     } catch (Exception e) {
   455                         Log.e(LOG_TAG, "Failed to init storage.", e);
   456                         state = State.INITIALIZATION_FAILED;
   457                         return;
   458                     }
   460                     try {
   461                         // Listen for add-ons and prefs changes.
   462                         dispatcher.registerEventListener(EVENT_ADDONS_UNINSTALLING, self);
   463                         dispatcher.registerEventListener(EVENT_ADDONS_CHANGE, self);
   464                         dispatcher.registerEventListener(EVENT_PREF_CHANGE, self);
   466                         // Initialize each provider here.
   467                         initializeSessionsProvider();
   468                         initializeSearchProvider();
   470                         Log.d(LOG_TAG, "Ensuring environment.");
   471                         ensureEnvironment();
   473                         Log.d(LOG_TAG, "Finishing init.");
   474                         storage.finishInitialization();
   475                         state = State.INITIALIZED;
   476                     } catch (Exception e) {
   477                         state = State.INITIALIZATION_FAILED;
   478                         storage.abortInitialization();
   479                         Log.e(LOG_TAG, "Initialization failed.", e);
   480                         return;
   481                     }
   483                     // Now do whatever we do after we start up.
   484                     checkForOrphanSessions();
   485                 }
   486             }
   487         });
   488     }
   490     /**
   491      * Add provider-specific initialization in this method.
   492      */
   493     private synchronized void initialize(final Context context,
   494                                          final String profilePath,
   495                                          final String osLocale,
   496                                          final String appLocale)
   497         throws java.io.IOException {
   499         Log.d(LOG_TAG, "Initializing profile cache.");
   500         this.state = State.INITIALIZING;
   502         // If we can restore state from last time, great.
   503         if (this.profileCache.restoreUnlessInitialized()) {
   504             this.profileCache.updateLocales(osLocale, appLocale);
   505             this.profileCache.completeInitialization();
   507             Log.d(LOG_TAG, "Successfully restored state. Initializing storage.");
   508             initializeStorage();
   509             return;
   510         }
   512         // Otherwise, let's initialize it from scratch.
   513         this.profileCache.beginInitialization();
   514         this.profileCache.setProfileCreationTime(getAndPersistProfileInitTime(context, profilePath));
   515         this.profileCache.setOSLocale(osLocale);
   516         this.profileCache.setAppLocale(appLocale);
   518         // Because the distribution lookup can take some time, do it at the end of
   519         // our background startup work, along with the Gecko snapshot fetch.
   520         final GeckoEventListener self = this;
   521         ThreadUtils.postToBackgroundThread(new Runnable() {
   522             @Override
   523             public void run() {
   524                 final DistributionDescriptor desc = new Distribution(context).getDescriptor();
   525                 if (desc != null && desc.valid) {
   526                     profileCache.setDistributionString(desc.id, desc.version);
   527                 }
   528                 Log.d(LOG_TAG, "Requesting all add-ons and FHR prefs from Gecko.");
   529                 dispatcher.registerEventListener(EVENT_SNAPSHOT, self);
   530                 GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("HealthReport:RequestSnapshot", null));
   531             }
   532         });
   533     }
   535     /**
   536      * Invoked in the background whenever the environment transitions between
   537      * two valid values.
   538      */
   539     protected void onEnvironmentTransition(int prev, int env, boolean startNewSession, String sessionEndReason) {
   540         if (this.state != State.INITIALIZED) {
   541             Log.d(LOG_TAG, "Not initialized: not recording env transition (" + prev + " => " + env + ").");
   542             return;
   543         }
   545         final SharedPreferences.Editor editor = this.prefs.edit();
   547         recordSessionEnd(sessionEndReason, editor, prev);
   549         if (!startNewSession) {
   550             editor.commit();
   551             return;
   552         }
   554         final SessionInformation newSession = SessionInformation.forRuntimeTransition();
   555         setCurrentSession(newSession);
   556         newSession.recordBegin(editor);
   557         editor.commit();
   558     }
   560     @Override
   561     public void handleMessage(String event, JSONObject message) {
   562         try {
   563             if (EVENT_SNAPSHOT.equals(event)) {
   564                 Log.d(LOG_TAG, "Got all add-ons and prefs.");
   565                 try {
   566                     JSONObject json = message.getJSONObject("json");
   567                     JSONObject addons = json.getJSONObject("addons");
   568                     Log.i(LOG_TAG, "Persisting " + addons.length() + " add-ons.");
   569                     profileCache.setJSONForAddons(addons);
   571                     JSONObject prefs = json.getJSONObject("prefs");
   572                     Log.i(LOG_TAG, "Persisting prefs.");
   573                     Iterator<?> keys = prefs.keys();
   574                     while (keys.hasNext()) {
   575                         String pref = (String) keys.next();
   576                         this.onPrefMessage(pref, prefs.getJSONObject(pref));
   577                     }
   579                     profileCache.completeInitialization();
   580                 } catch (java.io.IOException e) {
   581                     Log.e(LOG_TAG, "Error completing profile cache initialization.", e);
   582                     state = State.INITIALIZATION_FAILED;
   583                     return;
   584                 }
   586                 if (state == State.INITIALIZING) {
   587                     initializeStorage();
   588                 } else {
   589                     this.onEnvironmentChanged();
   590                 }
   592                 return;
   593             }
   595             if (EVENT_ADDONS_UNINSTALLING.equals(event)) {
   596                 this.onAddonUninstalling(message.getString("id"));
   597                 this.onEnvironmentChanged();
   598                 return;
   599             }
   601             if (EVENT_ADDONS_CHANGE.equals(event)) {
   602                 this.onAddonChanged(message.getString("id"), message.getJSONObject("json"));
   603                 this.onEnvironmentChanged();
   604                 return;
   605             }
   607             if (EVENT_PREF_CHANGE.equals(event)) {
   608                 final String pref = message.getString("pref");
   609                 Log.d(LOG_TAG, "Pref changed: " + pref);
   610                 this.onPrefMessage(pref, message);
   611                 this.onEnvironmentChanged();
   612                 return;
   613             }
   615             // Searches.
   616             if (EVENT_KEYWORD_SEARCH.equals(event)) {
   617                 // A search via the URL bar. Since we eliminate all other search possibilities
   618                 // (e.g. bookmarks keyword, search suggestion) when we initially process the
   619                 // search URL, this is considered a default search.
   620                 recordSearch(message.getString("identifier"), "bartext");
   621                 return;
   622             }
   623             if (EVENT_SEARCH.equals(event)) {
   624                 if (!message.has("location")) {
   625                     Log.d(LOG_TAG, "Ignoring search without location.");
   626                     return;
   627                 }
   628                 recordSearch(message.optString("identifier", null), message.getString("location"));
   629                 return;
   630             }
   631         } catch (Exception e) {
   632             Log.e(LOG_TAG, "Exception handling message \"" + event + "\":", e);
   633         }
   634     }
   636     /*
   637      * Searches.
   638      */
   640     public static final String MEASUREMENT_NAME_SEARCH_COUNTS = "org.mozilla.searches.counts";
   641     public static final int MEASUREMENT_VERSION_SEARCH_COUNTS = 5;
   643     public static final Set<String> SEARCH_LOCATIONS = Collections.unmodifiableSet(new HashSet<String>(Arrays.asList(new String[] {
   644         "barkeyword",
   645         "barsuggest",
   646         "bartext",
   647     })));
   649     private void initializeSearchProvider() {
   650         this.storage.ensureMeasurementInitialized(
   651             MEASUREMENT_NAME_SEARCH_COUNTS,
   652             MEASUREMENT_VERSION_SEARCH_COUNTS,
   653             new MeasurementFields() {
   654                 @Override
   655                 public Iterable<FieldSpec> getFields() {
   656                     ArrayList<FieldSpec> out = new ArrayList<FieldSpec>(SEARCH_LOCATIONS.size());
   657                     for (String location : SEARCH_LOCATIONS) {
   658                         // We're not using a counter, because the set of engine
   659                         // identifiers is potentially unbounded, and thus our
   660                         // measurement version would have to keep growing as
   661                         // fields changed. Instead we store discrete values, and
   662                         // accumulate them into a counting map during processing.
   663                         out.add(new FieldSpec(location, Field.TYPE_COUNTED_STRING_DISCRETE));
   664                     }
   665                     return out;
   666                 }
   667         });
   669         // Do this here, rather than in a centralized registration spot, in
   670         // case the above throws and we wind up handling events that we can't
   671         // store.
   672         this.dispatcher.registerEventListener(EVENT_KEYWORD_SEARCH, this);
   673         this.dispatcher.registerEventListener(EVENT_SEARCH, this);
   674     }
   676     /**
   677      * Record a search.
   678      *
   679      * @param engineID the string identifier for the engine. Can be <code>null</code>.
   680      * @param location one of a fixed set of locations: see {@link #SEARCH_LOCATIONS}.
   681      */
   682     public void recordSearch(final String engineID, final String location) {
   683         if (this.state != State.INITIALIZED) {
   684             Log.d(LOG_TAG, "Not initialized: not recording search. (" + this.state + ")");
   685             return;
   686         }
   688         final int env = this.env;
   690         if (env == -1) {
   691             Log.d(LOG_TAG, "No environment: not recording search.");
   692             return;
   693         }
   695         if (location == null) {
   696             throw new IllegalArgumentException("location must be provided for search.");
   697         }
   699         // Ensure that we don't throw when trying to look up the field for an
   700         // unknown location. If you add a search location, you must extend the
   701         // list of search locations *and update the measurement version*.
   702         if (!SEARCH_LOCATIONS.contains(location)) {
   703             throw new IllegalArgumentException("Unexpected location: " + location);
   704         }
   706         final int day = storage.getDay();
   707         final String key = (engineID == null) ? "other" : engineID;
   709         ThreadUtils.postToBackgroundThread(new Runnable() {
   710             @Override
   711             public void run() {
   712                 final HealthReportDatabaseStorage storage = BrowserHealthRecorder.this.storage;
   713                 if (storage == null) {
   714                     Log.d(LOG_TAG, "No storage: not recording search. Shutting down?");
   715                     return;
   716                 }
   718                 Log.d(LOG_TAG, "Recording search: " + key + ", " + location +
   719                                " (" + day + ", " + env + ").");
   720                 final int searchField = storage.getField(MEASUREMENT_NAME_SEARCH_COUNTS,
   721                                                          MEASUREMENT_VERSION_SEARCH_COUNTS,
   722                                                          location)
   723                                                .getID();
   724                 storage.recordDailyDiscrete(env, day, searchField, key);
   725             }
   726         });
   727     }
   729     /*
   730      * Sessions.
   731      *
   732      * We record session beginnings in SharedPreferences, because it's cheaper
   733      * to do that than to either write to then update the DB (which requires
   734      * keeping a row identifier to update, as well as two writes) or to record
   735      * two events (which doubles storage space and requires rollup logic).
   736      *
   737      * The pattern is:
   738      *
   739      * 1. On startup, determine whether an orphan session exists by looking for
   740      *    a saved timestamp in prefs. If it does, then record the orphan in FHR
   741      *    storage.
   742      * 2. Record in prefs that a new session has begun. Track the timestamp (so
   743      *    we know to which day the session belongs).
   744      * 3. As startup timings become available, accumulate them in memory.
   745      * 4. On clean shutdown, read the values from here, write them to the DB, and
   746      *    delete the sentinel time from SharedPreferences.
   747      * 5. On a dirty shutdown, the in-memory session will not be written to the
   748      *    DB, and the current session will be orphaned.
   749      *
   750      * Sessions are begun in onResume (and thus implicitly onStart) and ended
   751      * in onPause.
   752      *
   753      * Session objects are stored as discrete JSON.
   754      *
   755      *   "org.mozilla.appSessions": {
   756      *     _v: 4,
   757      *     "normal": [
   758      *       {"r":"P", "d": 123},
   759      *     ],
   760      *     "abnormal": [
   761      *       {"r":"A", "oom": true, "stopped": false}
   762      *     ]
   763      *   }
   764      *
   765      * "r": reason. Values are "P" (activity paused), "A" (abnormal termination)
   766      * "d": duration. Value in seconds.
   767      * "sg": Gecko startup time. Present if this is a clean launch. This
   768      *       corresponds to the telemetry timer FENNEC_STARTUP_TIME_GECKOREADY.
   769      * "sj": Java activity init time. Present if this is a clean launch. This
   770      *       corresponds to the telemetry timer FENNEC_STARTUP_TIME_JAVAUI,
   771      *       and includes initialization tasks beyond initial
   772      *       onWindowFocusChanged.
   773      *
   774      * Abnormal terminations will be missing a duration and will feature these keys:
   775      *
   776      * "oom": was the session killed by an OOM exception?
   777      * "stopped": was the session stopped gently?
   778      */
   780     public static final String MEASUREMENT_NAME_SESSIONS = "org.mozilla.appSessions";
   781     public static final int MEASUREMENT_VERSION_SESSIONS = 4;
   783     private void initializeSessionsProvider() {
   784         this.storage.ensureMeasurementInitialized(
   785             MEASUREMENT_NAME_SESSIONS,
   786             MEASUREMENT_VERSION_SESSIONS,
   787             new MeasurementFields() {
   788                 @Override
   789                 public Iterable<FieldSpec> getFields() {
   790                     List<FieldSpec> out = Arrays.asList(
   791                         new FieldSpec("normal", Field.TYPE_JSON_DISCRETE),
   792                         new FieldSpec("abnormal", Field.TYPE_JSON_DISCRETE));
   793                     return out;
   794                 }
   795         });
   796     }
   798     /**
   799      * Logic shared between crashed and normal sessions.
   800      */
   801     private void recordSessionEntry(String field, SessionInformation session, final int environment, JSONObject value) {
   802         final HealthReportDatabaseStorage storage = this.storage;
   803         if (storage == null) {
   804             Log.d(LOG_TAG, "No storage: not recording session entry. Shutting down?");
   805             return;
   806         }
   808         try {
   809             final int sessionField = storage.getField(MEASUREMENT_NAME_SESSIONS,
   810                                                       MEASUREMENT_VERSION_SESSIONS,
   811                                                       field)
   812                                             .getID();
   813             final int day = storage.getDay(session.wallStartTime);
   814             storage.recordDailyDiscrete(environment, day, sessionField, value);
   815             Log.v(LOG_TAG, "Recorded session entry for env " + environment + ", current is " + env);
   816         } catch (Exception e) {
   817             Log.w(LOG_TAG, "Unable to record session completion.", e);
   818         }
   819     }
   821     public void checkForOrphanSessions() {
   822         if (!this.orphanChecked.compareAndSet(false, true)) {
   823             Log.w(LOG_TAG, "Attempting to check for orphan sessions more than once.");
   824             return;
   825         }
   827         Log.d(LOG_TAG, "Checking for orphan session.");
   828         if (this.previousSession == null) {
   829             return;
   830         }
   831         if (this.previousSession.wallStartTime == 0) {
   832             return;
   833         }
   835         if (state != State.INITIALIZED) {
   836             // Something has gone awry.
   837             Log.e(LOG_TAG, "Attempted to record bad session end without initialized recorder.");
   838             return;
   839         }
   841         try {
   842             recordSessionEntry("abnormal", this.previousSession, this.env,
   843                                this.previousSession.getCrashedJSON());
   844         } catch (Exception e) {
   845             Log.w(LOG_TAG, "Unable to generate session JSON.", e);
   847             // Future: record this exception in FHR's own error submitter.
   848         }
   849     }
   851     public void recordSessionEnd(String reason, SharedPreferences.Editor editor) {
   852         recordSessionEnd(reason, editor, env);
   853     }
   855     /**
   856      * Record that the current session ended. Does not commit the provided editor.
   857      *
   858      * @param environment An environment ID. This allows callers to record the
   859      *                    end of a session due to an observed environment change.
   860      */
   861     public void recordSessionEnd(String reason, SharedPreferences.Editor editor, final int environment) {
   862         Log.d(LOG_TAG, "Recording session end: " + reason);
   863         if (state != State.INITIALIZED) {
   864             // Something has gone awry.
   865             Log.e(LOG_TAG, "Attempted to record session end without initialized recorder.");
   866             return;
   867         }
   869         final SessionInformation session = this.session;
   870         this.session = null;        // So it can't be double-recorded.
   872         if (session == null) {
   873             Log.w(LOG_TAG, "Unable to record session end: no session. Already ended?");
   874             return;
   875         }
   877         if (session.wallStartTime <= 0) {
   878             Log.e(LOG_TAG, "Session start " + session.wallStartTime + " isn't valid! Can't record end.");
   879             return;
   880         }
   882         long realEndTime = android.os.SystemClock.elapsedRealtime();
   883         try {
   884             JSONObject json = session.getCompletionJSON(reason, realEndTime);
   885             recordSessionEntry("normal", session, environment, json);
   886         } catch (JSONException e) {
   887             Log.w(LOG_TAG, "Unable to generate session JSON.", e);
   889             // Continue so we don't hit it next time.
   890             // Future: record this exception in FHR's own error submitter.
   891         }
   893         // Track the end of this session in shared prefs, so it doesn't get
   894         // double-counted on next run.
   895         session.recordCompletion(editor);
   896     }
   897 }

mercurial