diff -r 000000000000 -r 6474c204b198 mobile/android/base/health/BrowserHealthRecorder.java
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/mobile/android/base/health/BrowserHealthRecorder.java Wed Dec 31 06:09:35 2014 +0100
@@ -0,0 +1,897 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.health;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.OutputStreamWriter;
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Scanner;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.Distribution;
+import org.mozilla.gecko.Distribution.DistributionDescriptor;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoEvent;
+import org.mozilla.gecko.background.healthreport.EnvironmentBuilder;
+import org.mozilla.gecko.background.healthreport.HealthReportDatabaseStorage;
+import org.mozilla.gecko.background.healthreport.HealthReportStorage.Field;
+import org.mozilla.gecko.background.healthreport.HealthReportStorage.MeasurementFields;
+import org.mozilla.gecko.background.healthreport.ProfileInformationCache;
+import org.mozilla.gecko.util.GeckoEventListener;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.content.ContentProviderClient;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.util.Log;
+
+/**
+ * BrowserHealthRecorder is the browser's interface to the Firefox Health
+ * Report storage system. It manages environments (a collection of attributes
+ * that are tracked longitudinally) on the browser's behalf, exposing a simpler
+ * interface for recording changes.
+ *
+ * Keep an instance of this class around.
+ *
+ * Tell it when an environment attribute has changed: call {@link
+ * #onAppLocaleChanged(String)} followed by {@link
+ * #onEnvironmentChanged()}.
+ *
+ * Use it to record events: {@link #recordSearch(String, String)}.
+ *
+ * Shut it down when you're done being a browser: {@link #close()}.
+ */
+public class BrowserHealthRecorder implements HealthRecorder, GeckoEventListener {
+ private static final String LOG_TAG = "GeckoHealthRec";
+ private static final String PREF_ACCEPT_LANG = "intl.accept_languages";
+ private static final String PREF_BLOCKLIST_ENABLED = "extensions.blocklist.enabled";
+ private static final String EVENT_SNAPSHOT = "HealthReport:Snapshot";
+ private static final String EVENT_ADDONS_CHANGE = "Addons:Change";
+ private static final String EVENT_ADDONS_UNINSTALLING = "Addons:Uninstalling";
+ private static final String EVENT_PREF_CHANGE = "Pref:Change";
+
+ // This is raised from Gecko and signifies a search via the URL bar (not a bookmarks keyword
+ // search). Using this event (rather than passing the invocation location as an arg) avoids
+ // browser.js having to know about the invocation location.
+ public static final String EVENT_KEYWORD_SEARCH = "Search:Keyword";
+
+ // This is raised from Java. We include the location in the message.
+ public static final String EVENT_SEARCH = "Search:Event";
+
+ public enum State {
+ NOT_INITIALIZED,
+ INITIALIZING,
+ INITIALIZED,
+ INITIALIZATION_FAILED,
+ CLOSED
+ }
+
+ protected volatile State state = State.NOT_INITIALIZED;
+
+ private final AtomicBoolean orphanChecked = new AtomicBoolean(false);
+ private volatile int env = -1;
+
+ private ContentProviderClient client;
+ private volatile HealthReportDatabaseStorage storage;
+ private final ProfileInformationCache profileCache;
+ private final EventDispatcher dispatcher;
+ private final SharedPreferences prefs;
+
+ // We track previousSession to avoid order-of-initialization confusion. We
+ // accept it in the constructor, and process it after init.
+ private final SessionInformation previousSession;
+ private volatile SessionInformation session = null;
+
+ public void setCurrentSession(SessionInformation session) {
+ this.session = session;
+ }
+
+ public void recordGeckoStartupTime(long duration) {
+ if (this.session == null) {
+ return;
+ }
+ this.session.setTimedGeckoStartup(duration);
+ }
+ public void recordJavaStartupTime(long duration) {
+ if (this.session == null) {
+ return;
+ }
+ this.session.setTimedJavaStartup(duration);
+ }
+
+ /**
+ * This constructor does IO. Run it on a background thread.
+ *
+ * appLocale can be null, which indicates that it will be provided later.
+ */
+ public BrowserHealthRecorder(final Context context,
+ final SharedPreferences appPrefs,
+ final String profilePath,
+ final EventDispatcher dispatcher,
+ final String osLocale,
+ final String appLocale,
+ SessionInformation previousSession) {
+ Log.d(LOG_TAG, "Initializing. Dispatcher is " + dispatcher);
+ this.dispatcher = dispatcher;
+ this.previousSession = previousSession;
+
+ this.client = EnvironmentBuilder.getContentProviderClient(context);
+ if (this.client == null) {
+ throw new IllegalStateException("Could not fetch Health Report content provider.");
+ }
+
+ this.storage = EnvironmentBuilder.getStorage(this.client, profilePath);
+ if (this.storage == null) {
+ // Stick around even if we don't have storage: eventually we'll
+ // want to report total failures of FHR storage itself, and this
+ // way callers don't need to worry about whether their health
+ // recorder didn't initialize.
+ this.client.release();
+ this.client = null;
+ }
+
+ // Note that the PIC is not necessarily fully initialized at this point:
+ // we haven't set the app locale. This must be done before an environment
+ // is recorded.
+ this.profileCache = new ProfileInformationCache(profilePath);
+ try {
+ this.initialize(context, profilePath, osLocale, appLocale);
+ } catch (Exception e) {
+ Log.e(LOG_TAG, "Exception initializing.", e);
+ }
+
+ this.prefs = appPrefs;
+ }
+
+ public boolean isEnabled() {
+ return true;
+ }
+
+ /**
+ * Shut down database connections, unregister event listeners, and perform
+ * provider-specific uninitialization.
+ */
+ public synchronized void close() {
+ switch (this.state) {
+ case CLOSED:
+ Log.w(LOG_TAG, "Ignoring attempt to double-close closed BrowserHealthRecorder.");
+ return;
+ case INITIALIZED:
+ Log.i(LOG_TAG, "Closing Health Report client.");
+ break;
+ default:
+ Log.i(LOG_TAG, "Closing incompletely initialized BrowserHealthRecorder.");
+ }
+
+ this.state = State.CLOSED;
+ this.unregisterEventListeners();
+
+ // Add any necessary provider uninitialization here.
+ this.storage = null;
+ if (this.client != null) {
+ this.client.release();
+ this.client = null;
+ }
+ }
+
+ private void unregisterEventListeners() {
+ if (state != State.INITIALIZED) {
+ return;
+ }
+ this.dispatcher.unregisterEventListener(EVENT_SNAPSHOT, this);
+ this.dispatcher.unregisterEventListener(EVENT_ADDONS_CHANGE, this);
+ this.dispatcher.unregisterEventListener(EVENT_ADDONS_UNINSTALLING, this);
+ this.dispatcher.unregisterEventListener(EVENT_PREF_CHANGE, this);
+ this.dispatcher.unregisterEventListener(EVENT_KEYWORD_SEARCH, this);
+ this.dispatcher.unregisterEventListener(EVENT_SEARCH, this);
+ }
+
+ public void onAppLocaleChanged(String to) {
+ Log.d(LOG_TAG, "Setting health recorder app locale to " + to);
+ this.profileCache.beginInitialization();
+ this.profileCache.setAppLocale(to);
+ }
+
+ public void onAddonChanged(String id, JSONObject json) {
+ this.profileCache.beginInitialization();
+ try {
+ this.profileCache.updateJSONForAddon(id, json);
+ } catch (IllegalStateException e) {
+ Log.w(LOG_TAG, "Attempted to update add-on cache prior to full init.", e);
+ }
+ }
+
+ public void onAddonUninstalling(String id) {
+ this.profileCache.beginInitialization();
+ try {
+ this.profileCache.removeAddon(id);
+ } catch (IllegalStateException e) {
+ Log.w(LOG_TAG, "Attempted to update add-on cache prior to full init.", e);
+ }
+ }
+
+ /**
+ * Call this when a material change might have occurred in the running
+ * environment, such that a new environment should be computed and prepared
+ * for use in future events.
+ *
+ * Invoke this method after calls that mutate the environment.
+ *
+ * If this change resulted in a transition between two environments, {@link
+ * #onEnvironmentTransition(int, int, boolean, String)} will be invoked on the background
+ * thread.
+ */
+ public synchronized void onEnvironmentChanged() {
+ onEnvironmentChanged(true, "E");
+ }
+
+ /**
+ * If `startNewSession` is false, it means no new session should begin
+ * (e.g., because we're about to restart, and we don't want to create
+ * an orphan).
+ */
+ public synchronized void onEnvironmentChanged(final boolean startNewSession, final String sessionEndReason) {
+ final int previousEnv = this.env;
+ this.env = -1;
+ try {
+ profileCache.completeInitialization();
+ } catch (java.io.IOException e) {
+ Log.e(LOG_TAG, "Error completing profile cache initialization.", e);
+ this.state = State.INITIALIZATION_FAILED;
+ return;
+ }
+
+ final int updatedEnv = ensureEnvironment();
+
+ if (updatedEnv == -1 ||
+ updatedEnv == previousEnv) {
+ Log.v(LOG_TAG, "Environment didn't change.");
+ return;
+ }
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ onEnvironmentTransition(previousEnv, updatedEnv, startNewSession, sessionEndReason);
+ } catch (Exception e) {
+ Log.w(LOG_TAG, "Could not record environment transition.", e);
+ }
+ }
+ });
+ }
+
+ protected synchronized int ensureEnvironment() {
+ if (!(state == State.INITIALIZING ||
+ state == State.INITIALIZED)) {
+ throw new IllegalStateException("Not initialized.");
+ }
+
+ if (this.env != -1) {
+ return this.env;
+ }
+ if (this.storage == null) {
+ // Oh well.
+ return -1;
+ }
+ return this.env = EnvironmentBuilder.registerCurrentEnvironment(this.storage,
+ this.profileCache);
+ }
+
+ private static final String getTimesPath(final String profilePath) {
+ return profilePath + File.separator + "times.json";
+ }
+
+ /**
+ * Retrieve the stored profile creation time from the profile directory.
+ *
+ * @return the created
value from the times.json file, or -1 on failure.
+ */
+ protected static long getProfileInitTimeFromFile(final String profilePath) {
+ final File times = new File(getTimesPath(profilePath));
+ Log.d(LOG_TAG, "Looking for " + times.getAbsolutePath());
+ if (!times.exists()) {
+ return -1;
+ }
+
+ Log.d(LOG_TAG, "Using times.json for profile creation time.");
+ Scanner scanner = null;
+ try {
+ scanner = new Scanner(times, "UTF-8");
+ final String contents = scanner.useDelimiter("\\A").next();
+ return new JSONObject(contents).getLong("created");
+ } catch (Exception e) {
+ // There are assorted reasons why this might occur, but we
+ // don't care. Move on.
+ Log.w(LOG_TAG, "Failed to read times.json.", e);
+ } finally {
+ if (scanner != null) {
+ scanner.close();
+ }
+ }
+ return -1;
+ }
+
+ /**
+ * Only works on API 9 and up.
+ *
+ * @return the package install time, or -1 if an error occurred.
+ */
+ protected static long getPackageInstallTime(final Context context) {
+ if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.GINGERBREAD) {
+ return -1;
+ }
+
+ try {
+ return context.getPackageManager().getPackageInfo(AppConstants.ANDROID_PACKAGE_NAME, 0).firstInstallTime;
+ } catch (android.content.pm.PackageManager.NameNotFoundException e) {
+ Log.e(LOG_TAG, "Unable to fetch our own package info. This should never occur.", e);
+ }
+ return -1;
+ }
+
+ private static long getProfileInitTimeHeuristic(final Context context, final String profilePath) {
+ // As a pretty good shortcut, settle for installation time.
+ // In all recent Firefox profiles, times.json should exist.
+ final long time = getPackageInstallTime(context);
+ if (time != -1) {
+ return time;
+ }
+
+ // Otherwise, fall back to the filesystem.
+ // We'll settle for the modification time of the profile directory.
+ Log.d(LOG_TAG, "Using profile directory modified time as proxy for profile creation time.");
+ return new File(profilePath).lastModified();
+ }
+
+ private static long getAndPersistProfileInitTime(final Context context, final String profilePath) {
+ // Let's look in the profile.
+ long time = getProfileInitTimeFromFile(profilePath);
+ if (time > 0) {
+ Log.d(LOG_TAG, "Incorporating environment: times.json profile creation = " + time);
+ return time;
+ }
+
+ // Otherwise, we need to compute a valid creation time and write it out.
+ time = getProfileInitTimeHeuristic(context, profilePath);
+
+ if (time > 0) {
+ // Write out a stub times.json.
+ try {
+ FileOutputStream stream = new FileOutputStream(getTimesPath(profilePath));
+ OutputStreamWriter writer = new OutputStreamWriter(stream, Charset.forName("UTF-8"));
+ try {
+ writer.append("{\"created\": " + time + "}\n");
+ } finally {
+ writer.close();
+ }
+ } catch (Exception e) {
+ // Best-effort.
+ Log.w(LOG_TAG, "Couldn't write times.json.", e);
+ }
+ }
+
+ Log.d(LOG_TAG, "Incorporating environment: profile creation = " + time);
+ return time;
+ }
+
+ private void onPrefMessage(final String pref, final JSONObject message) {
+ Log.d(LOG_TAG, "Incorporating environment: " + pref);
+ if (PREF_ACCEPT_LANG.equals(pref)) {
+ // We only record whether this is user-set.
+ try {
+ this.profileCache.beginInitialization();
+ this.profileCache.setAcceptLangUserSet(message.getBoolean("isUserSet"));
+ } catch (JSONException ex) {
+ Log.w(LOG_TAG, "Unexpected JSONException fetching isUserSet for " + pref);
+ }
+ return;
+ }
+
+ // (We only handle boolean prefs right now.)
+ try {
+ boolean value = message.getBoolean("value");
+
+ if (AppConstants.TELEMETRY_PREF_NAME.equals(pref)) {
+ this.profileCache.beginInitialization();
+ this.profileCache.setTelemetryEnabled(value);
+ return;
+ }
+
+ if (PREF_BLOCKLIST_ENABLED.equals(pref)) {
+ this.profileCache.beginInitialization();
+ this.profileCache.setBlocklistEnabled(value);
+ return;
+ }
+ } catch (JSONException ex) {
+ Log.w(LOG_TAG, "Unexpected JSONException fetching boolean value for " + pref);
+ return;
+ }
+ Log.w(LOG_TAG, "Unexpected pref: " + pref);
+ }
+
+ /**
+ * Background init helper.
+ */
+ private void initializeStorage() {
+ Log.d(LOG_TAG, "Done initializing profile cache. Beginning storage init.");
+
+ final BrowserHealthRecorder self = this;
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ synchronized (self) {
+ if (state != State.INITIALIZING) {
+ Log.w(LOG_TAG, "Unexpected state during init: " + state);
+ return;
+ }
+
+ // Belt and braces.
+ if (storage == null) {
+ Log.w(LOG_TAG, "Storage is null during init; shutting down?");
+ if (state == State.INITIALIZING) {
+ state = State.INITIALIZATION_FAILED;
+ }
+ return;
+ }
+
+ try {
+ storage.beginInitialization();
+ } catch (Exception e) {
+ Log.e(LOG_TAG, "Failed to init storage.", e);
+ state = State.INITIALIZATION_FAILED;
+ return;
+ }
+
+ try {
+ // Listen for add-ons and prefs changes.
+ dispatcher.registerEventListener(EVENT_ADDONS_UNINSTALLING, self);
+ dispatcher.registerEventListener(EVENT_ADDONS_CHANGE, self);
+ dispatcher.registerEventListener(EVENT_PREF_CHANGE, self);
+
+ // Initialize each provider here.
+ initializeSessionsProvider();
+ initializeSearchProvider();
+
+ Log.d(LOG_TAG, "Ensuring environment.");
+ ensureEnvironment();
+
+ Log.d(LOG_TAG, "Finishing init.");
+ storage.finishInitialization();
+ state = State.INITIALIZED;
+ } catch (Exception e) {
+ state = State.INITIALIZATION_FAILED;
+ storage.abortInitialization();
+ Log.e(LOG_TAG, "Initialization failed.", e);
+ return;
+ }
+
+ // Now do whatever we do after we start up.
+ checkForOrphanSessions();
+ }
+ }
+ });
+ }
+
+ /**
+ * Add provider-specific initialization in this method.
+ */
+ private synchronized void initialize(final Context context,
+ final String profilePath,
+ final String osLocale,
+ final String appLocale)
+ throws java.io.IOException {
+
+ Log.d(LOG_TAG, "Initializing profile cache.");
+ this.state = State.INITIALIZING;
+
+ // If we can restore state from last time, great.
+ if (this.profileCache.restoreUnlessInitialized()) {
+ this.profileCache.updateLocales(osLocale, appLocale);
+ this.profileCache.completeInitialization();
+
+ Log.d(LOG_TAG, "Successfully restored state. Initializing storage.");
+ initializeStorage();
+ return;
+ }
+
+ // Otherwise, let's initialize it from scratch.
+ this.profileCache.beginInitialization();
+ this.profileCache.setProfileCreationTime(getAndPersistProfileInitTime(context, profilePath));
+ this.profileCache.setOSLocale(osLocale);
+ this.profileCache.setAppLocale(appLocale);
+
+ // Because the distribution lookup can take some time, do it at the end of
+ // our background startup work, along with the Gecko snapshot fetch.
+ final GeckoEventListener self = this;
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ final DistributionDescriptor desc = new Distribution(context).getDescriptor();
+ if (desc != null && desc.valid) {
+ profileCache.setDistributionString(desc.id, desc.version);
+ }
+ Log.d(LOG_TAG, "Requesting all add-ons and FHR prefs from Gecko.");
+ dispatcher.registerEventListener(EVENT_SNAPSHOT, self);
+ GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("HealthReport:RequestSnapshot", null));
+ }
+ });
+ }
+
+ /**
+ * Invoked in the background whenever the environment transitions between
+ * two valid values.
+ */
+ protected void onEnvironmentTransition(int prev, int env, boolean startNewSession, String sessionEndReason) {
+ if (this.state != State.INITIALIZED) {
+ Log.d(LOG_TAG, "Not initialized: not recording env transition (" + prev + " => " + env + ").");
+ return;
+ }
+
+ final SharedPreferences.Editor editor = this.prefs.edit();
+
+ recordSessionEnd(sessionEndReason, editor, prev);
+
+ if (!startNewSession) {
+ editor.commit();
+ return;
+ }
+
+ final SessionInformation newSession = SessionInformation.forRuntimeTransition();
+ setCurrentSession(newSession);
+ newSession.recordBegin(editor);
+ editor.commit();
+ }
+
+ @Override
+ public void handleMessage(String event, JSONObject message) {
+ try {
+ if (EVENT_SNAPSHOT.equals(event)) {
+ Log.d(LOG_TAG, "Got all add-ons and prefs.");
+ try {
+ JSONObject json = message.getJSONObject("json");
+ JSONObject addons = json.getJSONObject("addons");
+ Log.i(LOG_TAG, "Persisting " + addons.length() + " add-ons.");
+ profileCache.setJSONForAddons(addons);
+
+ JSONObject prefs = json.getJSONObject("prefs");
+ Log.i(LOG_TAG, "Persisting prefs.");
+ Iterator> keys = prefs.keys();
+ while (keys.hasNext()) {
+ String pref = (String) keys.next();
+ this.onPrefMessage(pref, prefs.getJSONObject(pref));
+ }
+
+ profileCache.completeInitialization();
+ } catch (java.io.IOException e) {
+ Log.e(LOG_TAG, "Error completing profile cache initialization.", e);
+ state = State.INITIALIZATION_FAILED;
+ return;
+ }
+
+ if (state == State.INITIALIZING) {
+ initializeStorage();
+ } else {
+ this.onEnvironmentChanged();
+ }
+
+ return;
+ }
+
+ if (EVENT_ADDONS_UNINSTALLING.equals(event)) {
+ this.onAddonUninstalling(message.getString("id"));
+ this.onEnvironmentChanged();
+ return;
+ }
+
+ if (EVENT_ADDONS_CHANGE.equals(event)) {
+ this.onAddonChanged(message.getString("id"), message.getJSONObject("json"));
+ this.onEnvironmentChanged();
+ return;
+ }
+
+ if (EVENT_PREF_CHANGE.equals(event)) {
+ final String pref = message.getString("pref");
+ Log.d(LOG_TAG, "Pref changed: " + pref);
+ this.onPrefMessage(pref, message);
+ this.onEnvironmentChanged();
+ return;
+ }
+
+ // Searches.
+ if (EVENT_KEYWORD_SEARCH.equals(event)) {
+ // A search via the URL bar. Since we eliminate all other search possibilities
+ // (e.g. bookmarks keyword, search suggestion) when we initially process the
+ // search URL, this is considered a default search.
+ recordSearch(message.getString("identifier"), "bartext");
+ return;
+ }
+ if (EVENT_SEARCH.equals(event)) {
+ if (!message.has("location")) {
+ Log.d(LOG_TAG, "Ignoring search without location.");
+ return;
+ }
+ recordSearch(message.optString("identifier", null), message.getString("location"));
+ return;
+ }
+ } catch (Exception e) {
+ Log.e(LOG_TAG, "Exception handling message \"" + event + "\":", e);
+ }
+ }
+
+ /*
+ * Searches.
+ */
+
+ public static final String MEASUREMENT_NAME_SEARCH_COUNTS = "org.mozilla.searches.counts";
+ public static final int MEASUREMENT_VERSION_SEARCH_COUNTS = 5;
+
+ public static final Set SEARCH_LOCATIONS = Collections.unmodifiableSet(new HashSet(Arrays.asList(new String[] {
+ "barkeyword",
+ "barsuggest",
+ "bartext",
+ })));
+
+ private void initializeSearchProvider() {
+ this.storage.ensureMeasurementInitialized(
+ MEASUREMENT_NAME_SEARCH_COUNTS,
+ MEASUREMENT_VERSION_SEARCH_COUNTS,
+ new MeasurementFields() {
+ @Override
+ public Iterable getFields() {
+ ArrayList out = new ArrayList(SEARCH_LOCATIONS.size());
+ for (String location : SEARCH_LOCATIONS) {
+ // We're not using a counter, because the set of engine
+ // identifiers is potentially unbounded, and thus our
+ // measurement version would have to keep growing as
+ // fields changed. Instead we store discrete values, and
+ // accumulate them into a counting map during processing.
+ out.add(new FieldSpec(location, Field.TYPE_COUNTED_STRING_DISCRETE));
+ }
+ return out;
+ }
+ });
+
+ // Do this here, rather than in a centralized registration spot, in
+ // case the above throws and we wind up handling events that we can't
+ // store.
+ this.dispatcher.registerEventListener(EVENT_KEYWORD_SEARCH, this);
+ this.dispatcher.registerEventListener(EVENT_SEARCH, this);
+ }
+
+ /**
+ * Record a search.
+ *
+ * @param engineID the string identifier for the engine. Can be null
.
+ * @param location one of a fixed set of locations: see {@link #SEARCH_LOCATIONS}.
+ */
+ public void recordSearch(final String engineID, final String location) {
+ if (this.state != State.INITIALIZED) {
+ Log.d(LOG_TAG, "Not initialized: not recording search. (" + this.state + ")");
+ return;
+ }
+
+ final int env = this.env;
+
+ if (env == -1) {
+ Log.d(LOG_TAG, "No environment: not recording search.");
+ return;
+ }
+
+ if (location == null) {
+ throw new IllegalArgumentException("location must be provided for search.");
+ }
+
+ // Ensure that we don't throw when trying to look up the field for an
+ // unknown location. If you add a search location, you must extend the
+ // list of search locations *and update the measurement version*.
+ if (!SEARCH_LOCATIONS.contains(location)) {
+ throw new IllegalArgumentException("Unexpected location: " + location);
+ }
+
+ final int day = storage.getDay();
+ final String key = (engineID == null) ? "other" : engineID;
+
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ final HealthReportDatabaseStorage storage = BrowserHealthRecorder.this.storage;
+ if (storage == null) {
+ Log.d(LOG_TAG, "No storage: not recording search. Shutting down?");
+ return;
+ }
+
+ Log.d(LOG_TAG, "Recording search: " + key + ", " + location +
+ " (" + day + ", " + env + ").");
+ final int searchField = storage.getField(MEASUREMENT_NAME_SEARCH_COUNTS,
+ MEASUREMENT_VERSION_SEARCH_COUNTS,
+ location)
+ .getID();
+ storage.recordDailyDiscrete(env, day, searchField, key);
+ }
+ });
+ }
+
+ /*
+ * Sessions.
+ *
+ * We record session beginnings in SharedPreferences, because it's cheaper
+ * to do that than to either write to then update the DB (which requires
+ * keeping a row identifier to update, as well as two writes) or to record
+ * two events (which doubles storage space and requires rollup logic).
+ *
+ * The pattern is:
+ *
+ * 1. On startup, determine whether an orphan session exists by looking for
+ * a saved timestamp in prefs. If it does, then record the orphan in FHR
+ * storage.
+ * 2. Record in prefs that a new session has begun. Track the timestamp (so
+ * we know to which day the session belongs).
+ * 3. As startup timings become available, accumulate them in memory.
+ * 4. On clean shutdown, read the values from here, write them to the DB, and
+ * delete the sentinel time from SharedPreferences.
+ * 5. On a dirty shutdown, the in-memory session will not be written to the
+ * DB, and the current session will be orphaned.
+ *
+ * Sessions are begun in onResume (and thus implicitly onStart) and ended
+ * in onPause.
+ *
+ * Session objects are stored as discrete JSON.
+ *
+ * "org.mozilla.appSessions": {
+ * _v: 4,
+ * "normal": [
+ * {"r":"P", "d": 123},
+ * ],
+ * "abnormal": [
+ * {"r":"A", "oom": true, "stopped": false}
+ * ]
+ * }
+ *
+ * "r": reason. Values are "P" (activity paused), "A" (abnormal termination)
+ * "d": duration. Value in seconds.
+ * "sg": Gecko startup time. Present if this is a clean launch. This
+ * corresponds to the telemetry timer FENNEC_STARTUP_TIME_GECKOREADY.
+ * "sj": Java activity init time. Present if this is a clean launch. This
+ * corresponds to the telemetry timer FENNEC_STARTUP_TIME_JAVAUI,
+ * and includes initialization tasks beyond initial
+ * onWindowFocusChanged.
+ *
+ * Abnormal terminations will be missing a duration and will feature these keys:
+ *
+ * "oom": was the session killed by an OOM exception?
+ * "stopped": was the session stopped gently?
+ */
+
+ public static final String MEASUREMENT_NAME_SESSIONS = "org.mozilla.appSessions";
+ public static final int MEASUREMENT_VERSION_SESSIONS = 4;
+
+ private void initializeSessionsProvider() {
+ this.storage.ensureMeasurementInitialized(
+ MEASUREMENT_NAME_SESSIONS,
+ MEASUREMENT_VERSION_SESSIONS,
+ new MeasurementFields() {
+ @Override
+ public Iterable getFields() {
+ List out = Arrays.asList(
+ new FieldSpec("normal", Field.TYPE_JSON_DISCRETE),
+ new FieldSpec("abnormal", Field.TYPE_JSON_DISCRETE));
+ return out;
+ }
+ });
+ }
+
+ /**
+ * Logic shared between crashed and normal sessions.
+ */
+ private void recordSessionEntry(String field, SessionInformation session, final int environment, JSONObject value) {
+ final HealthReportDatabaseStorage storage = this.storage;
+ if (storage == null) {
+ Log.d(LOG_TAG, "No storage: not recording session entry. Shutting down?");
+ return;
+ }
+
+ try {
+ final int sessionField = storage.getField(MEASUREMENT_NAME_SESSIONS,
+ MEASUREMENT_VERSION_SESSIONS,
+ field)
+ .getID();
+ final int day = storage.getDay(session.wallStartTime);
+ storage.recordDailyDiscrete(environment, day, sessionField, value);
+ Log.v(LOG_TAG, "Recorded session entry for env " + environment + ", current is " + env);
+ } catch (Exception e) {
+ Log.w(LOG_TAG, "Unable to record session completion.", e);
+ }
+ }
+
+ public void checkForOrphanSessions() {
+ if (!this.orphanChecked.compareAndSet(false, true)) {
+ Log.w(LOG_TAG, "Attempting to check for orphan sessions more than once.");
+ return;
+ }
+
+ Log.d(LOG_TAG, "Checking for orphan session.");
+ if (this.previousSession == null) {
+ return;
+ }
+ if (this.previousSession.wallStartTime == 0) {
+ return;
+ }
+
+ if (state != State.INITIALIZED) {
+ // Something has gone awry.
+ Log.e(LOG_TAG, "Attempted to record bad session end without initialized recorder.");
+ return;
+ }
+
+ try {
+ recordSessionEntry("abnormal", this.previousSession, this.env,
+ this.previousSession.getCrashedJSON());
+ } catch (Exception e) {
+ Log.w(LOG_TAG, "Unable to generate session JSON.", e);
+
+ // Future: record this exception in FHR's own error submitter.
+ }
+ }
+
+ public void recordSessionEnd(String reason, SharedPreferences.Editor editor) {
+ recordSessionEnd(reason, editor, env);
+ }
+
+ /**
+ * Record that the current session ended. Does not commit the provided editor.
+ *
+ * @param environment An environment ID. This allows callers to record the
+ * end of a session due to an observed environment change.
+ */
+ public void recordSessionEnd(String reason, SharedPreferences.Editor editor, final int environment) {
+ Log.d(LOG_TAG, "Recording session end: " + reason);
+ if (state != State.INITIALIZED) {
+ // Something has gone awry.
+ Log.e(LOG_TAG, "Attempted to record session end without initialized recorder.");
+ return;
+ }
+
+ final SessionInformation session = this.session;
+ this.session = null; // So it can't be double-recorded.
+
+ if (session == null) {
+ Log.w(LOG_TAG, "Unable to record session end: no session. Already ended?");
+ return;
+ }
+
+ if (session.wallStartTime <= 0) {
+ Log.e(LOG_TAG, "Session start " + session.wallStartTime + " isn't valid! Can't record end.");
+ return;
+ }
+
+ long realEndTime = android.os.SystemClock.elapsedRealtime();
+ try {
+ JSONObject json = session.getCompletionJSON(reason, realEndTime);
+ recordSessionEntry("normal", session, environment, json);
+ } catch (JSONException e) {
+ Log.w(LOG_TAG, "Unable to generate session JSON.", e);
+
+ // Continue so we don't hit it next time.
+ // Future: record this exception in FHR's own error submitter.
+ }
+
+ // Track the end of this session in shared prefs, so it doesn't get
+ // double-counted on next run.
+ session.recordCompletion(editor);
+ }
+}