/* -*- 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.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.GeckoApp; 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; // 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); } /** * Persist the opaque identifier for the current Firefox Health Report environment. * This changes in certain circumstances; be sure to use the current value when recording data. */ private void setHealthEnvironment(final int env) { this.env = env; } /** * 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 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); } } 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() { 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 prefs = GeckoApp.getAppSharedPreferences(); final SharedPreferences.Editor editor = 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() { ArrayList out = new ArrayList(2); out.add(new FieldSpec("normal", Field.TYPE_JSON_DISCRETE)); out.add(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); } }