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