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: }