Implementation of metrics for MMS.

Bug: 233259583
Test: atest MmsServiceTests
Change-Id: I5a6b94befa20d022283c8ce30198dbbb31aaeba8
diff --git a/tests/unittests/Android.bp b/tests/unittests/Android.bp
new file mode 100644
index 0000000..92371c6
--- /dev/null
+++ b/tests/unittests/Android.bp
@@ -0,0 +1,27 @@
+package {
+    // See: http://go/android-license-faq
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_test {
+    name: "MmsServiceTests",
+    static_libs: [
+        "mms-protos-lite",
+        "mms-statsd",
+        "androidx.annotation_annotation",
+        "mockito-target",
+        "compatibility-device-util-axt",
+        "androidx.test.rules",
+        "truth-prebuilt",
+    ],
+    libs: [
+        "android.test.runner",
+        "android.test.base",
+        "android.test.mock",
+    ],
+    srcs: ["src/**/*.java", ":mms-metrics-srcs"],
+    platform_apis: true,
+    test_suites: ["device-tests"],
+    certificate: "platform",
+    instrumentation_for: "MmsService",
+}
\ No newline at end of file
diff --git a/tests/unittests/AndroidManifest.xml b/tests/unittests/AndroidManifest.xml
new file mode 100644
index 0000000..3b4b582
--- /dev/null
+++ b/tests/unittests/AndroidManifest.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2022 The Android Open Source Project
+
+    Licensed under the Apache License, Version 2.0 (the "License");
+    you may not use this file except in compliance with the License.
+    You may obtain a copy of the License at
+
+         http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing, software
+    distributed under the License is distributed on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+    See the License for the specific language governing permissions and
+    limitations under the License.
+-->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+          package="com.android.mms.service.tests"
+          android:debuggable="true"
+          android:sharedUserId="android.uid.phone">
+
+    <application>
+        <uses-library android:name="android.test.runner"/>
+    </application>
+
+    <instrumentation android:name="androidx.test.runner.AndroidJUnitRunner"
+                     android:targetPackage="com.android.mms.service.tests"
+                     android:label="MmsServiceTests"
+                     android:debuggable="true">
+    </instrumentation>
+</manifest>
\ No newline at end of file
diff --git a/tests/unittests/AndroidTest.xml b/tests/unittests/AndroidTest.xml
new file mode 100644
index 0000000..61b3f5d
--- /dev/null
+++ b/tests/unittests/AndroidTest.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2022 The Android Open Source Project
+  ~
+  ~ Licensed under the Apache License, Version 2.0 (the "License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the License at
+  ~
+  ~      http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  -->
+<configuration description="Run MmsServiceTests.">
+    <target_preparer class="com.android.tradefed.targetprep.TestAppInstallSetup">
+        <option name="test-file-name" value="MmsServiceTests.apk"/>
+    </target_preparer>
+
+    <option name="test-suite-tag" value="apct"/>
+    <option name="test-tag" value="MmsServiceTests"/>
+    <test class="com.android.tradefed.testtype.AndroidJUnitTest">
+        <option name="package" value="com.android.mms.service.tests"/>
+        <option name="runner" value="androidx.test.runner.AndroidJUnitRunner"/>
+        <option name="hidden-api-checks" value="false"/>
+    </test>
+</configuration>
\ No newline at end of file
diff --git a/tests/unittests/src/com/android/mms/service/metrics/MmsMetricsCollectorTest.java b/tests/unittests/src/com/android/mms/service/metrics/MmsMetricsCollectorTest.java
new file mode 100644
index 0000000..8d93739
--- /dev/null
+++ b/tests/unittests/src/com/android/mms/service/metrics/MmsMetricsCollectorTest.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.mms.service.metrics;
+
+import static com.android.mms.MmsStatsLog.INCOMING_MMS;
+import static com.android.mms.MmsStatsLog.OUTGOING_MMS;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+import android.app.StatsManager;
+import android.content.Context;
+import android.util.StatsEvent;
+
+import com.android.mms.IncomingMms;
+import com.android.mms.OutgoingMms;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+public class MmsMetricsCollectorTest {
+    private static final long MIN_COOLDOWN_MILLIS = 23L * 3600L * 1000L;
+    Context mContext;
+    private PersistMmsAtomsStorage mPersistMmsAtomsStorage;
+    private MmsMetricsCollector mMmsMetricsCollector;
+
+    @Before
+    public void setUp() {
+        mContext = mock(Context.class);
+        mPersistMmsAtomsStorage = mock(PersistMmsAtomsStorage.class);
+        mMmsMetricsCollector = new MmsMetricsCollector(mContext, mPersistMmsAtomsStorage);
+    }
+
+    @After
+    public void tearDown() {
+        mContext = null;
+        mPersistMmsAtomsStorage = null;
+        mMmsMetricsCollector = null;
+    }
+
+    @Test
+    public void onPullAtom_incomingMms_empty() {
+        doReturn(new ArrayList<>()).when(mPersistMmsAtomsStorage).getIncomingMms(anyLong());
+        List<StatsEvent> actualAtoms = new ArrayList<>();
+
+        int result = mMmsMetricsCollector.onPullAtom(INCOMING_MMS, actualAtoms);
+
+        assertThat(actualAtoms).hasSize(0);
+        assertThat(result).isEqualTo(StatsManager.PULL_SUCCESS);
+    }
+
+    @Test
+    public void onPullAtom_incomingMms_tooFrequent() {
+        doReturn(null).when(mPersistMmsAtomsStorage).getIncomingMms(anyLong());
+        List<StatsEvent> actualAtoms = new ArrayList<>();
+
+        int result = mMmsMetricsCollector.onPullAtom(INCOMING_MMS, actualAtoms);
+
+        assertThat(actualAtoms).hasSize(0);
+        assertThat(result).isEqualTo(StatsManager.PULL_SKIP);
+        verify(mPersistMmsAtomsStorage, times(1))
+                .getIncomingMms(eq(MIN_COOLDOWN_MILLIS));
+        verifyNoMoreInteractions(mPersistMmsAtomsStorage);
+    }
+
+    @Test
+    public void onPullAtom_incomingMms_multipleMms() {
+        IncomingMms incomingMms = IncomingMms.newBuilder().build();
+        doReturn(Arrays.asList(incomingMms, incomingMms, incomingMms, incomingMms))
+                .when(mPersistMmsAtomsStorage).getIncomingMms(anyLong());
+        List<StatsEvent> actualAtoms = new ArrayList<>();
+
+        int result = mMmsMetricsCollector.onPullAtom(INCOMING_MMS, actualAtoms);
+
+        assertThat(actualAtoms).hasSize(4);
+        assertThat(result).isEqualTo(StatsManager.PULL_SUCCESS);
+    }
+
+    @Test
+    public void onPullAtom_outgoingMms_empty() {
+        doReturn(new ArrayList<>()).when(mPersistMmsAtomsStorage).getOutgoingMms(anyLong());
+        List<StatsEvent> actualAtoms = new ArrayList<>();
+
+        int result = mMmsMetricsCollector.onPullAtom(OUTGOING_MMS, actualAtoms);
+
+        assertThat(actualAtoms).hasSize(0);
+        assertThat(result).isEqualTo(StatsManager.PULL_SUCCESS);
+    }
+
+    @Test
+    public void onPullAtom_outgoingMms_tooFrequent() {
+        doReturn(null).when(mPersistMmsAtomsStorage).getOutgoingMms(anyLong());
+        List<StatsEvent> actualAtoms = new ArrayList<>();
+
+        int result = mMmsMetricsCollector.onPullAtom(OUTGOING_MMS, actualAtoms);
+
+        assertThat(actualAtoms).hasSize(0);
+        assertThat(result).isEqualTo(StatsManager.PULL_SKIP);
+        verify(mPersistMmsAtomsStorage, times(1))
+                .getOutgoingMms(eq(MIN_COOLDOWN_MILLIS));
+        verifyNoMoreInteractions(mPersistMmsAtomsStorage);
+    }
+
+    @Test
+    public void onPullAtom_outgoingMms_multipleMms() {
+        OutgoingMms outgoingMms = OutgoingMms.newBuilder().build();
+        doReturn(Arrays.asList(outgoingMms, outgoingMms, outgoingMms, outgoingMms))
+                .when(mPersistMmsAtomsStorage).getOutgoingMms(anyLong());
+        List<StatsEvent> actualAtoms = new ArrayList<>();
+
+        int result = mMmsMetricsCollector.onPullAtom(OUTGOING_MMS, actualAtoms);
+
+        assertThat(actualAtoms).hasSize(4);
+        assertThat(result).isEqualTo(StatsManager.PULL_SUCCESS);
+    }
+}
\ No newline at end of file
diff --git a/tests/unittests/src/com/android/mms/service/metrics/PersistMmsAtomsStorageTest.java b/tests/unittests/src/com/android/mms/service/metrics/PersistMmsAtomsStorageTest.java
new file mode 100644
index 0000000..7f604bc
--- /dev/null
+++ b/tests/unittests/src/com/android/mms/service/metrics/PersistMmsAtomsStorageTest.java
@@ -0,0 +1,734 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.mms.service.metrics;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.anyInt;
+import static org.mockito.Mockito.anyString;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.Build;
+import android.telephony.ServiceState;
+import android.telephony.TelephonyManager;
+import androidx.annotation.Nullable;
+
+import com.android.mms.IncomingMms;
+import com.android.mms.OutgoingMms;
+import com.android.mms.PersistMmsAtoms;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.stream.Collectors;
+
+public class PersistMmsAtomsStorageTest {
+    private static final String TEST_FILE = "PersistMmsAtomsStorageTest.pb";
+    @Rule
+    public TemporaryFolder mFolder = new TemporaryFolder();
+    private File mTestFile;
+    private static final long START_TIME_MILLIS = 2000L;
+    private static final int CARRIER1_ID = 1435;
+    private static final int CARRIER2_ID = 1187;
+    private TestablePersistMmsAtomsStorage mTestablePersistMmsAtomsStorage;
+    // IncomingMms
+    private List<IncomingMms> mIncomingMmsList;
+    private IncomingMms mIncomingMms1Proto;
+    private IncomingMms mIncomingMms2Proto;
+    // OutgoingMms
+    private List<OutgoingMms> mOutgoingMmsList;
+    private OutgoingMms mOutgoingMms1Proto;
+    private OutgoingMms mOutgoingMms2Proto;
+    // Mocked classes
+    private Context mContext;
+    private PackageManager mPackageManager;
+    private FileOutputStream mTestFileOutputStream;
+    // Comparator to compare proto objects
+    private static final Comparator<Object> sProtoComparator =
+            new Comparator<Object>() {
+                @Override
+                public int compare(Object o1, Object o2) {
+                    if (o1 == o2) {
+                        return 0;
+                    }
+                    if (o1 == null) {
+                        return -1;
+                    }
+                    if (o2 == null) {
+                        return 1;
+                    }
+                    assertEquals(o1.getClass(), o2.getClass());
+                    return o1.toString().compareTo(o2.toString());
+                }
+            };
+
+
+    @Before
+    public void setUp() throws Exception {
+        mTestFileOutputStream = mock(FileOutputStream.class);
+        mContext = mock(Context.class);
+        mPackageManager = mock(PackageManager.class);
+        makeTestData();
+
+        // By default, test loading with real file IO and saving with mocks.
+        mTestFile = mFolder.newFile(TEST_FILE);
+        doReturn(false).when(mPackageManager).
+                hasSystemFeature(PackageManager.FEATURE_RAM_LOW);
+        doReturn(mPackageManager).when(mContext).getPackageManager();
+        doReturn(mTestFileOutputStream).when(mContext).openFileOutput(anyString(), anyInt());
+        doReturn(mTestFile).when(mContext).getFileStreamPath(anyString());
+    }
+
+    @After
+    public void tearDown() {
+        mTestFile.delete();
+        mTestFile = null;
+        mFolder = null;
+        mIncomingMmsList = null;
+        mIncomingMms1Proto = null;
+        mIncomingMms2Proto = null;
+        mOutgoingMmsList = null;
+        mOutgoingMms1Proto = null;
+        mOutgoingMms2Proto = null;
+        mTestablePersistMmsAtomsStorage = null;
+        mTestFileOutputStream = null;
+        mPackageManager = null;
+        mContext = null;
+    }
+
+    @Test
+    public void loadAtoms_fileNotExist() {
+        mTestFile.delete();
+        mTestablePersistMmsAtomsStorage = new TestablePersistMmsAtomsStorage(mContext);
+        mTestablePersistMmsAtomsStorage.incTimeMillis(100L);
+
+        // No exception should be thrown, storage should be empty, pull time should be start time.
+        assertAllPullTimestampEquals(START_TIME_MILLIS);
+        assertStorageIsEmptyForAllAtoms();
+    }
+
+    @Test
+    public void loadAtoms_unreadable() throws Exception {
+        createEmptyTestFile();
+        mTestFile.setReadable(false);
+
+        mTestablePersistMmsAtomsStorage = new TestablePersistMmsAtomsStorage(mContext);
+        mTestablePersistMmsAtomsStorage.incTimeMillis(100L);
+
+        // No exception should be thrown, storage should be empty, pull time should be start time.
+        assertAllPullTimestampEquals(START_TIME_MILLIS);
+        assertStorageIsEmptyForAllAtoms();
+    }
+
+    @Test
+    public void loadAtoms_emptyProto() throws Exception {
+        createEmptyTestFile();
+
+        mTestablePersistMmsAtomsStorage = new TestablePersistMmsAtomsStorage(mContext);
+        mTestablePersistMmsAtomsStorage.incTimeMillis(100L);
+
+        // No exception should be thrown, storage should be empty, pull time should be start time.
+        assertAllPullTimestampEquals(START_TIME_MILLIS);
+        assertStorageIsEmptyForAllAtoms();
+    }
+
+    @Test
+    public void loadAtoms_malformedFile() throws Exception {
+        FileOutputStream stream = new FileOutputStream(mTestFile);
+        stream.write("This is not a proto file.".getBytes(StandardCharsets.UTF_8));
+        stream.close();
+
+        mTestablePersistMmsAtomsStorage = new TestablePersistMmsAtomsStorage(mContext);
+        mTestablePersistMmsAtomsStorage.incTimeMillis(100L);
+
+        // No exception should be thrown, storage should be empty, pull time should be start time.
+        assertAllPullTimestampEquals(START_TIME_MILLIS);
+        assertStorageIsEmptyForAllAtoms();
+    }
+
+    @Test
+    public void loadAtoms_pullTimeMissing() throws Exception {
+        // Create test file with lastPullTimeMillis = 0L, i.e. default/unknown.
+        createTestFile(0L);
+
+        mTestablePersistMmsAtomsStorage = new TestablePersistMmsAtomsStorage(mContext);
+        mTestablePersistMmsAtomsStorage.incTimeMillis(100L);
+
+        // No exception should be thrown, storage should be match, pull time should be start time.
+        assertAllPullTimestampEquals(START_TIME_MILLIS);
+        assertProtoListEqualsIgnoringOrder(mIncomingMmsList,
+                mTestablePersistMmsAtomsStorage.getIncomingMms(0L));
+        assertProtoListEqualsIgnoringOrder(mOutgoingMmsList,
+                mTestablePersistMmsAtomsStorage.getOutgoingMms(0L));
+    }
+
+    @Test
+    public void loadAtoms_validContents() throws Exception {
+        createTestFile(100L);
+
+        mTestablePersistMmsAtomsStorage = new TestablePersistMmsAtomsStorage(mContext);
+
+        // No exception should be thrown, storage and pull time should match.
+        assertAllPullTimestampEquals(100L);
+        assertProtoListEqualsIgnoringOrder(mIncomingMmsList,
+                mTestablePersistMmsAtomsStorage.getIncomingMms(0L));
+        assertProtoListEqualsIgnoringOrder(mOutgoingMmsList,
+                mTestablePersistMmsAtomsStorage.getOutgoingMms(0L));
+    }
+
+    @Test
+    public void addIncomingMms_emptyProto() throws Exception {
+        createEmptyTestFile();
+
+        mTestablePersistMmsAtomsStorage = new TestablePersistMmsAtomsStorage(mContext);
+        mTestablePersistMmsAtomsStorage.addIncomingMms(mIncomingMms1Proto);
+        mTestablePersistMmsAtomsStorage.incTimeMillis(100L);
+
+        // IncomingMms should be added successfully, there should not be any OutgoingMms,
+        // changes should be saved.
+        verifyCurrentStateSavedToFileOnce();
+        assertProtoListIsEmpty(mTestablePersistMmsAtomsStorage.getOutgoingMms(0L));
+        List<IncomingMms> expectedIncomingMmsList = new ArrayList<>();
+        expectedIncomingMmsList.add(mIncomingMms1Proto);
+        assertProtoListEquals(expectedIncomingMmsList,
+                mTestablePersistMmsAtomsStorage.getIncomingMms(0L));
+    }
+
+    @Test
+    public void addIncomingMms_withExistingEntries() throws Exception {
+        createEmptyTestFile();
+
+        mTestablePersistMmsAtomsStorage = new TestablePersistMmsAtomsStorage(mContext);
+        mTestablePersistMmsAtomsStorage.addIncomingMms(mIncomingMms1Proto);
+        mTestablePersistMmsAtomsStorage.addIncomingMms(mIncomingMms2Proto);
+        mTestablePersistMmsAtomsStorage.incTimeMillis(100L);
+
+        // IncomingMms should be added successfully.
+        verifyCurrentStateSavedToFileOnce();
+        List<IncomingMms> expectedIncomingMmsList = Arrays.asList(mIncomingMms1Proto,
+                mIncomingMms2Proto);
+        assertProtoListEqualsIgnoringOrder(expectedIncomingMmsList,
+                mTestablePersistMmsAtomsStorage.getIncomingMms(0L));
+    }
+
+    @Test
+    public void addIncomingMms_updateExistingEntries() throws Exception {
+        createTestFile(START_TIME_MILLIS);
+
+        // Add copy of mIncomingMms1Proto.
+        mTestablePersistMmsAtomsStorage = new TestablePersistMmsAtomsStorage(mContext);
+        mTestablePersistMmsAtomsStorage.addIncomingMms(copyOf(mIncomingMms1Proto));
+        mTestablePersistMmsAtomsStorage.incTimeMillis(100L);
+
+        // mIncomingMms1Proto's mms count should be increased by 1 and avgIntervalMillis
+        // should be updated correctly.
+        verifyCurrentStateSavedToFileOnce();
+        IncomingMms newIncomingMm1Proto = copyOf(mIncomingMms1Proto);
+        newIncomingMm1Proto = newIncomingMm1Proto.toBuilder()
+                .setMmsCount(2)
+                .setAvgIntervalMillis(mIncomingMms1Proto.getAvgIntervalMillis())
+                .build();
+        List<IncomingMms> expectedIncomingMmsList = Arrays.asList(newIncomingMm1Proto,
+                mIncomingMms2Proto);
+        assertProtoListEqualsIgnoringOrder(expectedIncomingMmsList,
+                mTestablePersistMmsAtomsStorage.getIncomingMms(0L));
+    }
+
+    @Test
+    public void addIncomingMms_tooManyEntries() throws Exception {
+        createEmptyTestFile();
+
+        mTestablePersistMmsAtomsStorage = new TestablePersistMmsAtomsStorage(mContext);
+        // Add 26 mms whereas max size is 25.
+        IncomingMms mms = IncomingMms.newBuilder()
+                .setRoaming(ServiceState.ROAMING_TYPE_DOMESTIC)
+                .setSimSlotIndex(0)
+                .setIsMultiSim(false)
+                .setIsEsim(false)
+                .setCarrierId(CARRIER1_ID)
+                .setMmsCount(1)
+                .setAvgIntervalMillis(500L)
+                .setRetryId(0)
+                .setHandledByCarrierApp(false)
+                .build();
+        for (int ratType = 0; ratType < 5; ratType++) {
+            for (int resultType = 0; resultType < 5; resultType++) {
+                mms = mms.toBuilder().setRat(ratType).setResult(resultType).build();
+                mTestablePersistMmsAtomsStorage.addIncomingMms(mms);
+                mTestablePersistMmsAtomsStorage.incTimeMillis(100L);
+            }
+        }
+
+        // Add 26th mms 5 times
+        IncomingMms lastMms = copyOf(mms);
+        lastMms = lastMms.toBuilder().setRat(6).setResult(6).build();
+        for (int i = 0; i < 5; i++) {
+            mTestablePersistMmsAtomsStorage.addIncomingMms(lastMms);
+            mTestablePersistMmsAtomsStorage.incTimeMillis(100L);
+        }
+
+        // Last mms should be present in storage.
+        assertHasMmsAndCountAvg(mTestablePersistMmsAtomsStorage.getIncomingMms(0L),
+                lastMms, 5L, lastMms.getAvgIntervalMillis());
+    }
+
+    @Test
+    public void getIncomingMms_tooFrequent() throws Exception {
+        createTestFile(START_TIME_MILLIS);
+
+        mTestablePersistMmsAtomsStorage = new TestablePersistMmsAtomsStorage(mContext);
+        // Pull interval less than minimum.
+        mTestablePersistMmsAtomsStorage.incTimeMillis(50L);
+
+        List<IncomingMms> incomingMmsList = mTestablePersistMmsAtomsStorage
+                .getIncomingMms(100L);
+        // Should be denied.
+        assertNull(incomingMmsList);
+    }
+
+    @Test
+    public void getIncomingMms_withSavedAtoms() throws Exception {
+        createTestFile(START_TIME_MILLIS);
+
+        mTestablePersistMmsAtomsStorage = new TestablePersistMmsAtomsStorage(mContext);
+        mTestablePersistMmsAtomsStorage.incTimeMillis(100L);
+        List<IncomingMms> incomingMmsList1 = mTestablePersistMmsAtomsStorage
+                .getIncomingMms(50L);
+        mTestablePersistMmsAtomsStorage.incTimeMillis(100L);
+        List<IncomingMms> incomingMmsList2 = mTestablePersistMmsAtomsStorage
+                .getIncomingMms(50L);
+
+        // First set of results should be equal to file contents.
+        List<IncomingMms> expectedIncomingMmsList = Arrays.asList(mIncomingMms1Proto,
+                mIncomingMms2Proto);
+        assertProtoListEqualsIgnoringOrder(expectedIncomingMmsList, incomingMmsList1);
+        // Second set of results should be empty.
+        expectedIncomingMmsList = new ArrayList<>();
+        assertProtoListEqualsIgnoringOrder(expectedIncomingMmsList, incomingMmsList2);
+        // Corresponding pull timestamp should be updated and saved.
+        assertEquals(START_TIME_MILLIS + 200L, mTestablePersistMmsAtomsStorage
+                .getAtomsProto().getIncomingMmsPullTimestampMillis());
+        InOrder inOrder = inOrder(mTestFileOutputStream);
+        assertEquals(START_TIME_MILLIS + 100L,
+                getAtomsWritten(inOrder).getIncomingMmsPullTimestampMillis());
+        assertEquals(START_TIME_MILLIS + 200L,
+                getAtomsWritten(inOrder).getIncomingMmsPullTimestampMillis());
+        inOrder.verifyNoMoreInteractions();
+    }
+
+    @Test
+    public void addOutgoingMms_emptyProto() throws Exception {
+        createEmptyTestFile();
+
+        mTestablePersistMmsAtomsStorage = new TestablePersistMmsAtomsStorage(mContext);
+        mTestablePersistMmsAtomsStorage.addOutgoingMms(mOutgoingMms1Proto);
+        mTestablePersistMmsAtomsStorage.incTimeMillis(100L);
+
+        // OutgoingMms should be added successfully, there should not be any IncomingMms,
+        // changes should be saved.
+        verifyCurrentStateSavedToFileOnce();
+        assertProtoListIsEmpty(mTestablePersistMmsAtomsStorage.getIncomingMms(0L));
+        List<OutgoingMms> expectedOutgoingMmsList = new ArrayList<>();
+        expectedOutgoingMmsList.add(mOutgoingMms1Proto);
+        assertProtoListEquals(expectedOutgoingMmsList,
+                mTestablePersistMmsAtomsStorage.getOutgoingMms(0L));
+    }
+
+    @Test
+    public void addOutgoingMms_withExistingEntries() throws Exception {
+        createEmptyTestFile();
+
+        mTestablePersistMmsAtomsStorage = new TestablePersistMmsAtomsStorage(mContext);
+        mTestablePersistMmsAtomsStorage.addOutgoingMms(mOutgoingMms1Proto);
+        mTestablePersistMmsAtomsStorage.addOutgoingMms(mOutgoingMms2Proto);
+        mTestablePersistMmsAtomsStorage.incTimeMillis(100L);
+
+        // OutgoingMms should be added successfully
+        verifyCurrentStateSavedToFileOnce();
+        List<OutgoingMms> expectedOutgoingMmsList = Arrays.asList(mOutgoingMms1Proto,
+                mOutgoingMms2Proto);
+        assertProtoListEqualsIgnoringOrder(expectedOutgoingMmsList,
+                mTestablePersistMmsAtomsStorage.getOutgoingMms(0L));
+    }
+
+    @Test
+    public void addOutgoingMms_updateExistingEntries() throws Exception {
+        createTestFile(START_TIME_MILLIS);
+
+        // Add copy of mOutgoingMms1Proto
+        mTestablePersistMmsAtomsStorage = new TestablePersistMmsAtomsStorage(mContext);
+        mTestablePersistMmsAtomsStorage.addOutgoingMms(copyOf(mOutgoingMms1Proto));
+        mTestablePersistMmsAtomsStorage.incTimeMillis(100L);
+
+        // mOutgoingMms1Proto's mms count should be increased by 1 and avgIntervalMillis
+        // should be updated correctly.
+        verifyCurrentStateSavedToFileOnce();
+        OutgoingMms newOutgoingMm1Proto = copyOf(mOutgoingMms1Proto);
+        newOutgoingMm1Proto = newOutgoingMm1Proto.toBuilder()
+                .setMmsCount(2)
+                .setAvgIntervalMillis(mOutgoingMms1Proto.getAvgIntervalMillis())
+                .build();
+        List<OutgoingMms> expectedOutgoingMmsList = Arrays.asList(newOutgoingMm1Proto,
+                mOutgoingMms2Proto);
+        assertProtoListEqualsIgnoringOrder(expectedOutgoingMmsList,
+                mTestablePersistMmsAtomsStorage.getOutgoingMms(0L));
+    }
+
+    @Test
+    public void addOutgoingMms_tooManyEntries() throws Exception {
+        createEmptyTestFile();
+
+        mTestablePersistMmsAtomsStorage = new TestablePersistMmsAtomsStorage(mContext);
+        // Add 26 mms whereas max size is 25.
+        OutgoingMms mms = OutgoingMms.newBuilder()
+                .setRoaming(ServiceState.ROAMING_TYPE_DOMESTIC)
+                .setSimSlotIndex(0)
+                .setIsMultiSim(false)
+                .setIsEsim(false)
+                .setCarrierId(CARRIER1_ID)
+                .setMmsCount(1)
+                .setAvgIntervalMillis(500L)
+                .setIsFromDefaultApp(true)
+                .setHandledByCarrierApp(false)
+                .setRetryId(0)
+                .build();
+        for (int ratType = 0; ratType < 5; ratType++) {
+            for (int resultType = 0; resultType < 5; resultType++) {
+                mms = mms.toBuilder().setRat(ratType).setResult(resultType).build();
+                mTestablePersistMmsAtomsStorage.addOutgoingMms(mms);
+                mTestablePersistMmsAtomsStorage.incTimeMillis(100L);
+            }
+        }
+
+        // Add 26th mms 5 times
+        OutgoingMms lastMms = copyOf(mms);
+        lastMms = lastMms.toBuilder().setRat(6).setResult(6).build();
+        for (int i = 0; i < 5; i++) {
+            mTestablePersistMmsAtomsStorage.addOutgoingMms(lastMms);
+            mTestablePersistMmsAtomsStorage.incTimeMillis(100L);
+        }
+
+        // Last mms should be present in storage.
+        assertHasMmsAndCountAvg(mTestablePersistMmsAtomsStorage.getOutgoingMms(0L),
+                lastMms, 5L, lastMms.getAvgIntervalMillis());
+    }
+
+    @Test
+    public void getOutgoingMms_tooFrequent() throws Exception {
+        createTestFile(START_TIME_MILLIS);
+
+        mTestablePersistMmsAtomsStorage = new TestablePersistMmsAtomsStorage(mContext);
+        // Pull interval less than minimum.
+        mTestablePersistMmsAtomsStorage.incTimeMillis(50L);
+
+        List<OutgoingMms> outgoingMmsList = mTestablePersistMmsAtomsStorage
+                .getOutgoingMms(100L);
+        // Should be denied.
+        assertNull(outgoingMmsList);
+    }
+
+    @Test
+    public void getOutgoingMms_withSavedAtoms() throws Exception {
+        createTestFile(START_TIME_MILLIS);
+
+        mTestablePersistMmsAtomsStorage = new TestablePersistMmsAtomsStorage(mContext);
+        mTestablePersistMmsAtomsStorage.incTimeMillis(100L);
+        List<OutgoingMms> outgoingMmsList1 = mTestablePersistMmsAtomsStorage
+                .getOutgoingMms(50L);
+        mTestablePersistMmsAtomsStorage.incTimeMillis(100L);
+        List<OutgoingMms> outgoingMmsList2 = mTestablePersistMmsAtomsStorage
+                .getOutgoingMms(50L);
+
+        // First set of results should be equal to file contents.
+        List<OutgoingMms> expectedOutgoingMmsList = Arrays.asList(mOutgoingMms1Proto,
+                mOutgoingMms2Proto);
+        assertProtoListEqualsIgnoringOrder(expectedOutgoingMmsList, outgoingMmsList1);
+        // Second set of results should be empty.
+        expectedOutgoingMmsList = new ArrayList<>();
+        assertProtoListEqualsIgnoringOrder(expectedOutgoingMmsList, outgoingMmsList2);
+        // Corresponding pull timestamp should be updated and saved.
+        assertEquals(START_TIME_MILLIS + 200L, mTestablePersistMmsAtomsStorage
+                .getAtomsProto().getOutgoingMmsPullTimestampMillis());
+        InOrder inOrder = inOrder(mTestFileOutputStream);
+        assertEquals(START_TIME_MILLIS + 100L,
+                getAtomsWritten(inOrder).getOutgoingMmsPullTimestampMillis());
+        assertEquals(START_TIME_MILLIS + 200L,
+                getAtomsWritten(inOrder).getOutgoingMmsPullTimestampMillis());
+        inOrder.verifyNoMoreInteractions();
+    }
+
+    /** Utilities */
+
+    private void assertAllPullTimestampEquals(long timestamp) {
+        assertEquals(timestamp, mTestablePersistMmsAtomsStorage.getAtomsProto()
+                .getIncomingMmsPullTimestampMillis());
+        assertEquals(timestamp, mTestablePersistMmsAtomsStorage.getAtomsProto()
+                .getOutgoingMmsPullTimestampMillis());
+    }
+
+    private void assertStorageIsEmptyForAllAtoms() {
+        assertProtoListIsEmpty(mTestablePersistMmsAtomsStorage.getIncomingMms(0L));
+        assertProtoListIsEmpty(mTestablePersistMmsAtomsStorage.getOutgoingMms(0L));
+    }
+
+    private static <T> void assertProtoListIsEmpty(@Nullable List<T> list) {
+        assertNotNull(list);
+        assertEquals(0, list.size());
+    }
+
+    private static <T> void assertProtoListEquals(@Nullable List<T> expected,
+            @Nullable List<T> actual) {
+        assertNotNull(expected);
+        assertNotNull(actual);
+        String message =
+                "Expected:\n" + expected.stream().map(Object::toString).collect(
+                        Collectors.joining(", "))
+                        + "\nGot:\n" + actual.stream().map(Object::toString).collect(
+                        Collectors.joining(", "));
+        assertEquals(message, expected.size(), actual.size());
+        for (int i = 0; i < expected.size(); i++) {
+            assertTrue(message, expected.get(i).equals(actual.get(i)));
+        }
+    }
+
+    private static <T> void assertProtoListEqualsIgnoringOrder(@Nullable List<T> expected,
+            @Nullable List<T> actual) {
+        assertNotNull(expected);
+        assertNotNull(actual);
+        expected = new ArrayList<>(expected);
+        actual = new ArrayList<>(actual);
+        Collections.sort(expected, sProtoComparator);
+        Collections.sort(actual, sProtoComparator);
+        assertProtoListEquals(expected, actual);
+    }
+
+    private static void assertHasMmsAndCountAvg(@Nullable List<IncomingMms> incomingMmsList,
+            @Nullable IncomingMms expectedMms, long expectedCount, long expectedAvg) {
+        assertNotNull(incomingMmsList);
+        assertNotNull(expectedMms);
+        long actualCount = -1;
+        long actualAvg = -1;
+        for (IncomingMms mms : incomingMmsList) {
+            if (mms.getRat() == expectedMms.getRat()
+                    && mms.getResult() == expectedMms.getResult()
+                    && mms.getRoaming() == expectedMms.getRoaming()
+                    && mms.getSimSlotIndex() == expectedMms.getSimSlotIndex()
+                    && mms.getIsMultiSim() == expectedMms.getIsMultiSim()
+                    && mms.getIsEsim() == expectedMms.getIsEsim()
+                    && mms.getCarrierId() == expectedMms.getCarrierId()
+                    && mms.getRetryId() == expectedMms.getRetryId()
+                    && mms.getHandledByCarrierApp() == expectedMms.getHandledByCarrierApp()) {
+                actualCount = mms.getMmsCount();
+                actualAvg = mms.getAvgIntervalMillis();
+            }
+        }
+
+        assertEquals(expectedCount, actualCount);
+        assertEquals(expectedAvg, actualAvg);
+    }
+
+    private static void assertHasMmsAndCountAvg(@Nullable List<OutgoingMms> outgoingMmsList,
+            @Nullable OutgoingMms expectedMms, long expectedCount, long expectedAvg) {
+        assertNotNull(outgoingMmsList);
+        assertNotNull(expectedMms);
+        long actualCount = -1;
+        long actualAvg = -1;
+        for (OutgoingMms mms : outgoingMmsList) {
+            if (mms.getRat() == expectedMms.getRat()
+                    && mms.getResult() == expectedMms.getResult()
+                    && mms.getRoaming() == expectedMms.getRoaming()
+                    && mms.getSimSlotIndex() == expectedMms.getSimSlotIndex()
+                    && mms.getIsMultiSim() == expectedMms.getIsMultiSim()
+                    && mms.getIsEsim() == expectedMms.getIsEsim()
+                    && mms.getCarrierId() == expectedMms.getCarrierId()
+                    && mms.getIsFromDefaultApp() == expectedMms.getIsFromDefaultApp()
+                    && mms.getRetryId() == expectedMms.getRetryId()
+                    && mms.getHandledByCarrierApp() == expectedMms.getHandledByCarrierApp()) {
+                actualCount = mms.getMmsCount();
+                actualAvg = mms.getAvgIntervalMillis();
+            }
+        }
+
+        assertEquals(expectedCount, actualCount);
+        assertEquals(expectedAvg, actualAvg);
+    }
+
+    private void verifyCurrentStateSavedToFileOnce() throws Exception {
+        InOrder inOrder = inOrder(mTestFileOutputStream);
+        inOrder.verify(mTestFileOutputStream, times(1))
+                .write(eq(mTestablePersistMmsAtomsStorage.getAtomsProto().toByteArray()));
+        inOrder.verify(mTestFileOutputStream, times(1)).close();
+        inOrder.verifyNoMoreInteractions();
+    }
+
+    private PersistMmsAtoms getAtomsWritten(@Nullable InOrder inOrder) throws Exception {
+        if (inOrder == null) {
+            inOrder = inOrder(mTestFileOutputStream);
+        }
+        ArgumentCaptor bytesCaptor = ArgumentCaptor.forClass(Object.class);
+        inOrder.verify(mTestFileOutputStream, times(1))
+                .write((byte[]) bytesCaptor.capture());
+        PersistMmsAtoms savedAtoms = PersistMmsAtoms.parseFrom((byte[]) bytesCaptor.getValue());
+        inOrder.verify(mTestFileOutputStream, times(1)).close();
+        return savedAtoms;
+    }
+
+    private static IncomingMms copyOf(IncomingMms source) {
+        return source.toBuilder().build();
+    }
+
+    private static OutgoingMms copyOf(OutgoingMms source) {
+        return source.toBuilder().build();
+    }
+
+    private void makeTestData() {
+        mIncomingMms1Proto = IncomingMms.newBuilder()
+                .setRat(TelephonyManager.NETWORK_TYPE_LTE)
+                .setResult(1)
+                .setRoaming(ServiceState.ROAMING_TYPE_NOT_ROAMING)
+                .setSimSlotIndex(0)
+                .setIsMultiSim(true)
+                .setIsEsim(false)
+                .setCarrierId(CARRIER1_ID)
+                .setAvgIntervalMillis(500L)
+                .setMmsCount(1)
+                .setRetryId(0)
+                .setHandledByCarrierApp(false)
+                .build();
+
+        mIncomingMms2Proto = IncomingMms.newBuilder()
+                .setRat(TelephonyManager.NETWORK_TYPE_LTE)
+                .setResult(1)
+                .setRoaming(ServiceState.ROAMING_TYPE_NOT_ROAMING)
+                .setSimSlotIndex(1)
+                .setIsMultiSim(false)
+                .setIsEsim(false)
+                .setCarrierId(CARRIER2_ID)
+                .setAvgIntervalMillis(500L)
+                .setMmsCount(1)
+                .setRetryId(0)
+                .setHandledByCarrierApp(false)
+                .build();
+
+        mIncomingMmsList = new ArrayList<>();
+        mIncomingMmsList.add(mIncomingMms1Proto);
+        mIncomingMmsList.add(mIncomingMms2Proto);
+
+        mOutgoingMms1Proto = OutgoingMms.newBuilder()
+                .setRat(0)
+                .setResult(1)
+                .setRoaming(0)
+                .setSimSlotIndex(0)
+                .setIsMultiSim(true)
+                .setIsEsim(false)
+                .setCarrierId(CARRIER1_ID)
+                .setAvgIntervalMillis(500L)
+                .setMmsCount(1)
+                .setIsFromDefaultApp(true)
+                .setRetryId(0)
+                .setHandledByCarrierApp(false)
+                .build();
+
+        mOutgoingMms2Proto = OutgoingMms.newBuilder()
+                .setRat(0)
+                .setResult(1)
+                .setRoaming(0)
+                .setSimSlotIndex(0)
+                .setIsMultiSim(false)
+                .setIsEsim(false)
+                .setCarrierId(CARRIER2_ID)
+                .setAvgIntervalMillis(500L)
+                .setMmsCount(1)
+                .setIsFromDefaultApp(true)
+                .setRetryId(0)
+                .setHandledByCarrierApp(false)
+                .build();
+
+        mOutgoingMmsList = new ArrayList<>();
+        mOutgoingMmsList.add(mOutgoingMms1Proto);
+        mOutgoingMmsList.add(mOutgoingMms2Proto);
+    }
+
+    private void createEmptyTestFile() throws Exception {
+        PersistMmsAtoms atoms = PersistMmsAtoms.newBuilder().build();
+        FileOutputStream stream = new FileOutputStream(mTestFile);
+        stream.write(atoms.toByteArray());
+        stream.close();
+    }
+
+    private void createTestFile(long lastPullTimeMillis) throws Exception {
+        PersistMmsAtoms atoms = PersistMmsAtoms.newBuilder()
+                .setBuildFingerprint(Build.FINGERPRINT)
+                .setIncomingMmsPullTimestampMillis(lastPullTimeMillis)
+                .setOutgoingMmsPullTimestampMillis(lastPullTimeMillis)
+                .addAllIncomingMms(mIncomingMmsList)
+                .addAllOutgoingMms(mOutgoingMmsList)
+                .build();
+
+        FileOutputStream stream = new FileOutputStream(mTestFile);
+        stream.write(atoms.toByteArray());
+        stream.close();
+    }
+
+    private static class TestablePersistMmsAtomsStorage extends PersistMmsAtomsStorage {
+        private long mTimeMillis = START_TIME_MILLIS;
+
+        TestablePersistMmsAtomsStorage(Context context) {
+            super(context);
+            // Remove delay for saving to persistent storage during tests.
+            mSaveImmediately = true;
+        }
+
+        @Override
+        protected long getWallTimeMillis() {
+            // NOTE: super class constructor will be executed before private field is set, which
+            // gives the wrong start time (mTimeMillis will have its default value of 0L).
+            return mTimeMillis == 0L ? START_TIME_MILLIS : mTimeMillis;
+        }
+
+        private void incTimeMillis(long timeMillis) {
+            mTimeMillis += timeMillis;
+        }
+
+        private PersistMmsAtoms getAtomsProto() {
+            // NOTE: unlike other methods in PersistAtomsStorage, this is not synchronized, but
+            // should be fine since the test is single-threaded.
+            return mPersistMmsAtoms;
+        }
+    }
+}
\ No newline at end of file