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