Bug 1243595 - Add SessionMeasurements and tests. r=ahunt
MozReview-Commit-ID: NEKDPblKEE
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/measurements/SessionMeasurements.java
@@ -0,0 +1,99 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+package org.mozilla.gecko.telemetry.measurements;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.support.annotation.UiThread;
+import android.support.annotation.VisibleForTesting;
+import org.mozilla.gecko.GeckoSharedPrefs;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * A class to measure the number of user sessions & their durations. It was created for use with the
+ * telemetry core ping. A session is the time between {@link #recordSessionStart()} and
+ * {@link #recordSessionEnd(Context)}.
+ *
+ * This class is thread-safe, provided the thread annotations are followed. Under the hood, this class uses
+ * SharedPreferences & because there is no atomic getAndSet operation, we synchronize access to it.
+ */
+public class SessionMeasurements {
+ @VisibleForTesting static final String PREF_SESSION_COUNT = "measurements-session-count";
+ @VisibleForTesting static final String PREF_SESSION_DURATION = "measurements-session-duration";
+
+ private boolean sessionStarted = false;
+ private long timeAtSessionStartNano = -1;
+
+ @UiThread // we assume this will be called on the same thread as session end so we don't have to synchronize sessionStarted.
+ public void recordSessionStart() {
+ if (sessionStarted) {
+ throw new IllegalStateException("Trying to start session but it is already started");
+ }
+ sessionStarted = true;
+ timeAtSessionStartNano = getSystemTimeNano();
+ }
+
+ @UiThread // we assume this will be called on the same thread as session start so we don't have to synchronize sessionStarted.
+ public void recordSessionEnd(final Context context) {
+ if (!sessionStarted) {
+ throw new IllegalStateException("Expected session to be started before session end is called");
+ }
+ sessionStarted = false;
+
+ final long sessionElapsedSeconds = TimeUnit.NANOSECONDS.toSeconds(getSystemTimeNano() - timeAtSessionStartNano);
+ final SharedPreferences sharedPrefs = getSharedPreferences(context);
+ synchronized (this) {
+ final int sessionCount = sharedPrefs.getInt(PREF_SESSION_COUNT, 0);
+ final long totalElapsedSeconds = sharedPrefs.getLong(PREF_SESSION_DURATION, 0);
+ sharedPrefs.edit()
+ .putInt(PREF_SESSION_COUNT, sessionCount + 1)
+ .putLong(PREF_SESSION_DURATION, totalElapsedSeconds + sessionElapsedSeconds)
+ .apply();
+ }
+ }
+
+ /**
+ * Gets the session measurements since the last time the measurements were last retrieved.
+ */
+ public synchronized SessionMeasurementsContainer getAndResetSessionMeasurements(final Context context) {
+ final SharedPreferences sharedPrefs = getSharedPreferences(context);
+ final int sessionCount = sharedPrefs.getInt(PREF_SESSION_COUNT, 0);
+ final long totalElapsedSeconds = sharedPrefs.getLong(PREF_SESSION_DURATION, 0);
+ sharedPrefs.edit()
+ .putInt(PREF_SESSION_COUNT, 0)
+ .putLong(PREF_SESSION_DURATION, 0)
+ .apply();
+ return new SessionMeasurementsContainer(sessionCount, totalElapsedSeconds);
+ }
+
+ @VisibleForTesting SharedPreferences getSharedPreferences(final Context context) {
+ return GeckoSharedPrefs.forProfile(context);
+ }
+
+ /**
+ * Returns (roughly) the system uptime in nanoseconds. A less coupled implementation would
+ * take this value from the caller of recordSession*, however, we do this internally to ensure
+ * the caller uses both a time system consistent between the start & end calls and uses the
+ * appropriate time system (i.e. not wall time, which can change when the clock is changed).
+ */
+ @VisibleForTesting long getSystemTimeNano() { // TODO: necessary?
+ return System.nanoTime();
+ }
+
+ public static final class SessionMeasurementsContainer {
+ /** The number of sessions. */
+ public final int sessionCount;
+ /** The number of seconds elapsed in ALL sessions included in {@link #sessionCount}. */
+ public final long elapsedSeconds;
+
+ private SessionMeasurementsContainer(final int sessionCount, final long elapsedSeconds) {
+ this.sessionCount = sessionCount;
+ this.elapsedSeconds = elapsedSeconds;
+ }
+ }
+}
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -571,16 +571,17 @@ gbjar.sources += ['java/org/mozilla/geck
'tabs/TabsGridLayout.java',
'tabs/TabsLayoutAdapter.java',
'tabs/TabsLayoutItemView.java',
'tabs/TabsListLayout.java',
'tabs/TabsPanel.java',
'tabs/TabsPanelThumbnailView.java',
'Telemetry.java',
'telemetry/measurements/SearchCountMeasurements.java',
+ 'telemetry/measurements/SessionMeasurements.java',
'telemetry/pingbuilders/TelemetryCorePingBuilder.java',
'telemetry/pingbuilders/TelemetryPingBuilder.java',
'telemetry/schedulers/TelemetryUploadAllPingsImmediatelyScheduler.java',
'telemetry/schedulers/TelemetryUploadScheduler.java',
'telemetry/stores/TelemetryJSONFilePingStore.java',
'telemetry/stores/TelemetryPingStore.java',
'telemetry/TelemetryConstants.java',
'telemetry/TelemetryDispatcher.java',
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/measurements/TestSessionMeasurements.java
@@ -0,0 +1,124 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+package org.mozilla.gecko.telemetry.measurements;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.telemetry.measurements.SessionMeasurements.SessionMeasurementsContainer;
+import org.robolectric.RuntimeEnvironment;
+
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * Tests the session measurements class.
+ */
+@RunWith(TestRunner.class)
+public class TestSessionMeasurements {
+
+ private SessionMeasurements testMeasurements;
+ private SharedPreferences sharedPrefs;
+ private Context context;
+
+ @Before
+ public void setUp() throws Exception {
+ testMeasurements = spy(SessionMeasurements.class);
+ sharedPrefs = RuntimeEnvironment.application.getSharedPreferences(
+ TestSessionMeasurements.class.getSimpleName(), Context.MODE_PRIVATE);
+ doReturn(sharedPrefs).when(testMeasurements).getSharedPreferences(any(Context.class));
+
+ context = RuntimeEnvironment.application;
+ }
+
+ private void assertSessionCount(final String postfix, final int expectedSessionCount) {
+ final int actual = sharedPrefs.getInt(SessionMeasurements.PREF_SESSION_COUNT, -1);
+ assertEquals("Expected number of sessions occurred " + postfix, expectedSessionCount, actual);
+ }
+
+ private void assertSessionDuration(final String postfix, final long expectedSessionDuration) {
+ final long actual = sharedPrefs.getLong(SessionMeasurements.PREF_SESSION_DURATION, -1);
+ assertEquals("Expected session duration received " + postfix, expectedSessionDuration, actual);
+ }
+
+ private void mockGetSystemTimeNanosToReturn(final long value) {
+ doReturn(value).when(testMeasurements).getSystemTimeNano();
+ }
+
+ @Test
+ public void testRecordSessionStartAndEndCalledOnce() throws Exception {
+ final long expectedElapsedSeconds = 4;
+ mockGetSystemTimeNanosToReturn(0);
+ testMeasurements.recordSessionStart();
+ mockGetSystemTimeNanosToReturn(TimeUnit.SECONDS.toNanos(expectedElapsedSeconds));
+ testMeasurements.recordSessionEnd(context);
+
+ final String postfix = "after recordSessionStart/End called once";
+ assertSessionCount(postfix, 1);
+ assertSessionDuration(postfix, expectedElapsedSeconds);
+ }
+
+ @Test
+ public void testRecordSessionStartAndEndCalledTwice() throws Exception {
+ final long expectedElapsedSeconds = 100;
+ mockGetSystemTimeNanosToReturn(0L);
+ for (int i = 1; i <= 2; ++i) {
+ testMeasurements.recordSessionStart();
+ mockGetSystemTimeNanosToReturn(TimeUnit.SECONDS.toNanos((expectedElapsedSeconds / 2) * i));
+ testMeasurements.recordSessionEnd(context);
+ }
+
+ final String postfix = "after recordSessionStart/End called twice";
+ assertSessionCount(postfix, 2);
+ assertSessionDuration(postfix, expectedElapsedSeconds);
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testRecordSessionStartThrowsIfSessionAlreadyStarted() throws Exception {
+ // First call will start the session, next expected to throw.
+ for (int i = 0; i < 2; ++i) {
+ testMeasurements.recordSessionStart();
+ }
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testRecordSessionEndThrowsIfCalledBeforeSessionStarted() {
+ testMeasurements.recordSessionEnd(context);
+ }
+
+ @Test // assumes the underlying format in SessionMeasurements
+ public void testGetAndResetSessionMeasurementsReturnsSetData() throws Exception {
+ final int expectedSessionCount = 42;
+ final long expectedSessionDuration = 1234567890;
+ sharedPrefs.edit()
+ .putInt(SessionMeasurements.PREF_SESSION_COUNT, expectedSessionCount)
+ .putLong(SessionMeasurements.PREF_SESSION_DURATION, expectedSessionDuration)
+ .apply();
+
+ final SessionMeasurementsContainer actual = testMeasurements.getAndResetSessionMeasurements(context);
+ assertEquals("Returned session count matches expected", expectedSessionCount, actual.sessionCount);
+ assertEquals("Returned session duration matches expected", expectedSessionDuration, actual.elapsedSeconds);
+ }
+
+ @Test
+ public void testGetAndResetSessionMeasurementsResetsData() throws Exception {
+ sharedPrefs.edit()
+ .putInt(SessionMeasurements.PREF_SESSION_COUNT, 10)
+ .putLong(SessionMeasurements.PREF_SESSION_DURATION, 10)
+ .apply();
+
+ testMeasurements.getAndResetSessionMeasurements(context);
+ final String postfix = "is reset after retrieval";
+ assertSessionCount(postfix, 0);
+ assertSessionDuration(postfix, 0);
+ }
+}
\ No newline at end of file