mobile/android/base/health/BrowserHealthRecorder.java

Wed, 31 Dec 2014 06:09:35 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Wed, 31 Dec 2014 06:09:35 +0100
changeset 0
6474c204b198
permissions
-rw-r--r--

Cloned upstream origin tor-browser at tor-browser-31.3.0esr-4.5-1-build1
revision ID fc1c9ff7c1b2defdbc039f12214767608f46423f for hacking purpose.

michael@0 1 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
michael@0 2 * This Source Code Form is subject to the terms of the Mozilla Public
michael@0 3 * License, v. 2.0. If a copy of the MPL was not distributed with this
michael@0 4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
michael@0 5
michael@0 6 package org.mozilla.gecko.health;
michael@0 7
michael@0 8 import java.io.File;
michael@0 9 import java.io.FileOutputStream;
michael@0 10 import java.io.OutputStreamWriter;
michael@0 11 import java.nio.charset.Charset;
michael@0 12 import java.util.ArrayList;
michael@0 13 import java.util.Arrays;
michael@0 14 import java.util.Collections;
michael@0 15 import java.util.HashSet;
michael@0 16 import java.util.Iterator;
michael@0 17 import java.util.List;
michael@0 18 import java.util.Scanner;
michael@0 19 import java.util.Set;
michael@0 20 import java.util.concurrent.atomic.AtomicBoolean;
michael@0 21
michael@0 22 import org.json.JSONException;
michael@0 23 import org.json.JSONObject;
michael@0 24 import org.mozilla.gecko.AppConstants;
michael@0 25 import org.mozilla.gecko.Distribution;
michael@0 26 import org.mozilla.gecko.Distribution.DistributionDescriptor;
michael@0 27 import org.mozilla.gecko.EventDispatcher;
michael@0 28 import org.mozilla.gecko.GeckoAppShell;
michael@0 29 import org.mozilla.gecko.GeckoEvent;
michael@0 30 import org.mozilla.gecko.background.healthreport.EnvironmentBuilder;
michael@0 31 import org.mozilla.gecko.background.healthreport.HealthReportDatabaseStorage;
michael@0 32 import org.mozilla.gecko.background.healthreport.HealthReportStorage.Field;
michael@0 33 import org.mozilla.gecko.background.healthreport.HealthReportStorage.MeasurementFields;
michael@0 34 import org.mozilla.gecko.background.healthreport.ProfileInformationCache;
michael@0 35 import org.mozilla.gecko.util.GeckoEventListener;
michael@0 36 import org.mozilla.gecko.util.ThreadUtils;
michael@0 37
michael@0 38 import android.content.ContentProviderClient;
michael@0 39 import android.content.Context;
michael@0 40 import android.content.SharedPreferences;
michael@0 41 import android.util.Log;
michael@0 42
michael@0 43 /**
michael@0 44 * BrowserHealthRecorder is the browser's interface to the Firefox Health
michael@0 45 * Report storage system. It manages environments (a collection of attributes
michael@0 46 * that are tracked longitudinally) on the browser's behalf, exposing a simpler
michael@0 47 * interface for recording changes.
michael@0 48 *
michael@0 49 * Keep an instance of this class around.
michael@0 50 *
michael@0 51 * Tell it when an environment attribute has changed: call {@link
michael@0 52 * #onAppLocaleChanged(String)} followed by {@link
michael@0 53 * #onEnvironmentChanged()}.
michael@0 54 *
michael@0 55 * Use it to record events: {@link #recordSearch(String, String)}.
michael@0 56 *
michael@0 57 * Shut it down when you're done being a browser: {@link #close()}.
michael@0 58 */
michael@0 59 public class BrowserHealthRecorder implements HealthRecorder, GeckoEventListener {
michael@0 60 private static final String LOG_TAG = "GeckoHealthRec";
michael@0 61 private static final String PREF_ACCEPT_LANG = "intl.accept_languages";
michael@0 62 private static final String PREF_BLOCKLIST_ENABLED = "extensions.blocklist.enabled";
michael@0 63 private static final String EVENT_SNAPSHOT = "HealthReport:Snapshot";
michael@0 64 private static final String EVENT_ADDONS_CHANGE = "Addons:Change";
michael@0 65 private static final String EVENT_ADDONS_UNINSTALLING = "Addons:Uninstalling";
michael@0 66 private static final String EVENT_PREF_CHANGE = "Pref:Change";
michael@0 67
michael@0 68 // This is raised from Gecko and signifies a search via the URL bar (not a bookmarks keyword
michael@0 69 // search). Using this event (rather than passing the invocation location as an arg) avoids
michael@0 70 // browser.js having to know about the invocation location.
michael@0 71 public static final String EVENT_KEYWORD_SEARCH = "Search:Keyword";
michael@0 72
michael@0 73 // This is raised from Java. We include the location in the message.
michael@0 74 public static final String EVENT_SEARCH = "Search:Event";
michael@0 75
michael@0 76 public enum State {
michael@0 77 NOT_INITIALIZED,
michael@0 78 INITIALIZING,
michael@0 79 INITIALIZED,
michael@0 80 INITIALIZATION_FAILED,
michael@0 81 CLOSED
michael@0 82 }
michael@0 83
michael@0 84 protected volatile State state = State.NOT_INITIALIZED;
michael@0 85
michael@0 86 private final AtomicBoolean orphanChecked = new AtomicBoolean(false);
michael@0 87 private volatile int env = -1;
michael@0 88
michael@0 89 private ContentProviderClient client;
michael@0 90 private volatile HealthReportDatabaseStorage storage;
michael@0 91 private final ProfileInformationCache profileCache;
michael@0 92 private final EventDispatcher dispatcher;
michael@0 93 private final SharedPreferences prefs;
michael@0 94
michael@0 95 // We track previousSession to avoid order-of-initialization confusion. We
michael@0 96 // accept it in the constructor, and process it after init.
michael@0 97 private final SessionInformation previousSession;
michael@0 98 private volatile SessionInformation session = null;
michael@0 99
michael@0 100 public void setCurrentSession(SessionInformation session) {
michael@0 101 this.session = session;
michael@0 102 }
michael@0 103
michael@0 104 public void recordGeckoStartupTime(long duration) {
michael@0 105 if (this.session == null) {
michael@0 106 return;
michael@0 107 }
michael@0 108 this.session.setTimedGeckoStartup(duration);
michael@0 109 }
michael@0 110 public void recordJavaStartupTime(long duration) {
michael@0 111 if (this.session == null) {
michael@0 112 return;
michael@0 113 }
michael@0 114 this.session.setTimedJavaStartup(duration);
michael@0 115 }
michael@0 116
michael@0 117 /**
michael@0 118 * This constructor does IO. Run it on a background thread.
michael@0 119 *
michael@0 120 * appLocale can be null, which indicates that it will be provided later.
michael@0 121 */
michael@0 122 public BrowserHealthRecorder(final Context context,
michael@0 123 final SharedPreferences appPrefs,
michael@0 124 final String profilePath,
michael@0 125 final EventDispatcher dispatcher,
michael@0 126 final String osLocale,
michael@0 127 final String appLocale,
michael@0 128 SessionInformation previousSession) {
michael@0 129 Log.d(LOG_TAG, "Initializing. Dispatcher is " + dispatcher);
michael@0 130 this.dispatcher = dispatcher;
michael@0 131 this.previousSession = previousSession;
michael@0 132
michael@0 133 this.client = EnvironmentBuilder.getContentProviderClient(context);
michael@0 134 if (this.client == null) {
michael@0 135 throw new IllegalStateException("Could not fetch Health Report content provider.");
michael@0 136 }
michael@0 137
michael@0 138 this.storage = EnvironmentBuilder.getStorage(this.client, profilePath);
michael@0 139 if (this.storage == null) {
michael@0 140 // Stick around even if we don't have storage: eventually we'll
michael@0 141 // want to report total failures of FHR storage itself, and this
michael@0 142 // way callers don't need to worry about whether their health
michael@0 143 // recorder didn't initialize.
michael@0 144 this.client.release();
michael@0 145 this.client = null;
michael@0 146 }
michael@0 147
michael@0 148 // Note that the PIC is not necessarily fully initialized at this point:
michael@0 149 // we haven't set the app locale. This must be done before an environment
michael@0 150 // is recorded.
michael@0 151 this.profileCache = new ProfileInformationCache(profilePath);
michael@0 152 try {
michael@0 153 this.initialize(context, profilePath, osLocale, appLocale);
michael@0 154 } catch (Exception e) {
michael@0 155 Log.e(LOG_TAG, "Exception initializing.", e);
michael@0 156 }
michael@0 157
michael@0 158 this.prefs = appPrefs;
michael@0 159 }
michael@0 160
michael@0 161 public boolean isEnabled() {
michael@0 162 return true;
michael@0 163 }
michael@0 164
michael@0 165 /**
michael@0 166 * Shut down database connections, unregister event listeners, and perform
michael@0 167 * provider-specific uninitialization.
michael@0 168 */
michael@0 169 public synchronized void close() {
michael@0 170 switch (this.state) {
michael@0 171 case CLOSED:
michael@0 172 Log.w(LOG_TAG, "Ignoring attempt to double-close closed BrowserHealthRecorder.");
michael@0 173 return;
michael@0 174 case INITIALIZED:
michael@0 175 Log.i(LOG_TAG, "Closing Health Report client.");
michael@0 176 break;
michael@0 177 default:
michael@0 178 Log.i(LOG_TAG, "Closing incompletely initialized BrowserHealthRecorder.");
michael@0 179 }
michael@0 180
michael@0 181 this.state = State.CLOSED;
michael@0 182 this.unregisterEventListeners();
michael@0 183
michael@0 184 // Add any necessary provider uninitialization here.
michael@0 185 this.storage = null;
michael@0 186 if (this.client != null) {
michael@0 187 this.client.release();
michael@0 188 this.client = null;
michael@0 189 }
michael@0 190 }
michael@0 191
michael@0 192 private void unregisterEventListeners() {
michael@0 193 if (state != State.INITIALIZED) {
michael@0 194 return;
michael@0 195 }
michael@0 196 this.dispatcher.unregisterEventListener(EVENT_SNAPSHOT, this);
michael@0 197 this.dispatcher.unregisterEventListener(EVENT_ADDONS_CHANGE, this);
michael@0 198 this.dispatcher.unregisterEventListener(EVENT_ADDONS_UNINSTALLING, this);
michael@0 199 this.dispatcher.unregisterEventListener(EVENT_PREF_CHANGE, this);
michael@0 200 this.dispatcher.unregisterEventListener(EVENT_KEYWORD_SEARCH, this);
michael@0 201 this.dispatcher.unregisterEventListener(EVENT_SEARCH, this);
michael@0 202 }
michael@0 203
michael@0 204 public void onAppLocaleChanged(String to) {
michael@0 205 Log.d(LOG_TAG, "Setting health recorder app locale to " + to);
michael@0 206 this.profileCache.beginInitialization();
michael@0 207 this.profileCache.setAppLocale(to);
michael@0 208 }
michael@0 209
michael@0 210 public void onAddonChanged(String id, JSONObject json) {
michael@0 211 this.profileCache.beginInitialization();
michael@0 212 try {
michael@0 213 this.profileCache.updateJSONForAddon(id, json);
michael@0 214 } catch (IllegalStateException e) {
michael@0 215 Log.w(LOG_TAG, "Attempted to update add-on cache prior to full init.", e);
michael@0 216 }
michael@0 217 }
michael@0 218
michael@0 219 public void onAddonUninstalling(String id) {
michael@0 220 this.profileCache.beginInitialization();
michael@0 221 try {
michael@0 222 this.profileCache.removeAddon(id);
michael@0 223 } catch (IllegalStateException e) {
michael@0 224 Log.w(LOG_TAG, "Attempted to update add-on cache prior to full init.", e);
michael@0 225 }
michael@0 226 }
michael@0 227
michael@0 228 /**
michael@0 229 * Call this when a material change might have occurred in the running
michael@0 230 * environment, such that a new environment should be computed and prepared
michael@0 231 * for use in future events.
michael@0 232 *
michael@0 233 * Invoke this method after calls that mutate the environment.
michael@0 234 *
michael@0 235 * If this change resulted in a transition between two environments, {@link
michael@0 236 * #onEnvironmentTransition(int, int, boolean, String)} will be invoked on the background
michael@0 237 * thread.
michael@0 238 */
michael@0 239 public synchronized void onEnvironmentChanged() {
michael@0 240 onEnvironmentChanged(true, "E");
michael@0 241 }
michael@0 242
michael@0 243 /**
michael@0 244 * If `startNewSession` is false, it means no new session should begin
michael@0 245 * (e.g., because we're about to restart, and we don't want to create
michael@0 246 * an orphan).
michael@0 247 */
michael@0 248 public synchronized void onEnvironmentChanged(final boolean startNewSession, final String sessionEndReason) {
michael@0 249 final int previousEnv = this.env;
michael@0 250 this.env = -1;
michael@0 251 try {
michael@0 252 profileCache.completeInitialization();
michael@0 253 } catch (java.io.IOException e) {
michael@0 254 Log.e(LOG_TAG, "Error completing profile cache initialization.", e);
michael@0 255 this.state = State.INITIALIZATION_FAILED;
michael@0 256 return;
michael@0 257 }
michael@0 258
michael@0 259 final int updatedEnv = ensureEnvironment();
michael@0 260
michael@0 261 if (updatedEnv == -1 ||
michael@0 262 updatedEnv == previousEnv) {
michael@0 263 Log.v(LOG_TAG, "Environment didn't change.");
michael@0 264 return;
michael@0 265 }
michael@0 266 ThreadUtils.postToBackgroundThread(new Runnable() {
michael@0 267 @Override
michael@0 268 public void run() {
michael@0 269 try {
michael@0 270 onEnvironmentTransition(previousEnv, updatedEnv, startNewSession, sessionEndReason);
michael@0 271 } catch (Exception e) {
michael@0 272 Log.w(LOG_TAG, "Could not record environment transition.", e);
michael@0 273 }
michael@0 274 }
michael@0 275 });
michael@0 276 }
michael@0 277
michael@0 278 protected synchronized int ensureEnvironment() {
michael@0 279 if (!(state == State.INITIALIZING ||
michael@0 280 state == State.INITIALIZED)) {
michael@0 281 throw new IllegalStateException("Not initialized.");
michael@0 282 }
michael@0 283
michael@0 284 if (this.env != -1) {
michael@0 285 return this.env;
michael@0 286 }
michael@0 287 if (this.storage == null) {
michael@0 288 // Oh well.
michael@0 289 return -1;
michael@0 290 }
michael@0 291 return this.env = EnvironmentBuilder.registerCurrentEnvironment(this.storage,
michael@0 292 this.profileCache);
michael@0 293 }
michael@0 294
michael@0 295 private static final String getTimesPath(final String profilePath) {
michael@0 296 return profilePath + File.separator + "times.json";
michael@0 297 }
michael@0 298
michael@0 299 /**
michael@0 300 * Retrieve the stored profile creation time from the profile directory.
michael@0 301 *
michael@0 302 * @return the <code>created</code> value from the times.json file, or -1 on failure.
michael@0 303 */
michael@0 304 protected static long getProfileInitTimeFromFile(final String profilePath) {
michael@0 305 final File times = new File(getTimesPath(profilePath));
michael@0 306 Log.d(LOG_TAG, "Looking for " + times.getAbsolutePath());
michael@0 307 if (!times.exists()) {
michael@0 308 return -1;
michael@0 309 }
michael@0 310
michael@0 311 Log.d(LOG_TAG, "Using times.json for profile creation time.");
michael@0 312 Scanner scanner = null;
michael@0 313 try {
michael@0 314 scanner = new Scanner(times, "UTF-8");
michael@0 315 final String contents = scanner.useDelimiter("\\A").next();
michael@0 316 return new JSONObject(contents).getLong("created");
michael@0 317 } catch (Exception e) {
michael@0 318 // There are assorted reasons why this might occur, but we
michael@0 319 // don't care. Move on.
michael@0 320 Log.w(LOG_TAG, "Failed to read times.json.", e);
michael@0 321 } finally {
michael@0 322 if (scanner != null) {
michael@0 323 scanner.close();
michael@0 324 }
michael@0 325 }
michael@0 326 return -1;
michael@0 327 }
michael@0 328
michael@0 329 /**
michael@0 330 * Only works on API 9 and up.
michael@0 331 *
michael@0 332 * @return the package install time, or -1 if an error occurred.
michael@0 333 */
michael@0 334 protected static long getPackageInstallTime(final Context context) {
michael@0 335 if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.GINGERBREAD) {
michael@0 336 return -1;
michael@0 337 }
michael@0 338
michael@0 339 try {
michael@0 340 return context.getPackageManager().getPackageInfo(AppConstants.ANDROID_PACKAGE_NAME, 0).firstInstallTime;
michael@0 341 } catch (android.content.pm.PackageManager.NameNotFoundException e) {
michael@0 342 Log.e(LOG_TAG, "Unable to fetch our own package info. This should never occur.", e);
michael@0 343 }
michael@0 344 return -1;
michael@0 345 }
michael@0 346
michael@0 347 private static long getProfileInitTimeHeuristic(final Context context, final String profilePath) {
michael@0 348 // As a pretty good shortcut, settle for installation time.
michael@0 349 // In all recent Firefox profiles, times.json should exist.
michael@0 350 final long time = getPackageInstallTime(context);
michael@0 351 if (time != -1) {
michael@0 352 return time;
michael@0 353 }
michael@0 354
michael@0 355 // Otherwise, fall back to the filesystem.
michael@0 356 // We'll settle for the modification time of the profile directory.
michael@0 357 Log.d(LOG_TAG, "Using profile directory modified time as proxy for profile creation time.");
michael@0 358 return new File(profilePath).lastModified();
michael@0 359 }
michael@0 360
michael@0 361 private static long getAndPersistProfileInitTime(final Context context, final String profilePath) {
michael@0 362 // Let's look in the profile.
michael@0 363 long time = getProfileInitTimeFromFile(profilePath);
michael@0 364 if (time > 0) {
michael@0 365 Log.d(LOG_TAG, "Incorporating environment: times.json profile creation = " + time);
michael@0 366 return time;
michael@0 367 }
michael@0 368
michael@0 369 // Otherwise, we need to compute a valid creation time and write it out.
michael@0 370 time = getProfileInitTimeHeuristic(context, profilePath);
michael@0 371
michael@0 372 if (time > 0) {
michael@0 373 // Write out a stub times.json.
michael@0 374 try {
michael@0 375 FileOutputStream stream = new FileOutputStream(getTimesPath(profilePath));
michael@0 376 OutputStreamWriter writer = new OutputStreamWriter(stream, Charset.forName("UTF-8"));
michael@0 377 try {
michael@0 378 writer.append("{\"created\": " + time + "}\n");
michael@0 379 } finally {
michael@0 380 writer.close();
michael@0 381 }
michael@0 382 } catch (Exception e) {
michael@0 383 // Best-effort.
michael@0 384 Log.w(LOG_TAG, "Couldn't write times.json.", e);
michael@0 385 }
michael@0 386 }
michael@0 387
michael@0 388 Log.d(LOG_TAG, "Incorporating environment: profile creation = " + time);
michael@0 389 return time;
michael@0 390 }
michael@0 391
michael@0 392 private void onPrefMessage(final String pref, final JSONObject message) {
michael@0 393 Log.d(LOG_TAG, "Incorporating environment: " + pref);
michael@0 394 if (PREF_ACCEPT_LANG.equals(pref)) {
michael@0 395 // We only record whether this is user-set.
michael@0 396 try {
michael@0 397 this.profileCache.beginInitialization();
michael@0 398 this.profileCache.setAcceptLangUserSet(message.getBoolean("isUserSet"));
michael@0 399 } catch (JSONException ex) {
michael@0 400 Log.w(LOG_TAG, "Unexpected JSONException fetching isUserSet for " + pref);
michael@0 401 }
michael@0 402 return;
michael@0 403 }
michael@0 404
michael@0 405 // (We only handle boolean prefs right now.)
michael@0 406 try {
michael@0 407 boolean value = message.getBoolean("value");
michael@0 408
michael@0 409 if (AppConstants.TELEMETRY_PREF_NAME.equals(pref)) {
michael@0 410 this.profileCache.beginInitialization();
michael@0 411 this.profileCache.setTelemetryEnabled(value);
michael@0 412 return;
michael@0 413 }
michael@0 414
michael@0 415 if (PREF_BLOCKLIST_ENABLED.equals(pref)) {
michael@0 416 this.profileCache.beginInitialization();
michael@0 417 this.profileCache.setBlocklistEnabled(value);
michael@0 418 return;
michael@0 419 }
michael@0 420 } catch (JSONException ex) {
michael@0 421 Log.w(LOG_TAG, "Unexpected JSONException fetching boolean value for " + pref);
michael@0 422 return;
michael@0 423 }
michael@0 424 Log.w(LOG_TAG, "Unexpected pref: " + pref);
michael@0 425 }
michael@0 426
michael@0 427 /**
michael@0 428 * Background init helper.
michael@0 429 */
michael@0 430 private void initializeStorage() {
michael@0 431 Log.d(LOG_TAG, "Done initializing profile cache. Beginning storage init.");
michael@0 432
michael@0 433 final BrowserHealthRecorder self = this;
michael@0 434 ThreadUtils.postToBackgroundThread(new Runnable() {
michael@0 435 @Override
michael@0 436 public void run() {
michael@0 437 synchronized (self) {
michael@0 438 if (state != State.INITIALIZING) {
michael@0 439 Log.w(LOG_TAG, "Unexpected state during init: " + state);
michael@0 440 return;
michael@0 441 }
michael@0 442
michael@0 443 // Belt and braces.
michael@0 444 if (storage == null) {
michael@0 445 Log.w(LOG_TAG, "Storage is null during init; shutting down?");
michael@0 446 if (state == State.INITIALIZING) {
michael@0 447 state = State.INITIALIZATION_FAILED;
michael@0 448 }
michael@0 449 return;
michael@0 450 }
michael@0 451
michael@0 452 try {
michael@0 453 storage.beginInitialization();
michael@0 454 } catch (Exception e) {
michael@0 455 Log.e(LOG_TAG, "Failed to init storage.", e);
michael@0 456 state = State.INITIALIZATION_FAILED;
michael@0 457 return;
michael@0 458 }
michael@0 459
michael@0 460 try {
michael@0 461 // Listen for add-ons and prefs changes.
michael@0 462 dispatcher.registerEventListener(EVENT_ADDONS_UNINSTALLING, self);
michael@0 463 dispatcher.registerEventListener(EVENT_ADDONS_CHANGE, self);
michael@0 464 dispatcher.registerEventListener(EVENT_PREF_CHANGE, self);
michael@0 465
michael@0 466 // Initialize each provider here.
michael@0 467 initializeSessionsProvider();
michael@0 468 initializeSearchProvider();
michael@0 469
michael@0 470 Log.d(LOG_TAG, "Ensuring environment.");
michael@0 471 ensureEnvironment();
michael@0 472
michael@0 473 Log.d(LOG_TAG, "Finishing init.");
michael@0 474 storage.finishInitialization();
michael@0 475 state = State.INITIALIZED;
michael@0 476 } catch (Exception e) {
michael@0 477 state = State.INITIALIZATION_FAILED;
michael@0 478 storage.abortInitialization();
michael@0 479 Log.e(LOG_TAG, "Initialization failed.", e);
michael@0 480 return;
michael@0 481 }
michael@0 482
michael@0 483 // Now do whatever we do after we start up.
michael@0 484 checkForOrphanSessions();
michael@0 485 }
michael@0 486 }
michael@0 487 });
michael@0 488 }
michael@0 489
michael@0 490 /**
michael@0 491 * Add provider-specific initialization in this method.
michael@0 492 */
michael@0 493 private synchronized void initialize(final Context context,
michael@0 494 final String profilePath,
michael@0 495 final String osLocale,
michael@0 496 final String appLocale)
michael@0 497 throws java.io.IOException {
michael@0 498
michael@0 499 Log.d(LOG_TAG, "Initializing profile cache.");
michael@0 500 this.state = State.INITIALIZING;
michael@0 501
michael@0 502 // If we can restore state from last time, great.
michael@0 503 if (this.profileCache.restoreUnlessInitialized()) {
michael@0 504 this.profileCache.updateLocales(osLocale, appLocale);
michael@0 505 this.profileCache.completeInitialization();
michael@0 506
michael@0 507 Log.d(LOG_TAG, "Successfully restored state. Initializing storage.");
michael@0 508 initializeStorage();
michael@0 509 return;
michael@0 510 }
michael@0 511
michael@0 512 // Otherwise, let's initialize it from scratch.
michael@0 513 this.profileCache.beginInitialization();
michael@0 514 this.profileCache.setProfileCreationTime(getAndPersistProfileInitTime(context, profilePath));
michael@0 515 this.profileCache.setOSLocale(osLocale);
michael@0 516 this.profileCache.setAppLocale(appLocale);
michael@0 517
michael@0 518 // Because the distribution lookup can take some time, do it at the end of
michael@0 519 // our background startup work, along with the Gecko snapshot fetch.
michael@0 520 final GeckoEventListener self = this;
michael@0 521 ThreadUtils.postToBackgroundThread(new Runnable() {
michael@0 522 @Override
michael@0 523 public void run() {
michael@0 524 final DistributionDescriptor desc = new Distribution(context).getDescriptor();
michael@0 525 if (desc != null && desc.valid) {
michael@0 526 profileCache.setDistributionString(desc.id, desc.version);
michael@0 527 }
michael@0 528 Log.d(LOG_TAG, "Requesting all add-ons and FHR prefs from Gecko.");
michael@0 529 dispatcher.registerEventListener(EVENT_SNAPSHOT, self);
michael@0 530 GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("HealthReport:RequestSnapshot", null));
michael@0 531 }
michael@0 532 });
michael@0 533 }
michael@0 534
michael@0 535 /**
michael@0 536 * Invoked in the background whenever the environment transitions between
michael@0 537 * two valid values.
michael@0 538 */
michael@0 539 protected void onEnvironmentTransition(int prev, int env, boolean startNewSession, String sessionEndReason) {
michael@0 540 if (this.state != State.INITIALIZED) {
michael@0 541 Log.d(LOG_TAG, "Not initialized: not recording env transition (" + prev + " => " + env + ").");
michael@0 542 return;
michael@0 543 }
michael@0 544
michael@0 545 final SharedPreferences.Editor editor = this.prefs.edit();
michael@0 546
michael@0 547 recordSessionEnd(sessionEndReason, editor, prev);
michael@0 548
michael@0 549 if (!startNewSession) {
michael@0 550 editor.commit();
michael@0 551 return;
michael@0 552 }
michael@0 553
michael@0 554 final SessionInformation newSession = SessionInformation.forRuntimeTransition();
michael@0 555 setCurrentSession(newSession);
michael@0 556 newSession.recordBegin(editor);
michael@0 557 editor.commit();
michael@0 558 }
michael@0 559
michael@0 560 @Override
michael@0 561 public void handleMessage(String event, JSONObject message) {
michael@0 562 try {
michael@0 563 if (EVENT_SNAPSHOT.equals(event)) {
michael@0 564 Log.d(LOG_TAG, "Got all add-ons and prefs.");
michael@0 565 try {
michael@0 566 JSONObject json = message.getJSONObject("json");
michael@0 567 JSONObject addons = json.getJSONObject("addons");
michael@0 568 Log.i(LOG_TAG, "Persisting " + addons.length() + " add-ons.");
michael@0 569 profileCache.setJSONForAddons(addons);
michael@0 570
michael@0 571 JSONObject prefs = json.getJSONObject("prefs");
michael@0 572 Log.i(LOG_TAG, "Persisting prefs.");
michael@0 573 Iterator<?> keys = prefs.keys();
michael@0 574 while (keys.hasNext()) {
michael@0 575 String pref = (String) keys.next();
michael@0 576 this.onPrefMessage(pref, prefs.getJSONObject(pref));
michael@0 577 }
michael@0 578
michael@0 579 profileCache.completeInitialization();
michael@0 580 } catch (java.io.IOException e) {
michael@0 581 Log.e(LOG_TAG, "Error completing profile cache initialization.", e);
michael@0 582 state = State.INITIALIZATION_FAILED;
michael@0 583 return;
michael@0 584 }
michael@0 585
michael@0 586 if (state == State.INITIALIZING) {
michael@0 587 initializeStorage();
michael@0 588 } else {
michael@0 589 this.onEnvironmentChanged();
michael@0 590 }
michael@0 591
michael@0 592 return;
michael@0 593 }
michael@0 594
michael@0 595 if (EVENT_ADDONS_UNINSTALLING.equals(event)) {
michael@0 596 this.onAddonUninstalling(message.getString("id"));
michael@0 597 this.onEnvironmentChanged();
michael@0 598 return;
michael@0 599 }
michael@0 600
michael@0 601 if (EVENT_ADDONS_CHANGE.equals(event)) {
michael@0 602 this.onAddonChanged(message.getString("id"), message.getJSONObject("json"));
michael@0 603 this.onEnvironmentChanged();
michael@0 604 return;
michael@0 605 }
michael@0 606
michael@0 607 if (EVENT_PREF_CHANGE.equals(event)) {
michael@0 608 final String pref = message.getString("pref");
michael@0 609 Log.d(LOG_TAG, "Pref changed: " + pref);
michael@0 610 this.onPrefMessage(pref, message);
michael@0 611 this.onEnvironmentChanged();
michael@0 612 return;
michael@0 613 }
michael@0 614
michael@0 615 // Searches.
michael@0 616 if (EVENT_KEYWORD_SEARCH.equals(event)) {
michael@0 617 // A search via the URL bar. Since we eliminate all other search possibilities
michael@0 618 // (e.g. bookmarks keyword, search suggestion) when we initially process the
michael@0 619 // search URL, this is considered a default search.
michael@0 620 recordSearch(message.getString("identifier"), "bartext");
michael@0 621 return;
michael@0 622 }
michael@0 623 if (EVENT_SEARCH.equals(event)) {
michael@0 624 if (!message.has("location")) {
michael@0 625 Log.d(LOG_TAG, "Ignoring search without location.");
michael@0 626 return;
michael@0 627 }
michael@0 628 recordSearch(message.optString("identifier", null), message.getString("location"));
michael@0 629 return;
michael@0 630 }
michael@0 631 } catch (Exception e) {
michael@0 632 Log.e(LOG_TAG, "Exception handling message \"" + event + "\":", e);
michael@0 633 }
michael@0 634 }
michael@0 635
michael@0 636 /*
michael@0 637 * Searches.
michael@0 638 */
michael@0 639
michael@0 640 public static final String MEASUREMENT_NAME_SEARCH_COUNTS = "org.mozilla.searches.counts";
michael@0 641 public static final int MEASUREMENT_VERSION_SEARCH_COUNTS = 5;
michael@0 642
michael@0 643 public static final Set<String> SEARCH_LOCATIONS = Collections.unmodifiableSet(new HashSet<String>(Arrays.asList(new String[] {
michael@0 644 "barkeyword",
michael@0 645 "barsuggest",
michael@0 646 "bartext",
michael@0 647 })));
michael@0 648
michael@0 649 private void initializeSearchProvider() {
michael@0 650 this.storage.ensureMeasurementInitialized(
michael@0 651 MEASUREMENT_NAME_SEARCH_COUNTS,
michael@0 652 MEASUREMENT_VERSION_SEARCH_COUNTS,
michael@0 653 new MeasurementFields() {
michael@0 654 @Override
michael@0 655 public Iterable<FieldSpec> getFields() {
michael@0 656 ArrayList<FieldSpec> out = new ArrayList<FieldSpec>(SEARCH_LOCATIONS.size());
michael@0 657 for (String location : SEARCH_LOCATIONS) {
michael@0 658 // We're not using a counter, because the set of engine
michael@0 659 // identifiers is potentially unbounded, and thus our
michael@0 660 // measurement version would have to keep growing as
michael@0 661 // fields changed. Instead we store discrete values, and
michael@0 662 // accumulate them into a counting map during processing.
michael@0 663 out.add(new FieldSpec(location, Field.TYPE_COUNTED_STRING_DISCRETE));
michael@0 664 }
michael@0 665 return out;
michael@0 666 }
michael@0 667 });
michael@0 668
michael@0 669 // Do this here, rather than in a centralized registration spot, in
michael@0 670 // case the above throws and we wind up handling events that we can't
michael@0 671 // store.
michael@0 672 this.dispatcher.registerEventListener(EVENT_KEYWORD_SEARCH, this);
michael@0 673 this.dispatcher.registerEventListener(EVENT_SEARCH, this);
michael@0 674 }
michael@0 675
michael@0 676 /**
michael@0 677 * Record a search.
michael@0 678 *
michael@0 679 * @param engineID the string identifier for the engine. Can be <code>null</code>.
michael@0 680 * @param location one of a fixed set of locations: see {@link #SEARCH_LOCATIONS}.
michael@0 681 */
michael@0 682 public void recordSearch(final String engineID, final String location) {
michael@0 683 if (this.state != State.INITIALIZED) {
michael@0 684 Log.d(LOG_TAG, "Not initialized: not recording search. (" + this.state + ")");
michael@0 685 return;
michael@0 686 }
michael@0 687
michael@0 688 final int env = this.env;
michael@0 689
michael@0 690 if (env == -1) {
michael@0 691 Log.d(LOG_TAG, "No environment: not recording search.");
michael@0 692 return;
michael@0 693 }
michael@0 694
michael@0 695 if (location == null) {
michael@0 696 throw new IllegalArgumentException("location must be provided for search.");
michael@0 697 }
michael@0 698
michael@0 699 // Ensure that we don't throw when trying to look up the field for an
michael@0 700 // unknown location. If you add a search location, you must extend the
michael@0 701 // list of search locations *and update the measurement version*.
michael@0 702 if (!SEARCH_LOCATIONS.contains(location)) {
michael@0 703 throw new IllegalArgumentException("Unexpected location: " + location);
michael@0 704 }
michael@0 705
michael@0 706 final int day = storage.getDay();
michael@0 707 final String key = (engineID == null) ? "other" : engineID;
michael@0 708
michael@0 709 ThreadUtils.postToBackgroundThread(new Runnable() {
michael@0 710 @Override
michael@0 711 public void run() {
michael@0 712 final HealthReportDatabaseStorage storage = BrowserHealthRecorder.this.storage;
michael@0 713 if (storage == null) {
michael@0 714 Log.d(LOG_TAG, "No storage: not recording search. Shutting down?");
michael@0 715 return;
michael@0 716 }
michael@0 717
michael@0 718 Log.d(LOG_TAG, "Recording search: " + key + ", " + location +
michael@0 719 " (" + day + ", " + env + ").");
michael@0 720 final int searchField = storage.getField(MEASUREMENT_NAME_SEARCH_COUNTS,
michael@0 721 MEASUREMENT_VERSION_SEARCH_COUNTS,
michael@0 722 location)
michael@0 723 .getID();
michael@0 724 storage.recordDailyDiscrete(env, day, searchField, key);
michael@0 725 }
michael@0 726 });
michael@0 727 }
michael@0 728
michael@0 729 /*
michael@0 730 * Sessions.
michael@0 731 *
michael@0 732 * We record session beginnings in SharedPreferences, because it's cheaper
michael@0 733 * to do that than to either write to then update the DB (which requires
michael@0 734 * keeping a row identifier to update, as well as two writes) or to record
michael@0 735 * two events (which doubles storage space and requires rollup logic).
michael@0 736 *
michael@0 737 * The pattern is:
michael@0 738 *
michael@0 739 * 1. On startup, determine whether an orphan session exists by looking for
michael@0 740 * a saved timestamp in prefs. If it does, then record the orphan in FHR
michael@0 741 * storage.
michael@0 742 * 2. Record in prefs that a new session has begun. Track the timestamp (so
michael@0 743 * we know to which day the session belongs).
michael@0 744 * 3. As startup timings become available, accumulate them in memory.
michael@0 745 * 4. On clean shutdown, read the values from here, write them to the DB, and
michael@0 746 * delete the sentinel time from SharedPreferences.
michael@0 747 * 5. On a dirty shutdown, the in-memory session will not be written to the
michael@0 748 * DB, and the current session will be orphaned.
michael@0 749 *
michael@0 750 * Sessions are begun in onResume (and thus implicitly onStart) and ended
michael@0 751 * in onPause.
michael@0 752 *
michael@0 753 * Session objects are stored as discrete JSON.
michael@0 754 *
michael@0 755 * "org.mozilla.appSessions": {
michael@0 756 * _v: 4,
michael@0 757 * "normal": [
michael@0 758 * {"r":"P", "d": 123},
michael@0 759 * ],
michael@0 760 * "abnormal": [
michael@0 761 * {"r":"A", "oom": true, "stopped": false}
michael@0 762 * ]
michael@0 763 * }
michael@0 764 *
michael@0 765 * "r": reason. Values are "P" (activity paused), "A" (abnormal termination)
michael@0 766 * "d": duration. Value in seconds.
michael@0 767 * "sg": Gecko startup time. Present if this is a clean launch. This
michael@0 768 * corresponds to the telemetry timer FENNEC_STARTUP_TIME_GECKOREADY.
michael@0 769 * "sj": Java activity init time. Present if this is a clean launch. This
michael@0 770 * corresponds to the telemetry timer FENNEC_STARTUP_TIME_JAVAUI,
michael@0 771 * and includes initialization tasks beyond initial
michael@0 772 * onWindowFocusChanged.
michael@0 773 *
michael@0 774 * Abnormal terminations will be missing a duration and will feature these keys:
michael@0 775 *
michael@0 776 * "oom": was the session killed by an OOM exception?
michael@0 777 * "stopped": was the session stopped gently?
michael@0 778 */
michael@0 779
michael@0 780 public static final String MEASUREMENT_NAME_SESSIONS = "org.mozilla.appSessions";
michael@0 781 public static final int MEASUREMENT_VERSION_SESSIONS = 4;
michael@0 782
michael@0 783 private void initializeSessionsProvider() {
michael@0 784 this.storage.ensureMeasurementInitialized(
michael@0 785 MEASUREMENT_NAME_SESSIONS,
michael@0 786 MEASUREMENT_VERSION_SESSIONS,
michael@0 787 new MeasurementFields() {
michael@0 788 @Override
michael@0 789 public Iterable<FieldSpec> getFields() {
michael@0 790 List<FieldSpec> out = Arrays.asList(
michael@0 791 new FieldSpec("normal", Field.TYPE_JSON_DISCRETE),
michael@0 792 new FieldSpec("abnormal", Field.TYPE_JSON_DISCRETE));
michael@0 793 return out;
michael@0 794 }
michael@0 795 });
michael@0 796 }
michael@0 797
michael@0 798 /**
michael@0 799 * Logic shared between crashed and normal sessions.
michael@0 800 */
michael@0 801 private void recordSessionEntry(String field, SessionInformation session, final int environment, JSONObject value) {
michael@0 802 final HealthReportDatabaseStorage storage = this.storage;
michael@0 803 if (storage == null) {
michael@0 804 Log.d(LOG_TAG, "No storage: not recording session entry. Shutting down?");
michael@0 805 return;
michael@0 806 }
michael@0 807
michael@0 808 try {
michael@0 809 final int sessionField = storage.getField(MEASUREMENT_NAME_SESSIONS,
michael@0 810 MEASUREMENT_VERSION_SESSIONS,
michael@0 811 field)
michael@0 812 .getID();
michael@0 813 final int day = storage.getDay(session.wallStartTime);
michael@0 814 storage.recordDailyDiscrete(environment, day, sessionField, value);
michael@0 815 Log.v(LOG_TAG, "Recorded session entry for env " + environment + ", current is " + env);
michael@0 816 } catch (Exception e) {
michael@0 817 Log.w(LOG_TAG, "Unable to record session completion.", e);
michael@0 818 }
michael@0 819 }
michael@0 820
michael@0 821 public void checkForOrphanSessions() {
michael@0 822 if (!this.orphanChecked.compareAndSet(false, true)) {
michael@0 823 Log.w(LOG_TAG, "Attempting to check for orphan sessions more than once.");
michael@0 824 return;
michael@0 825 }
michael@0 826
michael@0 827 Log.d(LOG_TAG, "Checking for orphan session.");
michael@0 828 if (this.previousSession == null) {
michael@0 829 return;
michael@0 830 }
michael@0 831 if (this.previousSession.wallStartTime == 0) {
michael@0 832 return;
michael@0 833 }
michael@0 834
michael@0 835 if (state != State.INITIALIZED) {
michael@0 836 // Something has gone awry.
michael@0 837 Log.e(LOG_TAG, "Attempted to record bad session end without initialized recorder.");
michael@0 838 return;
michael@0 839 }
michael@0 840
michael@0 841 try {
michael@0 842 recordSessionEntry("abnormal", this.previousSession, this.env,
michael@0 843 this.previousSession.getCrashedJSON());
michael@0 844 } catch (Exception e) {
michael@0 845 Log.w(LOG_TAG, "Unable to generate session JSON.", e);
michael@0 846
michael@0 847 // Future: record this exception in FHR's own error submitter.
michael@0 848 }
michael@0 849 }
michael@0 850
michael@0 851 public void recordSessionEnd(String reason, SharedPreferences.Editor editor) {
michael@0 852 recordSessionEnd(reason, editor, env);
michael@0 853 }
michael@0 854
michael@0 855 /**
michael@0 856 * Record that the current session ended. Does not commit the provided editor.
michael@0 857 *
michael@0 858 * @param environment An environment ID. This allows callers to record the
michael@0 859 * end of a session due to an observed environment change.
michael@0 860 */
michael@0 861 public void recordSessionEnd(String reason, SharedPreferences.Editor editor, final int environment) {
michael@0 862 Log.d(LOG_TAG, "Recording session end: " + reason);
michael@0 863 if (state != State.INITIALIZED) {
michael@0 864 // Something has gone awry.
michael@0 865 Log.e(LOG_TAG, "Attempted to record session end without initialized recorder.");
michael@0 866 return;
michael@0 867 }
michael@0 868
michael@0 869 final SessionInformation session = this.session;
michael@0 870 this.session = null; // So it can't be double-recorded.
michael@0 871
michael@0 872 if (session == null) {
michael@0 873 Log.w(LOG_TAG, "Unable to record session end: no session. Already ended?");
michael@0 874 return;
michael@0 875 }
michael@0 876
michael@0 877 if (session.wallStartTime <= 0) {
michael@0 878 Log.e(LOG_TAG, "Session start " + session.wallStartTime + " isn't valid! Can't record end.");
michael@0 879 return;
michael@0 880 }
michael@0 881
michael@0 882 long realEndTime = android.os.SystemClock.elapsedRealtime();
michael@0 883 try {
michael@0 884 JSONObject json = session.getCompletionJSON(reason, realEndTime);
michael@0 885 recordSessionEntry("normal", session, environment, json);
michael@0 886 } catch (JSONException e) {
michael@0 887 Log.w(LOG_TAG, "Unable to generate session JSON.", e);
michael@0 888
michael@0 889 // Continue so we don't hit it next time.
michael@0 890 // Future: record this exception in FHR's own error submitter.
michael@0 891 }
michael@0 892
michael@0 893 // Track the end of this session in shared prefs, so it doesn't get
michael@0 894 // double-counted on next run.
michael@0 895 session.recordCompletion(editor);
michael@0 896 }
michael@0 897 }

mercurial