1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/mobile/android/base/health/BrowserHealthRecorder.java Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,897 @@ 1.4 +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- 1.5 + * This Source Code Form is subject to the terms of the Mozilla Public 1.6 + * License, v. 2.0. If a copy of the MPL was not distributed with this 1.7 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 1.8 + 1.9 +package org.mozilla.gecko.health; 1.10 + 1.11 +import java.io.File; 1.12 +import java.io.FileOutputStream; 1.13 +import java.io.OutputStreamWriter; 1.14 +import java.nio.charset.Charset; 1.15 +import java.util.ArrayList; 1.16 +import java.util.Arrays; 1.17 +import java.util.Collections; 1.18 +import java.util.HashSet; 1.19 +import java.util.Iterator; 1.20 +import java.util.List; 1.21 +import java.util.Scanner; 1.22 +import java.util.Set; 1.23 +import java.util.concurrent.atomic.AtomicBoolean; 1.24 + 1.25 +import org.json.JSONException; 1.26 +import org.json.JSONObject; 1.27 +import org.mozilla.gecko.AppConstants; 1.28 +import org.mozilla.gecko.Distribution; 1.29 +import org.mozilla.gecko.Distribution.DistributionDescriptor; 1.30 +import org.mozilla.gecko.EventDispatcher; 1.31 +import org.mozilla.gecko.GeckoAppShell; 1.32 +import org.mozilla.gecko.GeckoEvent; 1.33 +import org.mozilla.gecko.background.healthreport.EnvironmentBuilder; 1.34 +import org.mozilla.gecko.background.healthreport.HealthReportDatabaseStorage; 1.35 +import org.mozilla.gecko.background.healthreport.HealthReportStorage.Field; 1.36 +import org.mozilla.gecko.background.healthreport.HealthReportStorage.MeasurementFields; 1.37 +import org.mozilla.gecko.background.healthreport.ProfileInformationCache; 1.38 +import org.mozilla.gecko.util.GeckoEventListener; 1.39 +import org.mozilla.gecko.util.ThreadUtils; 1.40 + 1.41 +import android.content.ContentProviderClient; 1.42 +import android.content.Context; 1.43 +import android.content.SharedPreferences; 1.44 +import android.util.Log; 1.45 + 1.46 +/** 1.47 + * BrowserHealthRecorder is the browser's interface to the Firefox Health 1.48 + * Report storage system. It manages environments (a collection of attributes 1.49 + * that are tracked longitudinally) on the browser's behalf, exposing a simpler 1.50 + * interface for recording changes. 1.51 + * 1.52 + * Keep an instance of this class around. 1.53 + * 1.54 + * Tell it when an environment attribute has changed: call {@link 1.55 + * #onAppLocaleChanged(String)} followed by {@link 1.56 + * #onEnvironmentChanged()}. 1.57 + * 1.58 + * Use it to record events: {@link #recordSearch(String, String)}. 1.59 + * 1.60 + * Shut it down when you're done being a browser: {@link #close()}. 1.61 + */ 1.62 +public class BrowserHealthRecorder implements HealthRecorder, GeckoEventListener { 1.63 + private static final String LOG_TAG = "GeckoHealthRec"; 1.64 + private static final String PREF_ACCEPT_LANG = "intl.accept_languages"; 1.65 + private static final String PREF_BLOCKLIST_ENABLED = "extensions.blocklist.enabled"; 1.66 + private static final String EVENT_SNAPSHOT = "HealthReport:Snapshot"; 1.67 + private static final String EVENT_ADDONS_CHANGE = "Addons:Change"; 1.68 + private static final String EVENT_ADDONS_UNINSTALLING = "Addons:Uninstalling"; 1.69 + private static final String EVENT_PREF_CHANGE = "Pref:Change"; 1.70 + 1.71 + // This is raised from Gecko and signifies a search via the URL bar (not a bookmarks keyword 1.72 + // search). Using this event (rather than passing the invocation location as an arg) avoids 1.73 + // browser.js having to know about the invocation location. 1.74 + public static final String EVENT_KEYWORD_SEARCH = "Search:Keyword"; 1.75 + 1.76 + // This is raised from Java. We include the location in the message. 1.77 + public static final String EVENT_SEARCH = "Search:Event"; 1.78 + 1.79 + public enum State { 1.80 + NOT_INITIALIZED, 1.81 + INITIALIZING, 1.82 + INITIALIZED, 1.83 + INITIALIZATION_FAILED, 1.84 + CLOSED 1.85 + } 1.86 + 1.87 + protected volatile State state = State.NOT_INITIALIZED; 1.88 + 1.89 + private final AtomicBoolean orphanChecked = new AtomicBoolean(false); 1.90 + private volatile int env = -1; 1.91 + 1.92 + private ContentProviderClient client; 1.93 + private volatile HealthReportDatabaseStorage storage; 1.94 + private final ProfileInformationCache profileCache; 1.95 + private final EventDispatcher dispatcher; 1.96 + private final SharedPreferences prefs; 1.97 + 1.98 + // We track previousSession to avoid order-of-initialization confusion. We 1.99 + // accept it in the constructor, and process it after init. 1.100 + private final SessionInformation previousSession; 1.101 + private volatile SessionInformation session = null; 1.102 + 1.103 + public void setCurrentSession(SessionInformation session) { 1.104 + this.session = session; 1.105 + } 1.106 + 1.107 + public void recordGeckoStartupTime(long duration) { 1.108 + if (this.session == null) { 1.109 + return; 1.110 + } 1.111 + this.session.setTimedGeckoStartup(duration); 1.112 + } 1.113 + public void recordJavaStartupTime(long duration) { 1.114 + if (this.session == null) { 1.115 + return; 1.116 + } 1.117 + this.session.setTimedJavaStartup(duration); 1.118 + } 1.119 + 1.120 + /** 1.121 + * This constructor does IO. Run it on a background thread. 1.122 + * 1.123 + * appLocale can be null, which indicates that it will be provided later. 1.124 + */ 1.125 + public BrowserHealthRecorder(final Context context, 1.126 + final SharedPreferences appPrefs, 1.127 + final String profilePath, 1.128 + final EventDispatcher dispatcher, 1.129 + final String osLocale, 1.130 + final String appLocale, 1.131 + SessionInformation previousSession) { 1.132 + Log.d(LOG_TAG, "Initializing. Dispatcher is " + dispatcher); 1.133 + this.dispatcher = dispatcher; 1.134 + this.previousSession = previousSession; 1.135 + 1.136 + this.client = EnvironmentBuilder.getContentProviderClient(context); 1.137 + if (this.client == null) { 1.138 + throw new IllegalStateException("Could not fetch Health Report content provider."); 1.139 + } 1.140 + 1.141 + this.storage = EnvironmentBuilder.getStorage(this.client, profilePath); 1.142 + if (this.storage == null) { 1.143 + // Stick around even if we don't have storage: eventually we'll 1.144 + // want to report total failures of FHR storage itself, and this 1.145 + // way callers don't need to worry about whether their health 1.146 + // recorder didn't initialize. 1.147 + this.client.release(); 1.148 + this.client = null; 1.149 + } 1.150 + 1.151 + // Note that the PIC is not necessarily fully initialized at this point: 1.152 + // we haven't set the app locale. This must be done before an environment 1.153 + // is recorded. 1.154 + this.profileCache = new ProfileInformationCache(profilePath); 1.155 + try { 1.156 + this.initialize(context, profilePath, osLocale, appLocale); 1.157 + } catch (Exception e) { 1.158 + Log.e(LOG_TAG, "Exception initializing.", e); 1.159 + } 1.160 + 1.161 + this.prefs = appPrefs; 1.162 + } 1.163 + 1.164 + public boolean isEnabled() { 1.165 + return true; 1.166 + } 1.167 + 1.168 + /** 1.169 + * Shut down database connections, unregister event listeners, and perform 1.170 + * provider-specific uninitialization. 1.171 + */ 1.172 + public synchronized void close() { 1.173 + switch (this.state) { 1.174 + case CLOSED: 1.175 + Log.w(LOG_TAG, "Ignoring attempt to double-close closed BrowserHealthRecorder."); 1.176 + return; 1.177 + case INITIALIZED: 1.178 + Log.i(LOG_TAG, "Closing Health Report client."); 1.179 + break; 1.180 + default: 1.181 + Log.i(LOG_TAG, "Closing incompletely initialized BrowserHealthRecorder."); 1.182 + } 1.183 + 1.184 + this.state = State.CLOSED; 1.185 + this.unregisterEventListeners(); 1.186 + 1.187 + // Add any necessary provider uninitialization here. 1.188 + this.storage = null; 1.189 + if (this.client != null) { 1.190 + this.client.release(); 1.191 + this.client = null; 1.192 + } 1.193 + } 1.194 + 1.195 + private void unregisterEventListeners() { 1.196 + if (state != State.INITIALIZED) { 1.197 + return; 1.198 + } 1.199 + this.dispatcher.unregisterEventListener(EVENT_SNAPSHOT, this); 1.200 + this.dispatcher.unregisterEventListener(EVENT_ADDONS_CHANGE, this); 1.201 + this.dispatcher.unregisterEventListener(EVENT_ADDONS_UNINSTALLING, this); 1.202 + this.dispatcher.unregisterEventListener(EVENT_PREF_CHANGE, this); 1.203 + this.dispatcher.unregisterEventListener(EVENT_KEYWORD_SEARCH, this); 1.204 + this.dispatcher.unregisterEventListener(EVENT_SEARCH, this); 1.205 + } 1.206 + 1.207 + public void onAppLocaleChanged(String to) { 1.208 + Log.d(LOG_TAG, "Setting health recorder app locale to " + to); 1.209 + this.profileCache.beginInitialization(); 1.210 + this.profileCache.setAppLocale(to); 1.211 + } 1.212 + 1.213 + public void onAddonChanged(String id, JSONObject json) { 1.214 + this.profileCache.beginInitialization(); 1.215 + try { 1.216 + this.profileCache.updateJSONForAddon(id, json); 1.217 + } catch (IllegalStateException e) { 1.218 + Log.w(LOG_TAG, "Attempted to update add-on cache prior to full init.", e); 1.219 + } 1.220 + } 1.221 + 1.222 + public void onAddonUninstalling(String id) { 1.223 + this.profileCache.beginInitialization(); 1.224 + try { 1.225 + this.profileCache.removeAddon(id); 1.226 + } catch (IllegalStateException e) { 1.227 + Log.w(LOG_TAG, "Attempted to update add-on cache prior to full init.", e); 1.228 + } 1.229 + } 1.230 + 1.231 + /** 1.232 + * Call this when a material change might have occurred in the running 1.233 + * environment, such that a new environment should be computed and prepared 1.234 + * for use in future events. 1.235 + * 1.236 + * Invoke this method after calls that mutate the environment. 1.237 + * 1.238 + * If this change resulted in a transition between two environments, {@link 1.239 + * #onEnvironmentTransition(int, int, boolean, String)} will be invoked on the background 1.240 + * thread. 1.241 + */ 1.242 + public synchronized void onEnvironmentChanged() { 1.243 + onEnvironmentChanged(true, "E"); 1.244 + } 1.245 + 1.246 + /** 1.247 + * If `startNewSession` is false, it means no new session should begin 1.248 + * (e.g., because we're about to restart, and we don't want to create 1.249 + * an orphan). 1.250 + */ 1.251 + public synchronized void onEnvironmentChanged(final boolean startNewSession, final String sessionEndReason) { 1.252 + final int previousEnv = this.env; 1.253 + this.env = -1; 1.254 + try { 1.255 + profileCache.completeInitialization(); 1.256 + } catch (java.io.IOException e) { 1.257 + Log.e(LOG_TAG, "Error completing profile cache initialization.", e); 1.258 + this.state = State.INITIALIZATION_FAILED; 1.259 + return; 1.260 + } 1.261 + 1.262 + final int updatedEnv = ensureEnvironment(); 1.263 + 1.264 + if (updatedEnv == -1 || 1.265 + updatedEnv == previousEnv) { 1.266 + Log.v(LOG_TAG, "Environment didn't change."); 1.267 + return; 1.268 + } 1.269 + ThreadUtils.postToBackgroundThread(new Runnable() { 1.270 + @Override 1.271 + public void run() { 1.272 + try { 1.273 + onEnvironmentTransition(previousEnv, updatedEnv, startNewSession, sessionEndReason); 1.274 + } catch (Exception e) { 1.275 + Log.w(LOG_TAG, "Could not record environment transition.", e); 1.276 + } 1.277 + } 1.278 + }); 1.279 + } 1.280 + 1.281 + protected synchronized int ensureEnvironment() { 1.282 + if (!(state == State.INITIALIZING || 1.283 + state == State.INITIALIZED)) { 1.284 + throw new IllegalStateException("Not initialized."); 1.285 + } 1.286 + 1.287 + if (this.env != -1) { 1.288 + return this.env; 1.289 + } 1.290 + if (this.storage == null) { 1.291 + // Oh well. 1.292 + return -1; 1.293 + } 1.294 + return this.env = EnvironmentBuilder.registerCurrentEnvironment(this.storage, 1.295 + this.profileCache); 1.296 + } 1.297 + 1.298 + private static final String getTimesPath(final String profilePath) { 1.299 + return profilePath + File.separator + "times.json"; 1.300 + } 1.301 + 1.302 + /** 1.303 + * Retrieve the stored profile creation time from the profile directory. 1.304 + * 1.305 + * @return the <code>created</code> value from the times.json file, or -1 on failure. 1.306 + */ 1.307 + protected static long getProfileInitTimeFromFile(final String profilePath) { 1.308 + final File times = new File(getTimesPath(profilePath)); 1.309 + Log.d(LOG_TAG, "Looking for " + times.getAbsolutePath()); 1.310 + if (!times.exists()) { 1.311 + return -1; 1.312 + } 1.313 + 1.314 + Log.d(LOG_TAG, "Using times.json for profile creation time."); 1.315 + Scanner scanner = null; 1.316 + try { 1.317 + scanner = new Scanner(times, "UTF-8"); 1.318 + final String contents = scanner.useDelimiter("\\A").next(); 1.319 + return new JSONObject(contents).getLong("created"); 1.320 + } catch (Exception e) { 1.321 + // There are assorted reasons why this might occur, but we 1.322 + // don't care. Move on. 1.323 + Log.w(LOG_TAG, "Failed to read times.json.", e); 1.324 + } finally { 1.325 + if (scanner != null) { 1.326 + scanner.close(); 1.327 + } 1.328 + } 1.329 + return -1; 1.330 + } 1.331 + 1.332 + /** 1.333 + * Only works on API 9 and up. 1.334 + * 1.335 + * @return the package install time, or -1 if an error occurred. 1.336 + */ 1.337 + protected static long getPackageInstallTime(final Context context) { 1.338 + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.GINGERBREAD) { 1.339 + return -1; 1.340 + } 1.341 + 1.342 + try { 1.343 + return context.getPackageManager().getPackageInfo(AppConstants.ANDROID_PACKAGE_NAME, 0).firstInstallTime; 1.344 + } catch (android.content.pm.PackageManager.NameNotFoundException e) { 1.345 + Log.e(LOG_TAG, "Unable to fetch our own package info. This should never occur.", e); 1.346 + } 1.347 + return -1; 1.348 + } 1.349 + 1.350 + private static long getProfileInitTimeHeuristic(final Context context, final String profilePath) { 1.351 + // As a pretty good shortcut, settle for installation time. 1.352 + // In all recent Firefox profiles, times.json should exist. 1.353 + final long time = getPackageInstallTime(context); 1.354 + if (time != -1) { 1.355 + return time; 1.356 + } 1.357 + 1.358 + // Otherwise, fall back to the filesystem. 1.359 + // We'll settle for the modification time of the profile directory. 1.360 + Log.d(LOG_TAG, "Using profile directory modified time as proxy for profile creation time."); 1.361 + return new File(profilePath).lastModified(); 1.362 + } 1.363 + 1.364 + private static long getAndPersistProfileInitTime(final Context context, final String profilePath) { 1.365 + // Let's look in the profile. 1.366 + long time = getProfileInitTimeFromFile(profilePath); 1.367 + if (time > 0) { 1.368 + Log.d(LOG_TAG, "Incorporating environment: times.json profile creation = " + time); 1.369 + return time; 1.370 + } 1.371 + 1.372 + // Otherwise, we need to compute a valid creation time and write it out. 1.373 + time = getProfileInitTimeHeuristic(context, profilePath); 1.374 + 1.375 + if (time > 0) { 1.376 + // Write out a stub times.json. 1.377 + try { 1.378 + FileOutputStream stream = new FileOutputStream(getTimesPath(profilePath)); 1.379 + OutputStreamWriter writer = new OutputStreamWriter(stream, Charset.forName("UTF-8")); 1.380 + try { 1.381 + writer.append("{\"created\": " + time + "}\n"); 1.382 + } finally { 1.383 + writer.close(); 1.384 + } 1.385 + } catch (Exception e) { 1.386 + // Best-effort. 1.387 + Log.w(LOG_TAG, "Couldn't write times.json.", e); 1.388 + } 1.389 + } 1.390 + 1.391 + Log.d(LOG_TAG, "Incorporating environment: profile creation = " + time); 1.392 + return time; 1.393 + } 1.394 + 1.395 + private void onPrefMessage(final String pref, final JSONObject message) { 1.396 + Log.d(LOG_TAG, "Incorporating environment: " + pref); 1.397 + if (PREF_ACCEPT_LANG.equals(pref)) { 1.398 + // We only record whether this is user-set. 1.399 + try { 1.400 + this.profileCache.beginInitialization(); 1.401 + this.profileCache.setAcceptLangUserSet(message.getBoolean("isUserSet")); 1.402 + } catch (JSONException ex) { 1.403 + Log.w(LOG_TAG, "Unexpected JSONException fetching isUserSet for " + pref); 1.404 + } 1.405 + return; 1.406 + } 1.407 + 1.408 + // (We only handle boolean prefs right now.) 1.409 + try { 1.410 + boolean value = message.getBoolean("value"); 1.411 + 1.412 + if (AppConstants.TELEMETRY_PREF_NAME.equals(pref)) { 1.413 + this.profileCache.beginInitialization(); 1.414 + this.profileCache.setTelemetryEnabled(value); 1.415 + return; 1.416 + } 1.417 + 1.418 + if (PREF_BLOCKLIST_ENABLED.equals(pref)) { 1.419 + this.profileCache.beginInitialization(); 1.420 + this.profileCache.setBlocklistEnabled(value); 1.421 + return; 1.422 + } 1.423 + } catch (JSONException ex) { 1.424 + Log.w(LOG_TAG, "Unexpected JSONException fetching boolean value for " + pref); 1.425 + return; 1.426 + } 1.427 + Log.w(LOG_TAG, "Unexpected pref: " + pref); 1.428 + } 1.429 + 1.430 + /** 1.431 + * Background init helper. 1.432 + */ 1.433 + private void initializeStorage() { 1.434 + Log.d(LOG_TAG, "Done initializing profile cache. Beginning storage init."); 1.435 + 1.436 + final BrowserHealthRecorder self = this; 1.437 + ThreadUtils.postToBackgroundThread(new Runnable() { 1.438 + @Override 1.439 + public void run() { 1.440 + synchronized (self) { 1.441 + if (state != State.INITIALIZING) { 1.442 + Log.w(LOG_TAG, "Unexpected state during init: " + state); 1.443 + return; 1.444 + } 1.445 + 1.446 + // Belt and braces. 1.447 + if (storage == null) { 1.448 + Log.w(LOG_TAG, "Storage is null during init; shutting down?"); 1.449 + if (state == State.INITIALIZING) { 1.450 + state = State.INITIALIZATION_FAILED; 1.451 + } 1.452 + return; 1.453 + } 1.454 + 1.455 + try { 1.456 + storage.beginInitialization(); 1.457 + } catch (Exception e) { 1.458 + Log.e(LOG_TAG, "Failed to init storage.", e); 1.459 + state = State.INITIALIZATION_FAILED; 1.460 + return; 1.461 + } 1.462 + 1.463 + try { 1.464 + // Listen for add-ons and prefs changes. 1.465 + dispatcher.registerEventListener(EVENT_ADDONS_UNINSTALLING, self); 1.466 + dispatcher.registerEventListener(EVENT_ADDONS_CHANGE, self); 1.467 + dispatcher.registerEventListener(EVENT_PREF_CHANGE, self); 1.468 + 1.469 + // Initialize each provider here. 1.470 + initializeSessionsProvider(); 1.471 + initializeSearchProvider(); 1.472 + 1.473 + Log.d(LOG_TAG, "Ensuring environment."); 1.474 + ensureEnvironment(); 1.475 + 1.476 + Log.d(LOG_TAG, "Finishing init."); 1.477 + storage.finishInitialization(); 1.478 + state = State.INITIALIZED; 1.479 + } catch (Exception e) { 1.480 + state = State.INITIALIZATION_FAILED; 1.481 + storage.abortInitialization(); 1.482 + Log.e(LOG_TAG, "Initialization failed.", e); 1.483 + return; 1.484 + } 1.485 + 1.486 + // Now do whatever we do after we start up. 1.487 + checkForOrphanSessions(); 1.488 + } 1.489 + } 1.490 + }); 1.491 + } 1.492 + 1.493 + /** 1.494 + * Add provider-specific initialization in this method. 1.495 + */ 1.496 + private synchronized void initialize(final Context context, 1.497 + final String profilePath, 1.498 + final String osLocale, 1.499 + final String appLocale) 1.500 + throws java.io.IOException { 1.501 + 1.502 + Log.d(LOG_TAG, "Initializing profile cache."); 1.503 + this.state = State.INITIALIZING; 1.504 + 1.505 + // If we can restore state from last time, great. 1.506 + if (this.profileCache.restoreUnlessInitialized()) { 1.507 + this.profileCache.updateLocales(osLocale, appLocale); 1.508 + this.profileCache.completeInitialization(); 1.509 + 1.510 + Log.d(LOG_TAG, "Successfully restored state. Initializing storage."); 1.511 + initializeStorage(); 1.512 + return; 1.513 + } 1.514 + 1.515 + // Otherwise, let's initialize it from scratch. 1.516 + this.profileCache.beginInitialization(); 1.517 + this.profileCache.setProfileCreationTime(getAndPersistProfileInitTime(context, profilePath)); 1.518 + this.profileCache.setOSLocale(osLocale); 1.519 + this.profileCache.setAppLocale(appLocale); 1.520 + 1.521 + // Because the distribution lookup can take some time, do it at the end of 1.522 + // our background startup work, along with the Gecko snapshot fetch. 1.523 + final GeckoEventListener self = this; 1.524 + ThreadUtils.postToBackgroundThread(new Runnable() { 1.525 + @Override 1.526 + public void run() { 1.527 + final DistributionDescriptor desc = new Distribution(context).getDescriptor(); 1.528 + if (desc != null && desc.valid) { 1.529 + profileCache.setDistributionString(desc.id, desc.version); 1.530 + } 1.531 + Log.d(LOG_TAG, "Requesting all add-ons and FHR prefs from Gecko."); 1.532 + dispatcher.registerEventListener(EVENT_SNAPSHOT, self); 1.533 + GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("HealthReport:RequestSnapshot", null)); 1.534 + } 1.535 + }); 1.536 + } 1.537 + 1.538 + /** 1.539 + * Invoked in the background whenever the environment transitions between 1.540 + * two valid values. 1.541 + */ 1.542 + protected void onEnvironmentTransition(int prev, int env, boolean startNewSession, String sessionEndReason) { 1.543 + if (this.state != State.INITIALIZED) { 1.544 + Log.d(LOG_TAG, "Not initialized: not recording env transition (" + prev + " => " + env + ")."); 1.545 + return; 1.546 + } 1.547 + 1.548 + final SharedPreferences.Editor editor = this.prefs.edit(); 1.549 + 1.550 + recordSessionEnd(sessionEndReason, editor, prev); 1.551 + 1.552 + if (!startNewSession) { 1.553 + editor.commit(); 1.554 + return; 1.555 + } 1.556 + 1.557 + final SessionInformation newSession = SessionInformation.forRuntimeTransition(); 1.558 + setCurrentSession(newSession); 1.559 + newSession.recordBegin(editor); 1.560 + editor.commit(); 1.561 + } 1.562 + 1.563 + @Override 1.564 + public void handleMessage(String event, JSONObject message) { 1.565 + try { 1.566 + if (EVENT_SNAPSHOT.equals(event)) { 1.567 + Log.d(LOG_TAG, "Got all add-ons and prefs."); 1.568 + try { 1.569 + JSONObject json = message.getJSONObject("json"); 1.570 + JSONObject addons = json.getJSONObject("addons"); 1.571 + Log.i(LOG_TAG, "Persisting " + addons.length() + " add-ons."); 1.572 + profileCache.setJSONForAddons(addons); 1.573 + 1.574 + JSONObject prefs = json.getJSONObject("prefs"); 1.575 + Log.i(LOG_TAG, "Persisting prefs."); 1.576 + Iterator<?> keys = prefs.keys(); 1.577 + while (keys.hasNext()) { 1.578 + String pref = (String) keys.next(); 1.579 + this.onPrefMessage(pref, prefs.getJSONObject(pref)); 1.580 + } 1.581 + 1.582 + profileCache.completeInitialization(); 1.583 + } catch (java.io.IOException e) { 1.584 + Log.e(LOG_TAG, "Error completing profile cache initialization.", e); 1.585 + state = State.INITIALIZATION_FAILED; 1.586 + return; 1.587 + } 1.588 + 1.589 + if (state == State.INITIALIZING) { 1.590 + initializeStorage(); 1.591 + } else { 1.592 + this.onEnvironmentChanged(); 1.593 + } 1.594 + 1.595 + return; 1.596 + } 1.597 + 1.598 + if (EVENT_ADDONS_UNINSTALLING.equals(event)) { 1.599 + this.onAddonUninstalling(message.getString("id")); 1.600 + this.onEnvironmentChanged(); 1.601 + return; 1.602 + } 1.603 + 1.604 + if (EVENT_ADDONS_CHANGE.equals(event)) { 1.605 + this.onAddonChanged(message.getString("id"), message.getJSONObject("json")); 1.606 + this.onEnvironmentChanged(); 1.607 + return; 1.608 + } 1.609 + 1.610 + if (EVENT_PREF_CHANGE.equals(event)) { 1.611 + final String pref = message.getString("pref"); 1.612 + Log.d(LOG_TAG, "Pref changed: " + pref); 1.613 + this.onPrefMessage(pref, message); 1.614 + this.onEnvironmentChanged(); 1.615 + return; 1.616 + } 1.617 + 1.618 + // Searches. 1.619 + if (EVENT_KEYWORD_SEARCH.equals(event)) { 1.620 + // A search via the URL bar. Since we eliminate all other search possibilities 1.621 + // (e.g. bookmarks keyword, search suggestion) when we initially process the 1.622 + // search URL, this is considered a default search. 1.623 + recordSearch(message.getString("identifier"), "bartext"); 1.624 + return; 1.625 + } 1.626 + if (EVENT_SEARCH.equals(event)) { 1.627 + if (!message.has("location")) { 1.628 + Log.d(LOG_TAG, "Ignoring search without location."); 1.629 + return; 1.630 + } 1.631 + recordSearch(message.optString("identifier", null), message.getString("location")); 1.632 + return; 1.633 + } 1.634 + } catch (Exception e) { 1.635 + Log.e(LOG_TAG, "Exception handling message \"" + event + "\":", e); 1.636 + } 1.637 + } 1.638 + 1.639 + /* 1.640 + * Searches. 1.641 + */ 1.642 + 1.643 + public static final String MEASUREMENT_NAME_SEARCH_COUNTS = "org.mozilla.searches.counts"; 1.644 + public static final int MEASUREMENT_VERSION_SEARCH_COUNTS = 5; 1.645 + 1.646 + public static final Set<String> SEARCH_LOCATIONS = Collections.unmodifiableSet(new HashSet<String>(Arrays.asList(new String[] { 1.647 + "barkeyword", 1.648 + "barsuggest", 1.649 + "bartext", 1.650 + }))); 1.651 + 1.652 + private void initializeSearchProvider() { 1.653 + this.storage.ensureMeasurementInitialized( 1.654 + MEASUREMENT_NAME_SEARCH_COUNTS, 1.655 + MEASUREMENT_VERSION_SEARCH_COUNTS, 1.656 + new MeasurementFields() { 1.657 + @Override 1.658 + public Iterable<FieldSpec> getFields() { 1.659 + ArrayList<FieldSpec> out = new ArrayList<FieldSpec>(SEARCH_LOCATIONS.size()); 1.660 + for (String location : SEARCH_LOCATIONS) { 1.661 + // We're not using a counter, because the set of engine 1.662 + // identifiers is potentially unbounded, and thus our 1.663 + // measurement version would have to keep growing as 1.664 + // fields changed. Instead we store discrete values, and 1.665 + // accumulate them into a counting map during processing. 1.666 + out.add(new FieldSpec(location, Field.TYPE_COUNTED_STRING_DISCRETE)); 1.667 + } 1.668 + return out; 1.669 + } 1.670 + }); 1.671 + 1.672 + // Do this here, rather than in a centralized registration spot, in 1.673 + // case the above throws and we wind up handling events that we can't 1.674 + // store. 1.675 + this.dispatcher.registerEventListener(EVENT_KEYWORD_SEARCH, this); 1.676 + this.dispatcher.registerEventListener(EVENT_SEARCH, this); 1.677 + } 1.678 + 1.679 + /** 1.680 + * Record a search. 1.681 + * 1.682 + * @param engineID the string identifier for the engine. Can be <code>null</code>. 1.683 + * @param location one of a fixed set of locations: see {@link #SEARCH_LOCATIONS}. 1.684 + */ 1.685 + public void recordSearch(final String engineID, final String location) { 1.686 + if (this.state != State.INITIALIZED) { 1.687 + Log.d(LOG_TAG, "Not initialized: not recording search. (" + this.state + ")"); 1.688 + return; 1.689 + } 1.690 + 1.691 + final int env = this.env; 1.692 + 1.693 + if (env == -1) { 1.694 + Log.d(LOG_TAG, "No environment: not recording search."); 1.695 + return; 1.696 + } 1.697 + 1.698 + if (location == null) { 1.699 + throw new IllegalArgumentException("location must be provided for search."); 1.700 + } 1.701 + 1.702 + // Ensure that we don't throw when trying to look up the field for an 1.703 + // unknown location. If you add a search location, you must extend the 1.704 + // list of search locations *and update the measurement version*. 1.705 + if (!SEARCH_LOCATIONS.contains(location)) { 1.706 + throw new IllegalArgumentException("Unexpected location: " + location); 1.707 + } 1.708 + 1.709 + final int day = storage.getDay(); 1.710 + final String key = (engineID == null) ? "other" : engineID; 1.711 + 1.712 + ThreadUtils.postToBackgroundThread(new Runnable() { 1.713 + @Override 1.714 + public void run() { 1.715 + final HealthReportDatabaseStorage storage = BrowserHealthRecorder.this.storage; 1.716 + if (storage == null) { 1.717 + Log.d(LOG_TAG, "No storage: not recording search. Shutting down?"); 1.718 + return; 1.719 + } 1.720 + 1.721 + Log.d(LOG_TAG, "Recording search: " + key + ", " + location + 1.722 + " (" + day + ", " + env + ")."); 1.723 + final int searchField = storage.getField(MEASUREMENT_NAME_SEARCH_COUNTS, 1.724 + MEASUREMENT_VERSION_SEARCH_COUNTS, 1.725 + location) 1.726 + .getID(); 1.727 + storage.recordDailyDiscrete(env, day, searchField, key); 1.728 + } 1.729 + }); 1.730 + } 1.731 + 1.732 + /* 1.733 + * Sessions. 1.734 + * 1.735 + * We record session beginnings in SharedPreferences, because it's cheaper 1.736 + * to do that than to either write to then update the DB (which requires 1.737 + * keeping a row identifier to update, as well as two writes) or to record 1.738 + * two events (which doubles storage space and requires rollup logic). 1.739 + * 1.740 + * The pattern is: 1.741 + * 1.742 + * 1. On startup, determine whether an orphan session exists by looking for 1.743 + * a saved timestamp in prefs. If it does, then record the orphan in FHR 1.744 + * storage. 1.745 + * 2. Record in prefs that a new session has begun. Track the timestamp (so 1.746 + * we know to which day the session belongs). 1.747 + * 3. As startup timings become available, accumulate them in memory. 1.748 + * 4. On clean shutdown, read the values from here, write them to the DB, and 1.749 + * delete the sentinel time from SharedPreferences. 1.750 + * 5. On a dirty shutdown, the in-memory session will not be written to the 1.751 + * DB, and the current session will be orphaned. 1.752 + * 1.753 + * Sessions are begun in onResume (and thus implicitly onStart) and ended 1.754 + * in onPause. 1.755 + * 1.756 + * Session objects are stored as discrete JSON. 1.757 + * 1.758 + * "org.mozilla.appSessions": { 1.759 + * _v: 4, 1.760 + * "normal": [ 1.761 + * {"r":"P", "d": 123}, 1.762 + * ], 1.763 + * "abnormal": [ 1.764 + * {"r":"A", "oom": true, "stopped": false} 1.765 + * ] 1.766 + * } 1.767 + * 1.768 + * "r": reason. Values are "P" (activity paused), "A" (abnormal termination) 1.769 + * "d": duration. Value in seconds. 1.770 + * "sg": Gecko startup time. Present if this is a clean launch. This 1.771 + * corresponds to the telemetry timer FENNEC_STARTUP_TIME_GECKOREADY. 1.772 + * "sj": Java activity init time. Present if this is a clean launch. This 1.773 + * corresponds to the telemetry timer FENNEC_STARTUP_TIME_JAVAUI, 1.774 + * and includes initialization tasks beyond initial 1.775 + * onWindowFocusChanged. 1.776 + * 1.777 + * Abnormal terminations will be missing a duration and will feature these keys: 1.778 + * 1.779 + * "oom": was the session killed by an OOM exception? 1.780 + * "stopped": was the session stopped gently? 1.781 + */ 1.782 + 1.783 + public static final String MEASUREMENT_NAME_SESSIONS = "org.mozilla.appSessions"; 1.784 + public static final int MEASUREMENT_VERSION_SESSIONS = 4; 1.785 + 1.786 + private void initializeSessionsProvider() { 1.787 + this.storage.ensureMeasurementInitialized( 1.788 + MEASUREMENT_NAME_SESSIONS, 1.789 + MEASUREMENT_VERSION_SESSIONS, 1.790 + new MeasurementFields() { 1.791 + @Override 1.792 + public Iterable<FieldSpec> getFields() { 1.793 + List<FieldSpec> out = Arrays.asList( 1.794 + new FieldSpec("normal", Field.TYPE_JSON_DISCRETE), 1.795 + new FieldSpec("abnormal", Field.TYPE_JSON_DISCRETE)); 1.796 + return out; 1.797 + } 1.798 + }); 1.799 + } 1.800 + 1.801 + /** 1.802 + * Logic shared between crashed and normal sessions. 1.803 + */ 1.804 + private void recordSessionEntry(String field, SessionInformation session, final int environment, JSONObject value) { 1.805 + final HealthReportDatabaseStorage storage = this.storage; 1.806 + if (storage == null) { 1.807 + Log.d(LOG_TAG, "No storage: not recording session entry. Shutting down?"); 1.808 + return; 1.809 + } 1.810 + 1.811 + try { 1.812 + final int sessionField = storage.getField(MEASUREMENT_NAME_SESSIONS, 1.813 + MEASUREMENT_VERSION_SESSIONS, 1.814 + field) 1.815 + .getID(); 1.816 + final int day = storage.getDay(session.wallStartTime); 1.817 + storage.recordDailyDiscrete(environment, day, sessionField, value); 1.818 + Log.v(LOG_TAG, "Recorded session entry for env " + environment + ", current is " + env); 1.819 + } catch (Exception e) { 1.820 + Log.w(LOG_TAG, "Unable to record session completion.", e); 1.821 + } 1.822 + } 1.823 + 1.824 + public void checkForOrphanSessions() { 1.825 + if (!this.orphanChecked.compareAndSet(false, true)) { 1.826 + Log.w(LOG_TAG, "Attempting to check for orphan sessions more than once."); 1.827 + return; 1.828 + } 1.829 + 1.830 + Log.d(LOG_TAG, "Checking for orphan session."); 1.831 + if (this.previousSession == null) { 1.832 + return; 1.833 + } 1.834 + if (this.previousSession.wallStartTime == 0) { 1.835 + return; 1.836 + } 1.837 + 1.838 + if (state != State.INITIALIZED) { 1.839 + // Something has gone awry. 1.840 + Log.e(LOG_TAG, "Attempted to record bad session end without initialized recorder."); 1.841 + return; 1.842 + } 1.843 + 1.844 + try { 1.845 + recordSessionEntry("abnormal", this.previousSession, this.env, 1.846 + this.previousSession.getCrashedJSON()); 1.847 + } catch (Exception e) { 1.848 + Log.w(LOG_TAG, "Unable to generate session JSON.", e); 1.849 + 1.850 + // Future: record this exception in FHR's own error submitter. 1.851 + } 1.852 + } 1.853 + 1.854 + public void recordSessionEnd(String reason, SharedPreferences.Editor editor) { 1.855 + recordSessionEnd(reason, editor, env); 1.856 + } 1.857 + 1.858 + /** 1.859 + * Record that the current session ended. Does not commit the provided editor. 1.860 + * 1.861 + * @param environment An environment ID. This allows callers to record the 1.862 + * end of a session due to an observed environment change. 1.863 + */ 1.864 + public void recordSessionEnd(String reason, SharedPreferences.Editor editor, final int environment) { 1.865 + Log.d(LOG_TAG, "Recording session end: " + reason); 1.866 + if (state != State.INITIALIZED) { 1.867 + // Something has gone awry. 1.868 + Log.e(LOG_TAG, "Attempted to record session end without initialized recorder."); 1.869 + return; 1.870 + } 1.871 + 1.872 + final SessionInformation session = this.session; 1.873 + this.session = null; // So it can't be double-recorded. 1.874 + 1.875 + if (session == null) { 1.876 + Log.w(LOG_TAG, "Unable to record session end: no session. Already ended?"); 1.877 + return; 1.878 + } 1.879 + 1.880 + if (session.wallStartTime <= 0) { 1.881 + Log.e(LOG_TAG, "Session start " + session.wallStartTime + " isn't valid! Can't record end."); 1.882 + return; 1.883 + } 1.884 + 1.885 + long realEndTime = android.os.SystemClock.elapsedRealtime(); 1.886 + try { 1.887 + JSONObject json = session.getCompletionJSON(reason, realEndTime); 1.888 + recordSessionEntry("normal", session, environment, json); 1.889 + } catch (JSONException e) { 1.890 + Log.w(LOG_TAG, "Unable to generate session JSON.", e); 1.891 + 1.892 + // Continue so we don't hit it next time. 1.893 + // Future: record this exception in FHR's own error submitter. 1.894 + } 1.895 + 1.896 + // Track the end of this session in shared prefs, so it doesn't get 1.897 + // double-counted on next run. 1.898 + session.recordCompletion(editor); 1.899 + } 1.900 +}