Snap for 10447354 from 0a1ab1245ef3f64593d270f8cd5217a823e51c0c to mainline-networking-release

Change-Id: I86df64110ca453c97195d8a288c08b60de74f050
diff --git a/Android.bp b/Android.bp
index e7e78ae..b586dc6 100644
--- a/Android.bp
+++ b/Android.bp
@@ -19,6 +19,21 @@
     default_applicable_licenses: ["Android-Apache-2.0"],
 }
 
+genrule {
+    name: "statslog-mms-java-gen",
+    tools: ["stats-log-api-gen"],
+    cmd: "$(location stats-log-api-gen) --java $(out) --module mms"
+        + " --javaPackage com.android.mms --javaClass MmsStatsLog",
+    out: ["com/android/mms/MmsStatsLog.java"],
+}
+
+java_library {
+    name: "mms-statsd",
+    srcs: [
+        ":statslog-mms-java-gen",
+    ],
+}
+
 android_app {
     name: "MmsService",
     platform_apis: true,
@@ -30,4 +45,16 @@
         proguard_flags_files: ["proguard.flags"],
     },
     certificate: "platform",
+    static_libs: [
+        "mms-protos-lite",
+        "mms-statsd",
+        "androidx.annotation_annotation",
+    ],
+}
+
+filegroup {
+    name: "mms-service-srcs",
+    srcs: [
+        "src/com/android/mms/service/**/*.java",
+    ],
 }
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 87e7947..a19a11a 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -26,6 +26,10 @@
     <uses-permission android:name="android.permission.BROADCAST_WAP_PUSH"/>
     <uses-permission android:name="android.permission.BIND_CARRIER_SERVICES"/>
     <uses-permission android:name="android.permission.INTERACT_ACROSS_USERS"/>
+    <!-- Needed to check if subscription is active. -->
+    <uses-permission android:name="android.permission.READ_PHONE_STATE"/>
+    <!-- Needed to query user associated with a subscription. -->
+    <uses-permission android:name="android.permission.MANAGE_SUBSCRIPTION_USER_ASSOCIATION"/>
 
     <protected-broadcast android:name="android.settings.ENABLE_MMS_DATA_REQUEST"/>
 
diff --git a/proto/Android.bp b/proto/Android.bp
new file mode 100644
index 0000000..b55e987
--- /dev/null
+++ b/proto/Android.bp
@@ -0,0 +1,29 @@
+/*
+ * 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 {
+     default_applicable_licenses: ["Android-Apache-2.0"],
+ }
+
+ java_library_static {
+     name: "mms-protos-lite",
+     proto: {
+         type: "lite",
+     },
+     sdk_version: "system_current",
+     min_sdk_version: "33",
+     srcs: ["src/persist_mms_atoms.proto"],
+ }
\ No newline at end of file
diff --git a/proto/src/persist_mms_atoms.proto b/proto/src/persist_mms_atoms.proto
new file mode 100644
index 0000000..bde1cb9
--- /dev/null
+++ b/proto/src/persist_mms_atoms.proto
@@ -0,0 +1,70 @@
+/*
+ * 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.
+ */
+
+syntax = "proto2";
+
+package com.android.mms;
+option java_package = "com.android.mms";
+option java_outer_classname = "PersistMmsAtomsProto";
+option java_multiple_files = true;
+
+message PersistMmsAtoms {
+  /* Last Android build fingerprint. This usually changes after system OTA. */
+  optional string build_fingerprint = 1;
+
+  /* Incoming MMS statistics and information. */
+  repeated IncomingMms incoming_mms = 2;
+
+  /* Timestamp of last incoming_mms pull. */
+  optional int64 incoming_mms_pull_timestamp_millis = 3;
+
+  /* Outgoing MMS statistics and information. */
+  repeated OutgoingMms outgoing_mms = 4;
+
+  /* Timestamp of last outgoing_mms pull. */
+  optional int64 outgoing_mms_pull_timestamp_millis = 5;
+}
+
+message IncomingMms {
+  optional int32 rat = 1;
+  optional int32 result = 2;
+  optional int32 roaming = 3;
+  optional int32 sim_slot_index = 4;
+  optional bool is_multi_sim = 5;
+  optional bool is_esim = 6;
+  optional int32 carrier_id = 7;
+  optional int64 avg_interval_millis = 8;
+  optional int64 mms_count = 9;
+  optional int32 retry_id = 10;
+  optional bool handled_by_carrier_app = 11;
+  optional bool is_managed_profile = 12;
+}
+
+message OutgoingMms {
+  optional int32 rat = 1;
+  optional int32 result = 2;
+  optional int32 roaming = 3;
+  optional int32 sim_slot_index = 4;
+  optional bool is_multi_sim = 5;
+  optional bool is_esim = 6;
+  optional int32 carrier_id = 7;
+  optional int64 avg_interval_millis = 8;
+  optional int64 mms_count = 9;
+  optional bool is_from_default_app = 10;
+  optional int32 retry_id = 11;
+  optional bool handled_by_carrier_app = 12;
+  optional bool is_managed_profile = 13;
+}
diff --git a/src/com/android/mms/service/DownloadRequest.java b/src/com/android/mms/service/DownloadRequest.java
index 0f12415..62fa9e6 100644
--- a/src/com/android/mms/service/DownloadRequest.java
+++ b/src/com/android/mms/service/DownloadRequest.java
@@ -38,6 +38,8 @@
 import android.text.TextUtils;
 
 import com.android.mms.service.exception.MmsHttpException;
+import com.android.mms.service.metrics.MmsStats;
+
 import com.google.android.mms.MmsException;
 import com.google.android.mms.pdu.GenericPdu;
 import com.google.android.mms.pdu.PduHeaders;
@@ -59,8 +61,8 @@
 
     public DownloadRequest(RequestManager manager, int subId, String locationUrl,
             Uri contentUri, PendingIntent downloadedIntent, String creator,
-            Bundle configOverrides, Context context, long messageId) {
-        super(manager, subId, creator, configOverrides, context, messageId);
+            Bundle configOverrides, Context context, long messageId, MmsStats mmsStats) {
+        super(manager, subId, creator, configOverrides, context, messageId, mmsStats);
         mLocationUrl = locationUrl;
         mDownloadedIntent = downloadedIntent;
         mContentUri = contentUri;
@@ -296,10 +298,12 @@
             if (mCarrierMessagingServiceWrapper.bindToCarrierMessagingService(
                     context, carrierMessagingServicePackage, Runnable::run,
                     ()->onServiceReady())) {
-                LogUtil.v("bindService() for carrier messaging service succeeded. "
+                LogUtil.v("bindService() for carrier messaging service: "
+                        + carrierMessagingServicePackage + " succeeded. "
                         + MmsService.formatCrossStackMessageId(mMessageId));
             } else {
-                LogUtil.e("bindService() for carrier messaging service failed. "
+                LogUtil.e("bindService() for carrier messaging service: "
+                        + carrierMessagingServicePackage + " failed. "
                         + MmsService.formatCrossStackMessageId(mMessageId));
                 carrierDownloadCallback.onDownloadMmsComplete(
                         CarrierMessagingService.DOWNLOAD_STATUS_RETRY_ON_CARRIER_NETWORK);
diff --git a/src/com/android/mms/service/MmsConstants.java b/src/com/android/mms/service/MmsConstants.java
new file mode 100644
index 0000000..57ef5df
--- /dev/null
+++ b/src/com/android/mms/service/MmsConstants.java
@@ -0,0 +1,25 @@
+/*
+ * 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;
+
+import java.util.UUID;
+
+public class MmsConstants {
+    // MMS anomaly uuid
+    public static final UUID MMS_ANOMALY_UUID = UUID.fromString(
+            "e4330975-17be-43b7-87d6-d9f281d33278");
+}
diff --git a/src/com/android/mms/service/MmsHttpClient.java b/src/com/android/mms/service/MmsHttpClient.java
index 5cec66f..b54b1aa 100644
--- a/src/com/android/mms/service/MmsHttpClient.java
+++ b/src/com/android/mms/service/MmsHttpClient.java
@@ -52,6 +52,8 @@
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
+import com.android.internal.annotations.VisibleForTesting;
+
 /**
  * MMS HTTP client for sending and downloading MMS messages
  */
@@ -452,6 +454,21 @@
         return sb.toString();
     }
 
+    private static String getPhoneNumberForMacroLine1(TelephonyManager telephonyManager,
+        Context context, int subId) {
+        String phoneNo = telephonyManager.getLine1Number();
+        if (TextUtils.isEmpty(phoneNo)) {
+            SubscriptionManager subscriptionManager = context.getSystemService(
+                SubscriptionManager.class);
+            if (subscriptionManager != null) {
+                phoneNo = subscriptionManager.getPhoneNumber(subId);
+            } else {
+                LogUtil.e("subscriptionManager is null");
+            }
+        }
+        return phoneNo;
+    }
+
     /*
      * Macro names
      */
@@ -471,15 +488,16 @@
      * @param subId     The subscription ID used to get line number, etc.
      * @return The value of the defined macro
      */
-    private static String getMacroValue(Context context, String macro, Bundle mmsConfig,
-            int subId) {
+    @VisibleForTesting
+    public static String getMacroValue(Context context, String macro, Bundle mmsConfig,
+        int subId) {
         final TelephonyManager telephonyManager = ((TelephonyManager) context.getSystemService(
             Context.TELEPHONY_SERVICE)).createForSubscriptionId(subId);
         if (MACRO_LINE1.equals(macro)) {
-            return telephonyManager.getLine1Number();
+            return getPhoneNumberForMacroLine1(telephonyManager, context, subId);
         } else if (MACRO_LINE1NOCOUNTRYCODE.equals(macro)) {
             return PhoneUtils.getNationalNumber(telephonyManager,
-                telephonyManager.getLine1Number());
+                getPhoneNumberForMacroLine1(telephonyManager, context, subId));
         } else if (MACRO_NAI.equals(macro)) {
             return getNai(telephonyManager, mmsConfig);
         }
diff --git a/src/com/android/mms/service/MmsNetworkManager.java b/src/com/android/mms/service/MmsNetworkManager.java
index 6799d29..f21e510 100644
--- a/src/com/android/mms/service/MmsNetworkManager.java
+++ b/src/com/android/mms/service/MmsNetworkManager.java
@@ -26,11 +26,16 @@
 import android.net.NetworkInfo;
 import android.net.NetworkRequest;
 import android.net.TelephonyNetworkSpecifier;
+import android.os.Binder;
 import android.os.Handler;
 import android.os.Looper;
+import android.os.Message;
+import android.os.PersistableBundle;
 import android.provider.DeviceConfig;
+import android.telephony.CarrierConfigManager;
 import android.telephony.SubscriptionManager;
 import android.telephony.TelephonyManager;
+import android.util.Log;
 
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.telephony.PhoneConstants;
@@ -52,9 +57,8 @@
     // timeout to make sure we don't bail prematurely.
     private static final int ADDITIONAL_NETWORK_ACQUIRE_TIMEOUT_MILLIS = (5 * 1000);
 
-    // Waiting time used before releasing a network prematurely. This allows the MMS download
-    // acknowledgement messages to be sent using the same network that was used to download the data
-    private static final int NETWORK_RELEASE_TIMEOUT_MILLIS = 5 * 1000;
+    /* Event created when receiving ACTION_CARRIER_CONFIG_CHANGED */
+    private static final int EVENT_CARRIER_CONFIG_CHANGED = 1;
 
     private final Context mContext;
 
@@ -88,17 +92,43 @@
     private int mPhoneId;
 
     // If ACTION_SIM_CARD_STATE_CHANGED intent receiver is registered
-    private boolean mReceiverRegistered;
+    private boolean mSimCardStateChangedReceiverRegistered;
 
     private final Dependencies mDeps;
 
+    private int mNetworkReleaseTimeoutMillis;
+    private EventHandler mEventHandler;
+
+    private final class EventHandler extends Handler {
+        EventHandler() {
+            super(Looper.getMainLooper());
+        }
+
+        /**
+         * Handles events coming from the phone stack. Overridden from handler.
+         *
+         * @param msg the message to handle
+         */
+        @Override
+        public void handleMessage(Message msg) {
+            switch (msg.what) {
+                case EVENT_CARRIER_CONFIG_CHANGED:
+                    // Reload mNetworkReleaseTimeoutMillis from CarrierConfigManager.
+                    handleCarrierConfigChanged();
+                    break;
+                default:
+                    LogUtil.e("MmsNetworkManager: ignoring message of unexpected type " + msg.what);
+            }
+        }
+    }
+
     /**
      * This receiver listens to ACTION_SIM_CARD_STATE_CHANGED after starting a new NetworkRequest.
      * If ACTION_SIM_CARD_STATE_CHANGED with SIM_STATE_ABSENT for a SIM card corresponding to the
      * current NetworkRequest is received, it just releases the NetworkRequest without waiting for
      * timeout.
      */
-    private final BroadcastReceiver mReceiver =
+    private final BroadcastReceiver mSimCardStateChangedReceiver =
             new BroadcastReceiver() {
                 @Override
                 public void onReceive(Context context, Intent intent) {
@@ -140,6 +170,35 @@
     }
 
     /**
+     * This receiver listens to ACTION_CARRIER_CONFIG_CHANGED. Whenever receiving this event,
+     * mNetworkReleaseTimeoutMillis needs to be reloaded from CarrierConfigManager.
+     */
+    private final BroadcastReceiver mCarrierConfigChangedReceiver = new BroadcastReceiver() {
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            final String action = intent.getAction();
+            if (CarrierConfigManager.ACTION_CARRIER_CONFIG_CHANGED.equals(action)
+                    && mSubId == intent.getIntExtra(
+                            CarrierConfigManager.EXTRA_SUBSCRIPTION_INDEX,
+                            SubscriptionManager.DEFAULT_SUBSCRIPTION_ID)) {
+                mEventHandler.sendMessage(mEventHandler.obtainMessage(
+                        EVENT_CARRIER_CONFIG_CHANGED));
+            }
+        }
+    };
+
+    private void handleCarrierConfigChanged() {
+        final CarrierConfigManager configManager =
+                (CarrierConfigManager)
+                        mContext.getSystemService(Context.CARRIER_CONFIG_SERVICE);
+        final PersistableBundle config = configManager.getConfigForSubId(mSubId);
+        mNetworkReleaseTimeoutMillis =
+                config.getInt(CarrierConfigManager.KEY_MMS_NETWORK_RELEASE_TIMEOUT_MILLIS_INT);
+        LogUtil.d("MmsNetworkManager: handleCarrierConfigChanged() mNetworkReleaseTimeoutMillis "
+                + mNetworkReleaseTimeoutMillis);
+    }
+
+    /**
      * Network callback for our network request
      */
     private class NetworkRequestCallback extends ConnectivityManager.NetworkCallback {
@@ -245,6 +304,13 @@
                 }
             }
         };
+
+        mEventHandler = new EventHandler();
+        // Register a receiver to listen to ACTION_CARRIER_CONFIG_CHANGED
+        mContext.registerReceiver(
+                mCarrierConfigChangedReceiver,
+                new IntentFilter(CarrierConfigManager.ACTION_CARRIER_CONFIG_CHANGED));
+        handleCarrierConfigChanged();
     }
 
     public MmsNetworkManager(Context context, int subId) {
@@ -270,7 +336,7 @@
                 return;
             }
 
-            if (!mReceiverRegistered) {
+            if (!mSimCardStateChangedReceiverRegistered) {
                 mPhoneId = mDeps.getPhoneId(mSubId);
                 if (mPhoneId == SubscriptionManager.INVALID_PHONE_INDEX
                         || mPhoneId == SubscriptionManager.DEFAULT_PHONE_INDEX) {
@@ -279,9 +345,9 @@
 
                 // Register a receiver to listen to ACTION_SIM_CARD_STATE_CHANGED
                 mContext.registerReceiver(
-                        mReceiver,
+                        mSimCardStateChangedReceiver,
                         new IntentFilter(TelephonyManager.ACTION_SIM_CARD_STATE_CHANGED));
-                mReceiverRegistered = true;
+                mSimCardStateChangedReceiverRegistered = true;
             }
 
             // Not available, so start a new request if not done yet
@@ -297,10 +363,10 @@
                 LogUtil.w(requestId, "MmsNetworkManager: acquire network wait interrupted");
             }
 
-            if (mReceiverRegistered) {
+            if (mSimCardStateChangedReceiverRegistered) {
                 // Unregister the receiver.
-                mContext.unregisterReceiver(mReceiver);
-                mReceiverRegistered = false;
+                mContext.unregisterReceiver(mSimCardStateChangedReceiver);
+                mSimCardStateChangedReceiverRegistered = false;
             }
 
             if (mNetwork != null) {
@@ -328,10 +394,11 @@
     /**
      * Release the MMS network when nobody is holding on to it.
      *
-     * @param requestId          request ID for logging
-     * @param shouldDelayRelease whether the release should be delayed for 5 seconds, the regular
-     *                           use case is to delay this for DownloadRequests to use the network
-     *                           for sending an acknowledgement on the same network
+     * @param requestId          request ID for logging.
+     * @param shouldDelayRelease whether the release should be delayed for a carrier-configured
+     *                           timeout (default 5 seconds), the regular use case is to delay this
+     *                           for DownloadRequests to use the network for sending an
+     *                           acknowledgement on the same network.
      */
     public void releaseNetwork(final String requestId, final boolean shouldDelayRelease) {
         synchronized (this) {
@@ -344,7 +411,7 @@
                         // handler to release the network
                         mReleaseHandler.removeCallbacks(mNetworkReleaseTask);
                         mReleaseHandler.postDelayed(mNetworkReleaseTask,
-                                NETWORK_RELEASE_TIMEOUT_MILLIS);
+                                mNetworkReleaseTimeoutMillis);
                     } else {
                         releaseRequestLocked(mNetworkCallback);
                     }
@@ -443,4 +510,9 @@
         }
         return apnName;
     }
+
+    @VisibleForTesting
+    protected int getNetworkReleaseTimeoutMillis() {
+        return mNetworkReleaseTimeoutMillis;
+    }
 }
diff --git a/src/com/android/mms/service/MmsRequest.java b/src/com/android/mms/service/MmsRequest.java
index dfef1cc..dca77df 100644
--- a/src/com/android/mms/service/MmsRequest.java
+++ b/src/com/android/mms/service/MmsRequest.java
@@ -39,6 +39,7 @@
 import com.android.mms.service.exception.ApnException;
 import com.android.mms.service.exception.MmsHttpException;
 import com.android.mms.service.exception.MmsNetworkException;
+import com.android.mms.service.metrics.MmsStats;
 
 import java.util.UUID;
 
@@ -49,8 +50,6 @@
     private static final int RETRY_TIMES = 3;
     // Signal level threshold for both wifi and cellular
     private static final int SIGNAL_LEVEL_THRESHOLD = 2;
-    // MMS anomaly uuid
-    private final UUID mAnomalyUUID = UUID.fromString("e4330975-17be-43b7-87d6-d9f281d33278");
     public static final String EXTRA_LAST_CONNECTION_FAILURE_CAUSE_CODE
             = "android.telephony.extra.LAST_CONNECTION_FAILURE_CAUSE_CODE";
     public static final String EXTRA_HANDLED_BY_CARRIER_APP
@@ -101,6 +100,7 @@
     protected Context mContext;
     protected long mMessageId;
     protected int mLastConnectionFailure;
+    private MmsStats mMmsStats;
 
     class MonitorTelephonyCallback extends TelephonyCallback implements
             TelephonyCallback.PreciseDataConnectionStateListener {
@@ -121,13 +121,14 @@
     }
 
     public MmsRequest(RequestManager requestManager, int subId, String creator,
-            Bundle mmsConfig, Context context, long messageId) {
+            Bundle mmsConfig, Context context, long messageId, MmsStats mmsStats) {
         mRequestManager = requestManager;
         mSubId = subId;
         mCreator = creator;
         mMmsConfig = mmsConfig;
         mContext = context;
         mMessageId = messageId;
+        mMmsStats = mmsStats;
     }
 
     public int getSubId() {
@@ -146,6 +147,7 @@
         int result = SmsManager.MMS_ERROR_UNSPECIFIED;
         int httpStatusCode = 0;
         byte[] response = null;
+        int retryId = 0;
         // TODO: add mms data channel check back to fast fail if no way to send mms,
         // when telephony provides such API.
         if (!prepareForHttpRequest()) { // Prepare request, like reading pdu data from user
@@ -154,7 +156,7 @@
         } else { // Execute
             long retryDelaySecs = 2;
             // Try multiple times of MMS HTTP request, depending on the error.
-            for (int i = 0; i < RETRY_TIMES; i++) {
+            for (retryId = 0; retryId < RETRY_TIMES; retryId++) {
                 httpStatusCode = 0; // Clear for retry.
                 MonitorTelephonyCallback connectionStateCallback = new MonitorTelephonyCallback();
                 try {
@@ -212,7 +214,8 @@
                 retryDelaySecs <<= 1;
             }
         }
-        processResult(context, result, response, httpStatusCode, /* handledByCarrierApp= */ false);
+        processResult(context, result, response, httpStatusCode, /* handledByCarrierApp= */ false,
+                retryId);
     }
 
     private void listenToDataConnectionState(MonitorTelephonyCallback connectionStateCallback) {
@@ -240,6 +243,11 @@
      */
     public void processResult(Context context, int result, byte[] response, int httpStatusCode,
             boolean handledByCarrierApp) {
+        processResult(context, result, response, httpStatusCode, handledByCarrierApp, 0);
+    }
+
+    private void processResult(Context context, int result, byte[] response, int httpStatusCode,
+            boolean handledByCarrierApp, int retryId) {
         final Uri messageUri = persistIfRequired(context, result, response);
 
         final String requestId = this.getRequestId();
@@ -276,6 +284,7 @@
                 }
                 reportPossibleAnomaly(result, httpStatusCode);
                 pendingIntent.send(context, result, fillIn);
+                mMmsStats.addAtomToStorage(result, retryId, handledByCarrierApp);
             } catch (PendingIntent.CanceledException e) {
                 LogUtil.e(requestId, "Sending pending intent canceled", e);
             }
@@ -314,8 +323,9 @@
     private UUID generateUUID(int result, int httpStatusCode) {
         long lresult = result;
         long lhttpStatusCode = httpStatusCode;
-        return new UUID(mAnomalyUUID.getMostSignificantBits(),
-                mAnomalyUUID.getLeastSignificantBits() + ((lhttpStatusCode << 32) + lresult));
+        return new UUID(MmsConstants.MMS_ANOMALY_UUID.getMostSignificantBits(),
+                MmsConstants.MMS_ANOMALY_UUID.getLeastSignificantBits()
+                        + ((lhttpStatusCode << 32) + lresult));
     }
 
     private boolean isPoorSignal() {
diff --git a/src/com/android/mms/service/MmsService.java b/src/com/android/mms/service/MmsService.java
index d227b3c..ee88f09 100644
--- a/src/com/android/mms/service/MmsService.java
+++ b/src/com/android/mms/service/MmsService.java
@@ -41,6 +41,7 @@
 import android.provider.Telephony;
 import android.security.NetworkSecurityPolicy;
 import android.service.carrier.CarrierMessagingService;
+import android.telephony.AnomalyReporter;
 import android.telephony.SmsManager;
 import android.telephony.SubscriptionInfo;
 import android.telephony.SubscriptionManager;
@@ -51,6 +52,8 @@
 import android.util.SparseArray;
 
 import com.android.internal.telephony.IMms;
+import com.android.mms.service.metrics.MmsMetricsCollector;
+import com.android.mms.service.metrics.MmsStats;
 
 import com.google.android.mms.MmsException;
 import com.google.android.mms.pdu.DeliveryInd;
@@ -138,6 +141,8 @@
     // 1: download queue
     private final ExecutorService[] mRunningRequestExecutors = new ExecutorService[2];
 
+    private static MmsMetricsCollector mMmsMetricsCollector;
+
     private MmsNetworkManager getNetworkManager(int subId) {
         synchronized (mNetworkManagerCache) {
             MmsNetworkManager manager = mNetworkManagerCache.get(subId);
@@ -179,6 +184,8 @@
         List<String> carrierPackages = telephonyManager.getCarrierPackageNamesForIntent(intent);
 
         if (carrierPackages == null || carrierPackages.size() != 1) {
+            LogUtil.d("getCarrierMessagingServicePackageIfExists - multiple ("
+                    + carrierPackages.size() + ") carrier apps installed, not using any.");
             return null;
         } else {
             return carrierPackages.get(0);
@@ -211,19 +218,24 @@
             LogUtil.d("sendMessage " + formatCrossStackMessageId(messageId));
             enforceSystemUid();
 
+            MmsStats mmsStats = new MmsStats(MmsService.this,
+                    mMmsMetricsCollector.getAtomsStorage(), subId, getTelephonyManager(subId),
+                    callingPkg, false);
+
             // Make sure the subId is correct
             if (!SubscriptionManager.isValidSubscriptionId(subId)) {
                 LogUtil.e("Invalid subId " + subId);
-                sendErrorInPendingIntent(sentIntent, SmsManager.MMS_ERROR_INVALID_SUBSCRIPTION_ID);
+                handleError(sentIntent, SmsManager.MMS_ERROR_INVALID_SUBSCRIPTION_ID, mmsStats);
                 return;
             }
             if (subId == SubscriptionManager.DEFAULT_SUBSCRIPTION_ID) {
                 subId = SubscriptionManager.getDefaultSmsSubscriptionId();
+                mmsStats.updateSubId(subId, getTelephonyManager(subId));
             }
 
             // Make sure the subId is active
             if (!isActiveSubId(subId)) {
-                sendErrorInPendingIntent(sentIntent, SmsManager.MMS_ERROR_INACTIVE_SUBSCRIPTION);
+                handleError(sentIntent, SmsManager.MMS_ERROR_INACTIVE_SUBSCRIPTION, mmsStats);
                 return;
             }
 
@@ -231,7 +243,7 @@
             Bundle mmsConfig = loadMmsConfig(subId);
             if (mmsConfig == null) {
                 LogUtil.e("MMS config is not loaded yet for subId " + subId);
-                sendErrorInPendingIntent(sentIntent, SmsManager.MMS_ERROR_CONFIGURATION_ERROR);
+                handleError(sentIntent, SmsManager.MMS_ERROR_CONFIGURATION_ERROR, mmsStats);
                 return;
             }
 
@@ -243,19 +255,21 @@
             // Make sure MMS is enabled
             if (!mmsConfig.getBoolean(SmsManager.MMS_CONFIG_MMS_ENABLED)) {
                 LogUtil.e("MMS is not enabled for subId " + subId);
-                sendErrorInPendingIntent(sentIntent, SmsManager.MMS_ERROR_CONFIGURATION_ERROR);
+                handleError(sentIntent, SmsManager.MMS_ERROR_CONFIGURATION_ERROR, mmsStats);
                 return;
             }
 
             final SendRequest request = new SendRequest(MmsService.this, subId, contentUri,
-                    locationUrl, sentIntent, callingPkg, mmsConfig, MmsService.this, messageId);
+                    locationUrl, sentIntent, callingPkg, mmsConfig, MmsService.this,
+                    messageId, mmsStats);
 
             final String carrierMessagingServicePackage =
                     getCarrierMessagingServicePackageIfExists(subId);
 
             if (carrierMessagingServicePackage != null) {
-                LogUtil.d(request.toString(), "sending message by carrier app "
-                        + formatCrossStackMessageId(messageId));
+                LogUtil.d(request.toString(), "sending message by carrier app: "
+                        + carrierMessagingServicePackage
+                        + " " + formatCrossStackMessageId(messageId));
                 request.trySendingByCarrierApp(MmsService.this, carrierMessagingServicePackage);
                 return;
             }
@@ -269,7 +283,7 @@
                 // AcknowledgeInd and NotifyRespInd are parts of downloading sequence.
                 // TODO: Should consider ReadRecInd(Read Report)?
                 sendSettingsIntentForFailedMms(!isRawPduSendReq(contentUri), subId);
-                sendErrorInPendingIntent(sentIntent, SmsManager.MMS_ERROR_NO_DATA_NETWORK);
+                handleError(sentIntent, SmsManager.MMS_ERROR_NO_DATA_NETWORK, mmsStats);
                 return;
             }
 
@@ -288,22 +302,27 @@
 
             enforceSystemUid();
 
+            MmsStats mmsStats = new MmsStats(MmsService.this,
+                    mMmsMetricsCollector.getAtomsStorage(), subId, getTelephonyManager(subId),
+                    callingPkg, true);
+
             // Make sure the subId is correct
             if (!SubscriptionManager.isValidSubscriptionId(subId)) {
                 LogUtil.e("Invalid subId " + subId);
-                sendErrorInPendingIntent(downloadedIntent,
-                        SmsManager.MMS_ERROR_INVALID_SUBSCRIPTION_ID);
+                handleError(downloadedIntent, SmsManager.MMS_ERROR_INVALID_SUBSCRIPTION_ID,
+                        mmsStats);
                 return;
             }
             if (subId == SubscriptionManager.DEFAULT_SUBSCRIPTION_ID) {
                 subId = SubscriptionManager.getDefaultSmsSubscriptionId();
+                mmsStats.updateSubId(subId, getTelephonyManager(subId));
             }
 
             if (!isActiveSubId(subId)) {
                 List<SubscriptionInfo> activeSubList = getActiveSubscriptionsInGroup(subId);
                 if (activeSubList.isEmpty()) {
-                    sendErrorInPendingIntent(downloadedIntent,
-                            SmsManager.MMS_ERROR_INACTIVE_SUBSCRIPTION);
+                    handleError(downloadedIntent, SmsManager.MMS_ERROR_INACTIVE_SUBSCRIPTION,
+                            mmsStats);
                     return;
                 }
 
@@ -317,13 +336,13 @@
                     }
                 }
             }
+            mmsStats.updateSubId(subId, getTelephonyManager(subId));
 
             // Load MMS config
             Bundle mmsConfig = loadMmsConfig(subId);
             if (mmsConfig == null) {
                 LogUtil.e("MMS config is not loaded yet for subId " + subId);
-                sendErrorInPendingIntent(
-                        downloadedIntent, SmsManager.MMS_ERROR_CONFIGURATION_ERROR);
+                handleError(downloadedIntent, SmsManager.MMS_ERROR_CONFIGURATION_ERROR, mmsStats);
                 return;
             }
 
@@ -335,21 +354,21 @@
             // Make sure MMS is enabled
             if (!mmsConfig.getBoolean(SmsManager.MMS_CONFIG_MMS_ENABLED)) {
                 LogUtil.e("MMS is not enabled for subId " + subId);
-                sendErrorInPendingIntent(
-                        downloadedIntent, SmsManager.MMS_ERROR_CONFIGURATION_ERROR);
+                handleError(downloadedIntent, SmsManager.MMS_ERROR_CONFIGURATION_ERROR, mmsStats);
                 return;
             }
 
             final DownloadRequest request = new DownloadRequest(MmsService.this, subId, locationUrl,
                     contentUri, downloadedIntent, callingPkg, mmsConfig, MmsService.this,
-                    messageId);
+                    messageId, mmsStats);
 
             final String carrierMessagingServicePackage =
                     getCarrierMessagingServicePackageIfExists(subId);
 
             if (carrierMessagingServicePackage != null) {
-                LogUtil.d(request.toString(), "downloading message by carrier app "
-                        + formatCrossStackMessageId(messageId));
+                LogUtil.d(request.toString(), "downloading message by carrier app: "
+                        + carrierMessagingServicePackage
+                        + " " + formatCrossStackMessageId(messageId));
                 request.tryDownloadingByCarrierApp(MmsService.this, carrierMessagingServicePackage);
                 return;
             }
@@ -357,7 +376,7 @@
             // Make sure subId has MMS data
             if (!getTelephonyManager(subId).isDataEnabledForApn(ApnSetting.TYPE_MMS)) {
                 sendSettingsIntentForFailedMms(/*isIncoming=*/ true, subId);
-                sendErrorInPendingIntent(downloadedIntent, SmsManager.MMS_ERROR_DATA_DISABLED);
+                handleError(downloadedIntent, SmsManager.MMS_ERROR_DATA_DISABLED, mmsStats);
                 return;
             }
 
@@ -577,6 +596,14 @@
             }
             return false;
         }
+
+        private void handleError(@Nullable PendingIntent pendingIntent, int resultCode,
+                MmsStats mmsStats) {
+            sendErrorInPendingIntent(pendingIntent, resultCode);
+            mmsStats.addAtomToStorage(resultCode);
+            String message = "MMS failed" + " with error " + resultCode;
+            AnomalyReporter.reportAnomaly(MmsConstants.MMS_ANOMALY_UUID, message);
+        }
     };
 
     @Override
@@ -703,6 +730,9 @@
 
         NetworkSecurityPolicy.getInstance().setCleartextTrafficPermitted(true);
 
+        // Registers statsd pullers
+        mMmsMetricsCollector = new MmsMetricsCollector(this);
+
         // Initialize running request state
         for (int i = 0; i < mRunningRequestExecutors.length; i++) {
             mRunningRequestExecutors[i] = Executors.newFixedThreadPool(THREAD_POOL_SIZE);
diff --git a/src/com/android/mms/service/SendRequest.java b/src/com/android/mms/service/SendRequest.java
index 67c368d..4f97d84 100644
--- a/src/com/android/mms/service/SendRequest.java
+++ b/src/com/android/mms/service/SendRequest.java
@@ -25,17 +25,21 @@
 import android.os.AsyncTask;
 import android.os.Binder;
 import android.os.Bundle;
+import android.os.UserHandle;
 import android.provider.BlockedNumberContract;
 import android.provider.Telephony;
 import android.service.carrier.CarrierMessagingService;
 import android.service.carrier.CarrierMessagingServiceWrapper;
 import android.telephony.SmsManager;
+import android.telephony.SubscriptionManager;
 import android.telephony.TelephonyManager;
 import android.text.TextUtils;
 
 import com.android.internal.telephony.SmsApplication;
 import com.android.internal.telephony.SmsNumberUtils;
 import com.android.mms.service.exception.MmsHttpException;
+import com.android.mms.service.metrics.MmsStats;
+
 import com.google.android.mms.MmsException;
 import com.google.android.mms.pdu.EncodedStringValue;
 import com.google.android.mms.pdu.GenericPdu;
@@ -58,8 +62,8 @@
 
     public SendRequest(RequestManager manager, int subId, Uri contentUri, String locationUrl,
             PendingIntent sentIntent, String creator, Bundle configOverrides, Context context,
-            long messageId) {
-        super(manager, subId, creator, configOverrides, context, messageId);
+            long messageId, MmsStats mmsStats) {
+        super(manager, subId, creator, configOverrides, context, messageId, mmsStats);
         mPduUri = contentUri;
         mPduData = null;
         mLocationUrl = locationUrl;
@@ -172,10 +176,22 @@
     @Override
     protected Uri persistIfRequired(Context context, int result, byte[] response) {
         final String requestId = getRequestId();
-        if (!SmsApplication.shouldWriteMessageForPackage(mCreator, context)) {
-            // Not required to persist
+
+        SubscriptionManager subManager = context.getSystemService(SubscriptionManager.class);
+        UserHandle userHandle = null;
+        long identity = Binder.clearCallingIdentity();
+        try {
+            if ((subManager != null) && (subManager.isActiveSubscriptionId(mSubId))) {
+                userHandle = subManager.getSubscriptionUserHandle(mSubId);
+            }
+        } finally {
+            Binder.restoreCallingIdentity(identity);
+        }
+
+        if (!SmsApplication.shouldWriteMessageForPackageAsUser(mCreator, context, userHandle)) {
             return null;
         }
+
         LogUtil.d(requestId, "persistIfRequired. "
                 + MmsService.formatCrossStackMessageId(mMessageId));
         if (mPduData == null) {
@@ -183,7 +199,7 @@
                     + MmsService.formatCrossStackMessageId(mMessageId));
             return null;
         }
-        final long identity = Binder.clearCallingIdentity();
+        identity = Binder.clearCallingIdentity();
         try {
             final boolean supportContentDisposition =
                     mMmsConfig.getBoolean(SmsManager.MMS_CONFIG_SUPPORT_MMS_CONTENT_DISPOSITION);
@@ -417,10 +433,12 @@
             if (mCarrierMessagingServiceWrapper.bindToCarrierMessagingService(
                     context, carrierMessagingServicePackage, Runnable::run,
                     () -> onServiceReady())) {
-                LogUtil.v("bindService() for carrier messaging service succeeded. "
+                LogUtil.v("bindService() for carrier messaging service: "
+                        + carrierMessagingServicePackage + " succeeded. "
                         + MmsService.formatCrossStackMessageId(mMessageId));
             } else {
-                LogUtil.e("bindService() for carrier messaging service failed. "
+                LogUtil.e("bindService() for carrier messaging service: "
+                        + carrierMessagingServicePackage + " failed. "
                         + MmsService.formatCrossStackMessageId(mMessageId));
                 carrierSendCompleteCallback.onSendMmsComplete(
                         CarrierMessagingService.SEND_STATUS_RETRY_ON_CARRIER_NETWORK,
diff --git a/src/com/android/mms/service/metrics/MmsMetricsCollector.java b/src/com/android/mms/service/metrics/MmsMetricsCollector.java
new file mode 100644
index 0000000..8da61ba
--- /dev/null
+++ b/src/com/android/mms/service/metrics/MmsMetricsCollector.java
@@ -0,0 +1,162 @@
+/*
+ * 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 android.app.StatsManager;
+import android.content.Context;
+import android.util.Log;
+import android.util.StatsEvent;
+
+import androidx.annotation.VisibleForTesting;
+
+import com.android.mms.IncomingMms;
+import com.android.mms.MmsStatsLog;
+import com.android.mms.OutgoingMms;
+import com.android.internal.util.ConcurrentUtils;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.Executor;
+
+/**
+ * Implements statsd pullers for Mms.
+ *
+ * <p>This class registers pullers to statsd, which will be called once a day to obtain mms
+ * statistics that cannot be sent to statsd in real time.
+ */
+public class MmsMetricsCollector implements StatsManager.StatsPullAtomCallback {
+    private static final String TAG = MmsMetricsCollector.class.getSimpleName();
+    /** Disables various restrictions to ease debugging during development. */
+    private static final boolean DBG = false; // STOPSHIP if true
+    private static final long MILLIS_PER_HOUR = Duration.ofHours(1).toMillis();
+    private static final long MILLIS_PER_SECOND = Duration.ofSeconds(1).toMillis();
+    /**
+     * Sets atom pull cool down to 23 hours to help enforcing privacy requirement.
+     *
+     * <p>Applies to certain atoms. The interval of 23 hours leaves some margin for pull operations
+     * that occur once a day.
+     */
+    private static final long MIN_COOLDOWN_MILLIS =
+            DBG ? 10L * MILLIS_PER_SECOND : 23L * MILLIS_PER_HOUR;
+    private final PersistMmsAtomsStorage mStorage;
+    private final StatsManager mStatsManager;
+
+
+    public MmsMetricsCollector(Context context) {
+        this(context, new PersistMmsAtomsStorage(context));
+    }
+
+    @VisibleForTesting
+    public MmsMetricsCollector(Context context, PersistMmsAtomsStorage storage) {
+        mStorage = storage;
+        mStatsManager = context.getSystemService(StatsManager.class);
+        if (mStatsManager != null) {
+            registerAtom(INCOMING_MMS);
+            registerAtom(OUTGOING_MMS);
+            Log.d(TAG, "[MmsMetricsCollector]: registered atoms");
+        } else {
+            Log.e(TAG, "[MmsMetricsCollector]: could not get StatsManager, "
+                    + "atoms not registered");
+        }
+    }
+
+    private static StatsEvent buildStatsEvent(IncomingMms mms) {
+        return MmsStatsLog.buildStatsEvent(
+                INCOMING_MMS,
+                mms.getRat(),
+                mms.getResult(),
+                mms.getRoaming(),
+                mms.getSimSlotIndex(),
+                mms.getIsMultiSim(),
+                mms.getIsEsim(),
+                mms.getCarrierId(),
+                mms.getAvgIntervalMillis(),
+                mms.getMmsCount(),
+                mms.getRetryId(),
+                mms.getHandledByCarrierApp(),
+                mms.getIsManagedProfile());
+    }
+
+    private static StatsEvent buildStatsEvent(OutgoingMms mms) {
+        return MmsStatsLog.buildStatsEvent(
+                OUTGOING_MMS,
+                mms.getRat(),
+                mms.getResult(),
+                mms.getRoaming(),
+                mms.getSimSlotIndex(),
+                mms.getIsMultiSim(),
+                mms.getIsEsim(),
+                mms.getCarrierId(),
+                mms.getAvgIntervalMillis(),
+                mms.getMmsCount(),
+                mms.getIsFromDefaultApp(),
+                mms.getRetryId(),
+                mms.getHandledByCarrierApp(),
+                mms.getIsManagedProfile());
+    }
+
+    @Override
+    public int onPullAtom(int atomTag, List<StatsEvent> data) {
+        switch (atomTag) {
+            case INCOMING_MMS:
+                return pullIncomingMms(data);
+            case OUTGOING_MMS:
+                return pullOutgoingMms(data);
+            default:
+                Log.e(TAG, String.format("unexpected atom ID %d", atomTag));
+                return StatsManager.PULL_SKIP;
+        }
+    }
+
+    private int pullIncomingMms(List<StatsEvent> data) {
+        List<IncomingMms> incomingMmsList = mStorage.getIncomingMms(MIN_COOLDOWN_MILLIS);
+        if (incomingMmsList != null) {
+            // MMS List is already shuffled when MMS were inserted.
+            incomingMmsList.forEach(mms -> data.add(buildStatsEvent(mms)));
+            return StatsManager.PULL_SUCCESS;
+        } else {
+            Log.w(TAG, "INCOMING_MMS pull too frequent, skipping");
+            return StatsManager.PULL_SKIP;
+        }
+    }
+
+    private int pullOutgoingMms(List<StatsEvent> data) {
+        List<OutgoingMms> outgoingMmsList = mStorage.getOutgoingMms(MIN_COOLDOWN_MILLIS);
+        if (outgoingMmsList != null) {
+            // MMS List is already shuffled when MMS were inserted.
+            outgoingMmsList.forEach(mms -> data.add(buildStatsEvent(mms)));
+            return StatsManager.PULL_SUCCESS;
+        } else {
+            Log.w(TAG, "OUTGOING_MMS pull too frequent, skipping");
+            return StatsManager.PULL_SKIP;
+        }
+    }
+
+    /** Registers a pulled atom ID {@code atomId}. */
+    private void registerAtom(int atomId) {
+        mStatsManager.setPullAtomCallback(atomId, /* metadata= */ null,
+                ConcurrentUtils.DIRECT_EXECUTOR, this);
+    }
+
+    /** Returns the {@link PersistMmsAtomsStorage} backing the puller. */
+    public PersistMmsAtomsStorage getAtomsStorage() {
+        return mStorage;
+    }
+}
diff --git a/src/com/android/mms/service/metrics/MmsStats.java b/src/com/android/mms/service/metrics/MmsStats.java
new file mode 100644
index 0000000..7e98b0b
--- /dev/null
+++ b/src/com/android/mms/service/metrics/MmsStats.java
@@ -0,0 +1,232 @@
+/*
+ * 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__RESULT__MMS_RESULT_ERROR_UNSPECIFIED;
+import static com.android.mms.MmsStatsLog.INCOMING_MMS__RESULT__MMS_RESULT_SUCCESS;
+import static com.android.mms.MmsStatsLog.OUTGOING_MMS__RESULT__MMS_RESULT_ERROR_UNSPECIFIED;
+import static com.android.mms.MmsStatsLog.OUTGOING_MMS__RESULT__MMS_RESULT_SUCCESS;
+
+import android.app.Activity;
+import android.content.Context;
+import android.os.Binder;
+import android.os.SystemClock;
+import android.os.UserHandle;
+import android.os.UserManager;
+import android.telephony.ServiceState;
+import android.telephony.SmsManager;
+import android.telephony.SubscriptionInfo;
+import android.telephony.SubscriptionManager;
+import android.telephony.TelephonyManager;
+import android.telephony.UiccCardInfo;
+
+import com.android.internal.telephony.SmsApplication;
+import com.android.mms.IncomingMms;
+import com.android.mms.OutgoingMms;
+
+import java.util.List;
+
+/** Collects mms events for the pulled atom. */
+public class MmsStats {
+    private static final String TAG = MmsStats.class.getSimpleName();
+
+    private final Context mContext;
+    private final PersistMmsAtomsStorage mPersistMmsAtomsStorage;
+    private final String mCallingPkg;
+    private final boolean mIsIncomingMms;
+    private final long mTimestamp;
+    private int mSubId;
+    private TelephonyManager mTelephonyManager;
+
+    public MmsStats(Context context, PersistMmsAtomsStorage persistMmsAtomsStorage, int subId,
+            TelephonyManager telephonyManager, String callingPkg, boolean isIncomingMms) {
+        mContext = context;
+        mPersistMmsAtomsStorage = persistMmsAtomsStorage;
+        mSubId = subId;
+        mTelephonyManager = telephonyManager;
+        mCallingPkg = callingPkg;
+        mIsIncomingMms = isIncomingMms;
+        mTimestamp = SystemClock.elapsedRealtime();
+    }
+
+    /** Updates subId and corresponding telephonyManager. */
+    public void updateSubId(int subId, TelephonyManager telephonyManager) {
+        mSubId = subId;
+        mTelephonyManager = telephonyManager;
+    }
+
+    /** Adds incoming or outgoing mms atom to storage. */
+    public void addAtomToStorage(int result) {
+        addAtomToStorage(result, 0, false);
+    }
+
+    /** Adds incoming or outgoing mms atom to storage. */
+    public void addAtomToStorage(int result, int retryId, boolean handledByCarrierApp) {
+        long identity = Binder.clearCallingIdentity();
+        try {
+            if (mIsIncomingMms) {
+                onIncomingMms(result, retryId, handledByCarrierApp);
+            } else {
+                onOutgoingMms(result, retryId, handledByCarrierApp);
+            }
+        } finally {
+            Binder.restoreCallingIdentity(identity);
+        }
+    }
+
+    /** Creates a new atom when MMS is received. */
+    private void onIncomingMms(int result, int retryId, boolean handledByCarrierApp) {
+        IncomingMms incomingMms = IncomingMms.newBuilder()
+                .setRat(getDataNetworkType())
+                .setResult(getIncomingMmsResult(result))
+                .setRoaming(getDataRoamingType())
+                .setSimSlotIndex(getSlotIndex())
+                .setIsMultiSim(getIsMultiSim())
+                .setIsEsim(getIsEuicc())
+                .setCarrierId(getSimCarrierId())
+                .setAvgIntervalMillis(getInterval())
+                .setMmsCount(1)
+                .setRetryId(retryId)
+                .setHandledByCarrierApp(handledByCarrierApp)
+                .setIsManagedProfile(isManagedProfile())
+                .build();
+        mPersistMmsAtomsStorage.addIncomingMms(incomingMms);
+    }
+
+    /** Creates a new atom when MMS is sent. */
+    private void onOutgoingMms(int result, int retryId, boolean handledByCarrierApp) {
+        OutgoingMms outgoingMms = OutgoingMms.newBuilder()
+                .setRat(getDataNetworkType())
+                .setResult(getOutgoingMmsResult(result))
+                .setRoaming(getDataRoamingType())
+                .setSimSlotIndex(getSlotIndex())
+                .setIsMultiSim(getIsMultiSim())
+                .setIsEsim(getIsEuicc())
+                .setCarrierId(getSimCarrierId())
+                .setAvgIntervalMillis(getInterval())
+                .setMmsCount(1)
+                .setIsFromDefaultApp(isDefaultMmsApp())
+                .setRetryId(retryId)
+                .setHandledByCarrierApp(handledByCarrierApp)
+                .setIsManagedProfile(isManagedProfile())
+                .build();
+        mPersistMmsAtomsStorage.addOutgoingMms(outgoingMms);
+    }
+
+    /** @return {@code true} if this SIM is dedicated to work profile */
+    private boolean isManagedProfile() {
+        SubscriptionManager subManager = mContext.getSystemService(SubscriptionManager.class);
+        if (subManager == null || !subManager.isActiveSubscriptionId(mSubId)) return false;
+        UserHandle userHandle = subManager.getSubscriptionUserHandle(mSubId);
+        UserManager userManager = mContext.getSystemService(UserManager.class);
+        if (userHandle == null || userManager == null) return false;
+        return userManager.isManagedProfile(userHandle.getIdentifier());
+    }
+
+    /** Returns data network type of current subscription. */
+    private int getDataNetworkType() {
+        return mTelephonyManager.getDataNetworkType();
+    }
+
+    /** Returns incoming mms result. */
+    private int getIncomingMmsResult(int result) {
+        switch (result) {
+            case SmsManager.MMS_ERROR_UNSPECIFIED:
+                // SmsManager.MMS_ERROR_UNSPECIFIED(1) -> MMS_RESULT_ERROR_UNSPECIFIED(0)
+                return INCOMING_MMS__RESULT__MMS_RESULT_ERROR_UNSPECIFIED;
+            case Activity.RESULT_OK:
+                // Activity.RESULT_OK -> MMS_RESULT_SUCCESS(1)
+                return INCOMING_MMS__RESULT__MMS_RESULT_SUCCESS;
+            default:
+                // Int value of other SmsManager.MMS_ERROR matches MMS_RESULT_ERROR
+                return result;
+        }
+    }
+
+    /** Returns outgoing mms result. */
+    private int getOutgoingMmsResult(int result) {
+        switch (result) {
+            case SmsManager.MMS_ERROR_UNSPECIFIED:
+                // SmsManager.MMS_ERROR_UNSPECIFIED(1) -> MMS_RESULT_ERROR_UNSPECIFIED(0)
+                return OUTGOING_MMS__RESULT__MMS_RESULT_ERROR_UNSPECIFIED;
+            case Activity.RESULT_OK:
+                // Activity.RESULT_OK -> MMS_RESULT_SUCCESS(1)
+                return OUTGOING_MMS__RESULT__MMS_RESULT_SUCCESS;
+            default:
+                // Int value of other SmsManager.MMS_ERROR matches MMS_RESULT_ERROR
+                return result;
+        }
+    }
+
+    /** Returns data network roaming type of current subscription. */
+    private int getDataRoamingType() {
+        ServiceState serviceState = mTelephonyManager.getServiceState();
+        return (serviceState != null) ? serviceState.getDataRoamingType() :
+                ServiceState.ROAMING_TYPE_NOT_ROAMING;
+    }
+
+    /** Returns slot index associated with the subscription. */
+    private int getSlotIndex() {
+        return SubscriptionManager.getSlotIndex(mSubId);
+    }
+
+    /** Returns whether the device has multiple active SIM profiles. */
+    private boolean getIsMultiSim() {
+        SubscriptionManager subManager = mContext.getSystemService(SubscriptionManager.class);
+        if(subManager == null) {
+            return false;
+        }
+
+        List<SubscriptionInfo> activeSubscriptionInfo = subManager.getActiveSubscriptionInfoList();
+        return (activeSubscriptionInfo.size() > 1);
+    }
+
+    /** Returns if current subscription is embedded subscription. */
+    private boolean getIsEuicc() {
+        List<UiccCardInfo> uiccCardInfoList = mTelephonyManager.getUiccCardsInfo();
+        for (UiccCardInfo card : uiccCardInfoList) {
+            if (card.getPhysicalSlotIndex() == getSlotIndex()) {
+                return card.isEuicc();
+            }
+        }
+        return false;
+    }
+
+    /** Returns carrier id of the current subscription used by MMS. */
+    private int getSimCarrierId() {
+        return mTelephonyManager.getSimCarrierId();
+    }
+
+    /** Returns if the MMS was originated from the default MMS application. */
+    private boolean isDefaultMmsApp() {
+        UserHandle userHandle = null;
+        SubscriptionManager subManager = mContext.getSystemService(SubscriptionManager.class);
+        if ((subManager != null) && (subManager.isActiveSubscriptionId(mSubId))) {
+            userHandle = subManager.getSubscriptionUserHandle(mSubId);
+        }
+        return SmsApplication.isDefaultMmsApplicationAsUser(mContext, mCallingPkg, userHandle);
+    }
+
+    /**
+     * Returns the interval in milliseconds between sending/receiving MMS message and current time.
+     * Calculates the time taken to send message to the network
+     * or download message from the network.
+     */
+    private long getInterval() {
+        return (SystemClock.elapsedRealtime() - mTimestamp);
+    }
+}
diff --git a/src/com/android/mms/service/metrics/PersistMmsAtomsStorage.java b/src/com/android/mms/service/metrics/PersistMmsAtomsStorage.java
new file mode 100644
index 0000000..4e9ffc7
--- /dev/null
+++ b/src/com/android/mms/service/metrics/PersistMmsAtomsStorage.java
@@ -0,0 +1,370 @@
+/*
+ * 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 android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.Build;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.util.Log;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+
+import com.android.mms.IncomingMms;
+import com.android.mms.OutgoingMms;
+import com.android.mms.PersistMmsAtoms;
+
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
+import java.security.SecureRandom;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public class PersistMmsAtomsStorage {
+    private static final String TAG = PersistMmsAtomsStorage.class.getSimpleName();
+
+    /** Name of the file where cached statistics are saved to. */
+    private static final String FILENAME = "persist_mms_atoms.pb";
+
+    /** Delay to store atoms to persistent storage to bundle multiple operations together. */
+    private static final int SAVE_TO_FILE_DELAY_FOR_UPDATE_MILLIS = 30000;
+
+    /**
+     * Delay to store atoms to persistent storage during pulls to avoid unnecessary operations.
+     *
+     * <p>This delay should be short to avoid duplicating atoms or losing pull timestamp in case of
+     * crash or power loss.
+     */
+    private static final int SAVE_TO_FILE_DELAY_FOR_GET_MILLIS = 500;
+    private static final SecureRandom sRandom = new SecureRandom();
+    /**
+     * Maximum number of MMS to store between pulls.
+     * Incoming MMS and outgoing MMS are counted separately.
+     */
+    private final int mMaxNumMms;
+    private final Context mContext;
+    private final Handler mHandler;
+    private final HandlerThread mHandlerThread;
+    /** Stores persist atoms and persist states of the puller. */
+    @VisibleForTesting
+    protected PersistMmsAtoms mPersistMmsAtoms;
+    private final Runnable mSaveRunnable =
+            new Runnable() {
+                @Override
+                public void run() {
+                    saveAtomsToFileNow();
+                }
+            };
+    /** Whether atoms should be saved immediately, skipping the delay. */
+    @VisibleForTesting
+    protected boolean mSaveImmediately;
+
+    public PersistMmsAtomsStorage(Context context) {
+        mContext = context;
+
+        if (mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_RAM_LOW)) {
+            Log.i(TAG, "[PersistMmsAtomsStorage]: Low RAM device");
+            mMaxNumMms = 5;
+        } else {
+            mMaxNumMms = 25;
+        }
+        mPersistMmsAtoms = loadAtomsFromFile();
+        mHandlerThread = new HandlerThread("PersistMmsAtomsThread");
+        mHandlerThread.start();
+        mHandler = new Handler(mHandlerThread.getLooper());
+        mSaveImmediately = false;
+    }
+
+    /** Loads {@link  PersistMmsAtoms} from a file in private storage. */
+    private PersistMmsAtoms loadAtomsFromFile() {
+        try {
+            PersistMmsAtoms atoms = PersistMmsAtoms.parseFrom(
+                    Files.readAllBytes(mContext.getFileStreamPath(FILENAME).toPath()));
+
+            // Start from scratch if build changes, since mixing atoms from different builds could
+            // produce strange results.
+            if (!Build.FINGERPRINT.equals(atoms.getBuildFingerprint())) {
+                Log.d(TAG, "[loadAtomsFromFile]: Build changed");
+                return makeNewPersistMmsAtoms();
+            }
+            // check all the fields in case of situations such as OTA or crash during saving.
+            List<IncomingMms> incomingMms = sanitizeAtoms(atoms.getIncomingMmsList(), mMaxNumMms);
+            List<OutgoingMms> outgoingMms = sanitizeAtoms(atoms.getOutgoingMmsList(), mMaxNumMms);
+            long incomingMmsPullTimestamp = sanitizeTimestamp(
+                    atoms.getIncomingMmsPullTimestampMillis());
+            long outgoingMmsPullTimestamp = sanitizeTimestamp(
+                    atoms.getOutgoingMmsPullTimestampMillis());
+
+            // Rebuild atoms after sanitizing.
+            atoms = atoms.toBuilder()
+                    .clearIncomingMms()
+                    .clearOutgoingMms()
+                    .addAllIncomingMms(incomingMms)
+                    .addAllOutgoingMms(outgoingMms)
+                    .setIncomingMmsPullTimestampMillis(incomingMmsPullTimestamp)
+                    .setOutgoingMmsPullTimestampMillis(outgoingMmsPullTimestamp)
+                    .build();
+            return atoms;
+        } catch (NoSuchFileException e) {
+            Log.e(TAG, "[loadAtomsFromFile]: PersistMmsAtoms file not found");
+        } catch (IOException | NullPointerException e) {
+            Log.e(TAG, "[loadAtomsFromFile]: cannot load/parse PersistMmsAtoms", e);
+        }
+        return makeNewPersistMmsAtoms();
+    }
+
+    /** Adds an IncomingMms to the storage. */
+    public synchronized void addIncomingMms(IncomingMms mms) {
+        int existingMmsIndex = findIndex(mms);
+        if (existingMmsIndex != -1) {
+            // Update mmsCount and avgIntervalMillis of existingMms.
+            IncomingMms existingMms = mPersistMmsAtoms.getIncomingMms(existingMmsIndex);
+            long updatedMmsCount = existingMms.getMmsCount() + 1;
+            long updatedAvgIntervalMillis =
+                    (((existingMms.getAvgIntervalMillis() * existingMms.getMmsCount())
+                            + mms.getAvgIntervalMillis()) / updatedMmsCount);
+            existingMms = existingMms.toBuilder()
+                    .setMmsCount(updatedMmsCount)
+                    .setAvgIntervalMillis(updatedAvgIntervalMillis)
+                    .build();
+
+            mPersistMmsAtoms = mPersistMmsAtoms.toBuilder()
+                    .setIncomingMms(existingMmsIndex, existingMms)
+                    .build();
+        } else {
+            // Insert new mms at random place.
+            List<IncomingMms> incomingMmsList = insertAtRandomPlace(
+                    mPersistMmsAtoms.getIncomingMmsList(), mms, mMaxNumMms);
+            mPersistMmsAtoms = mPersistMmsAtoms.toBuilder()
+                    .clearIncomingMms()
+                    .addAllIncomingMms(incomingMmsList)
+                    .build();
+        }
+        saveAtomsToFile(SAVE_TO_FILE_DELAY_FOR_UPDATE_MILLIS);
+    }
+
+    /** Adds an OutgoingMms to the storage. */
+    public synchronized void addOutgoingMms(OutgoingMms mms) {
+        int existingMmsIndex = findIndex(mms);
+        if (existingMmsIndex != -1) {
+            // Update mmsCount and avgIntervalMillis of existingMms.
+            OutgoingMms existingMms = mPersistMmsAtoms.getOutgoingMms(existingMmsIndex);
+            long updatedMmsCount = existingMms.getMmsCount() + 1;
+            long updatedAvgIntervalMillis =
+                    (((existingMms.getAvgIntervalMillis() * existingMms.getMmsCount())
+                            + mms.getAvgIntervalMillis()) / updatedMmsCount);
+            existingMms = existingMms.toBuilder()
+                    .setMmsCount(updatedMmsCount)
+                    .setAvgIntervalMillis(updatedAvgIntervalMillis)
+                    .build();
+
+            mPersistMmsAtoms = mPersistMmsAtoms.toBuilder()
+                    .setOutgoingMms(existingMmsIndex, existingMms)
+                    .build();
+        } else {
+            // Insert new mms at random place.
+            List<OutgoingMms> outgoingMmsList = insertAtRandomPlace(
+                    mPersistMmsAtoms.getOutgoingMmsList(), mms, mMaxNumMms);
+            mPersistMmsAtoms = mPersistMmsAtoms.toBuilder()
+                    .clearOutgoingMms()
+                    .addAllOutgoingMms(outgoingMmsList)
+                    .build();
+        }
+        saveAtomsToFile(SAVE_TO_FILE_DELAY_FOR_UPDATE_MILLIS);
+    }
+
+    /**
+     * Returns and clears the IncomingMms if last pulled longer than {@code minIntervalMillis} ago,
+     * otherwise returns {@code null}.
+     */
+    @Nullable
+    public synchronized List<IncomingMms> getIncomingMms(long minIntervalMillis) {
+        if ((getWallTimeMillis() - mPersistMmsAtoms.getIncomingMmsPullTimestampMillis())
+                > minIntervalMillis) {
+            List<IncomingMms> previousIncomingMmsList = mPersistMmsAtoms.getIncomingMmsList();
+            mPersistMmsAtoms = mPersistMmsAtoms.toBuilder()
+                    .setIncomingMmsPullTimestampMillis(getWallTimeMillis())
+                    .clearIncomingMms()
+                    .build();
+            saveAtomsToFile(SAVE_TO_FILE_DELAY_FOR_GET_MILLIS);
+            return previousIncomingMmsList;
+        } else {
+            return null;
+        }
+    }
+
+    /**
+     * Returns and clears the OutgoingMms if last pulled longer than {@code minIntervalMillis} ago,
+     * otherwise returns {@code null}.
+     */
+    @Nullable
+    public synchronized List<OutgoingMms> getOutgoingMms(long minIntervalMillis) {
+        if ((getWallTimeMillis() - mPersistMmsAtoms.getOutgoingMmsPullTimestampMillis())
+                > minIntervalMillis) {
+            List<OutgoingMms> previousOutgoingMmsList = mPersistMmsAtoms.getOutgoingMmsList();
+            mPersistMmsAtoms = mPersistMmsAtoms.toBuilder()
+                    .setOutgoingMmsPullTimestampMillis(getWallTimeMillis())
+                    .clearOutgoingMms()
+                    .build();
+            saveAtomsToFile(SAVE_TO_FILE_DELAY_FOR_GET_MILLIS);
+            return previousOutgoingMmsList;
+        } else {
+            return null;
+        }
+    }
+
+    /** Saves a pending {@link PersistMmsAtoms} to a file in private storage immediately. */
+    public void flushAtoms() {
+        if (mHandler.hasCallbacks(mSaveRunnable)) {
+            mHandler.removeCallbacks(mSaveRunnable);
+            saveAtomsToFileNow();
+        }
+    }
+
+    /** Returns an empty PersistMmsAtoms with pull timestamp set to current time. */
+    private PersistMmsAtoms makeNewPersistMmsAtoms() {
+        // allow pulling only after some time so data are sufficiently aggregated.
+        long currentTime = getWallTimeMillis();
+        PersistMmsAtoms atoms = PersistMmsAtoms.newBuilder()
+                .setBuildFingerprint(Build.FINGERPRINT)
+                .setIncomingMmsPullTimestampMillis(currentTime)
+                .setOutgoingMmsPullTimestampMillis(currentTime)
+                .build();
+        return atoms;
+    }
+
+    /**
+     * Posts message to save a copy of {@link PersistMmsAtoms} to a file after a delay.
+     *
+     * <p>The delay is introduced to avoid too frequent operations to disk, which would negatively
+     * impact the power consumption.
+     */
+    private void saveAtomsToFile(int delayMillis) {
+        if (delayMillis > 0 && !mSaveImmediately) {
+            mHandler.removeCallbacks(mSaveRunnable);
+            if (mHandler.postDelayed(mSaveRunnable, delayMillis)) {
+                return;
+            }
+        }
+        // In case of error posting the event or if delay is 0, save immediately.
+        saveAtomsToFileNow();
+    }
+
+    /** Saves a copy of {@link PersistMmsAtoms} to a file in private storage. */
+    private synchronized void saveAtomsToFileNow() {
+        try (FileOutputStream stream = mContext.openFileOutput(FILENAME, Context.MODE_PRIVATE)) {
+            stream.write(mPersistMmsAtoms.toByteArray());
+        } catch (IOException e) {
+            Log.e(TAG, "[saveAtomsToFileNow]: Cannot save PersistMmsAtoms", e);
+        }
+    }
+
+    /**
+     * Inserts a new element in a random position.
+     */
+    private static <T> List<T> insertAtRandomPlace(List<T> storage, T instance, int maxSize) {
+        final int storage_size = storage.size();
+        List<T> result = new ArrayList<>(storage);
+        if (storage_size == 0) {
+            result.add(instance);
+        } else if (storage_size == maxSize) {
+            // Index of the item suitable for eviction is chosen randomly when the array is full.
+            int insertAt = sRandom.nextInt(maxSize);
+            result.set(insertAt, instance);
+        } else {
+            // Insert at random place (by moving the item at the random place to the end).
+            int insertAt = sRandom.nextInt(storage_size);
+            result.add(result.get(insertAt));
+            result.set(insertAt, instance);
+        }
+        return result;
+    }
+
+    /**
+     * Returns IncomingMms atom index that has the same dimension values with the given one,
+     * or {@code -1} if it does not exist.
+     */
+    private int findIndex(IncomingMms key) {
+        for (int i = 0; i < mPersistMmsAtoms.getIncomingMmsCount(); i++) {
+            IncomingMms mms = mPersistMmsAtoms.getIncomingMms(i);
+            if (mms.getRat() == key.getRat()
+                    && mms.getResult() == key.getResult()
+                    && mms.getRoaming() == key.getRoaming()
+                    && mms.getSimSlotIndex() == key.getSimSlotIndex()
+                    && mms.getIsMultiSim() == key.getIsMultiSim()
+                    && mms.getIsEsim() == key.getIsEsim()
+                    && mms.getCarrierId() == key.getCarrierId()
+                    && mms.getRetryId() == key.getRetryId()
+                    && mms.getHandledByCarrierApp() == key.getHandledByCarrierApp()) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
+    /**
+     * Returns OutgoingMms atom index that has the same dimension values with the given one,
+     * or {@code -1} if it does not exist.
+     */
+    private int findIndex(OutgoingMms key) {
+        for (int i = 0; i < mPersistMmsAtoms.getOutgoingMmsCount(); i++) {
+            OutgoingMms mms = mPersistMmsAtoms.getOutgoingMms(i);
+            if (mms.getRat() == key.getRat()
+                    && mms.getResult() == key.getResult()
+                    && mms.getRoaming() == key.getRoaming()
+                    && mms.getSimSlotIndex() == key.getSimSlotIndex()
+                    && mms.getIsMultiSim() == key.getIsMultiSim()
+                    && mms.getIsEsim() == key.getIsEsim()
+                    && mms.getCarrierId() == key.getCarrierId()
+                    && mms.getIsFromDefaultApp() == key.getIsFromDefaultApp()
+                    && mms.getRetryId() == key.getRetryId()
+                    && mms.getHandledByCarrierApp() == key.getHandledByCarrierApp()) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
+    /** Sanitizes the loaded list of atoms to avoid null values. */
+    private <T> List<T> sanitizeAtoms(List<T> list) {
+        return list == null ? Collections.emptyList() : list;
+    }
+
+    /** Sanitizes the loaded list of atoms loaded to avoid null values and enforce max length. */
+    private <T> List<T> sanitizeAtoms(List<T> list, int maxSize) {
+        list = sanitizeAtoms(list);
+        if (list.size() > maxSize) {
+            return list.subList(0, maxSize);
+        }
+        return list;
+    }
+
+    /** Sanitizes the timestamp of the last pull loaded from persistent storage. */
+    private long sanitizeTimestamp(long timestamp) {
+        return timestamp <= 0L ? getWallTimeMillis() : timestamp;
+    }
+
+    @VisibleForTesting
+    protected long getWallTimeMillis() {
+        // Epoch time in UTC, preserved across reboots, but can be adjusted e.g. by the user or NTP.
+        return System.currentTimeMillis();
+    }
+}
\ No newline at end of file
diff --git a/tests/robotests/src/com/android/mms/service/MmsNetworkManagerTest.java b/tests/robotests/src/com/android/mms/service/MmsNetworkManagerTest.java
index c2d3e05..dff2bab 100644
--- a/tests/robotests/src/com/android/mms/service/MmsNetworkManagerTest.java
+++ b/tests/robotests/src/com/android/mms/service/MmsNetworkManagerTest.java
@@ -36,6 +36,8 @@
 import android.net.Network;
 import android.net.NetworkCapabilities;
 import android.net.NetworkInfo;
+import android.os.PersistableBundle;
+import android.telephony.CarrierConfigManager;
 
 import org.junit.Before;
 import org.junit.Test;
@@ -70,7 +72,8 @@
     @Mock Context mCtx;
     @Mock ConnectivityManager mCm;
     @Mock MmsNetworkManager.Dependencies mDeps;
-
+    @Mock CarrierConfigManager mCarrierConfigManager;
+    @Mock PersistableBundle mConfig;
 
     private MmsNetworkManager mMnm;
     private final ExecutorService mExecutor = Executors.newSingleThreadExecutor();
@@ -89,6 +92,8 @@
         doReturn(MMS_APN2).when(mNetworkInfo2).getExtraInfo();
         doReturn(NETWORK_ACQUIRE_TIMEOUT_MS).when(mDeps).getNetworkRequestTimeoutMillis();
         doReturn(NETWORK_ACQUIRE_TIMEOUT_MS).when(mDeps).getAdditionalNetworkAcquireTimeoutMillis();
+        doReturn(mCarrierConfigManager).when(mCtx).getSystemService(Context.CARRIER_CONFIG_SERVICE);
+        doReturn(mConfig).when(mCarrierConfigManager).getConfigForSubId(TEST_SUBID);
 
         mMnm = new MmsNetworkManager(mCtx, TEST_SUBID, mDeps);
     }
@@ -201,6 +206,25 @@
         assertEquals(null, mMnm.getApnName());
     }
 
+    @Test
+    public void testHandleCarrierConfigChanged() throws Exception {
+        // Expect receiving default NETWORK_RELEASE_TIMEOUT of 5 seconds
+        int defaultNetworkReleaseTimeout = 5000;
+        doReturn(defaultNetworkReleaseTimeout).when(mConfig).getInt(
+                CarrierConfigManager.KEY_MMS_NETWORK_RELEASE_TIMEOUT_MILLIS_INT);
+        MmsNetworkManager mmsNetworkManager = new MmsNetworkManager(mCtx, TEST_SUBID, mDeps);
+        assertEquals(defaultNetworkReleaseTimeout,
+                mmsNetworkManager.getNetworkReleaseTimeoutMillis());
+
+        // Expect receiving a carrier-configured value
+        int configuredNetworkReleaseTimeout = 10000;
+        doReturn(configuredNetworkReleaseTimeout).when(mConfig).getInt(
+                CarrierConfigManager.KEY_MMS_NETWORK_RELEASE_TIMEOUT_MILLIS_INT);
+        mmsNetworkManager = new MmsNetworkManager(mCtx, TEST_SUBID, mDeps);
+        assertEquals(configuredNetworkReleaseTimeout,
+                mmsNetworkManager.getNetworkReleaseTimeoutMillis());
+    }
+
     private NetworkCallback acquireAvailableNetworkAndGetCallback(
             Network expectNetwork, String expectApn) throws Exception {
         final ArgumentCaptor<NetworkCallback> callbackCaptor =
diff --git a/tests/unittests/Android.bp b/tests/unittests/Android.bp
new file mode 100644
index 0000000..595b4be
--- /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-service-srcs"],
+    platform_apis: true,
+    test_suites: ["device-tests"],
+    certificate: "platform",
+    instrumentation_for: "MmsService",
+}
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/MmsHttpClientTest.java b/tests/unittests/src/com/android/mms/service/MmsHttpClientTest.java
new file mode 100644
index 0000000..dd126e8
--- /dev/null
+++ b/tests/unittests/src/com/android/mms/service/MmsHttpClientTest.java
@@ -0,0 +1,148 @@
+/*
+ * Copyright (C) 2023 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;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.net.ConnectivityManager;
+import android.net.Network;
+import android.os.Bundle;
+import android.telephony.ServiceState;
+import android.telephony.SubscriptionManager;
+import android.telephony.TelephonyManager;
+
+import androidx.test.core.app.ApplicationProvider;
+
+import org.junit.After;
+import org.junit.Before;
+import org.mockito.MockitoAnnotations;
+import org.mockito.Spy;
+import org.junit.Test;
+
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.reset;
+
+import android.util.Log;
+
+public class MmsHttpClientTest {
+    // Mocked classes
+    private Context mContext;
+    private TelephonyManager mTelephonyManager;
+    private SubscriptionManager mSubscriptionManager;
+
+    // The raw phone number from TelephonyManager.getLine1Number
+    private static final String MACRO_LINE1 = "LINE1";
+    // The phone number without country code
+    private static final String MACRO_LINE1NOCOUNTRYCODE = "LINE1NOCOUNTRYCODE";
+    private String line1Number = "1234567890";
+    private String subscriberPhoneNumber = "0987654321";
+    private int subId = 1;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+        mContext = spy(ApplicationProvider.getApplicationContext());
+        mTelephonyManager = mock(TelephonyManager.class);
+        mSubscriptionManager = mock(SubscriptionManager.class);
+
+        when(mContext.getSystemService(Context.TELEPHONY_SERVICE))
+            .thenReturn(mTelephonyManager);
+        when(mTelephonyManager.createForSubscriptionId(anyInt()))
+            .thenReturn(mTelephonyManager);
+        when(mContext.getSystemService(SubscriptionManager.class))
+            .thenReturn(mSubscriptionManager);
+    }
+
+    @After
+    public void tearDown() {
+        mContext = null;
+        mTelephonyManager = null;
+        mSubscriptionManager = null;
+    }
+
+    @Test
+    public void getPhoneNumberForMacroLine1() {
+        String macro = MACRO_LINE1;
+        Bundle mmsConfig = new Bundle();
+        String emptyStr = "";
+        String phoneNo;
+
+        /* when getLine1Number returns valid number */
+        doReturn(line1Number).when(mTelephonyManager).getLine1Number();
+        phoneNo = MmsHttpClient.getMacroValue(mContext, macro, mmsConfig, subId);
+        assertThat(phoneNo).isEqualTo(line1Number);
+        // getLine1NumberAPI should be called
+        verify(mTelephonyManager).getLine1Number();
+        // getPhoneNumber should never be called
+        verify(mSubscriptionManager, never()).getPhoneNumber(subId);
+
+        /* when getLine1Number returns empty string */
+        doReturn(emptyStr).when(mTelephonyManager).getLine1Number();
+        when(mSubscriptionManager.getPhoneNumber(subId)).thenReturn(subscriberPhoneNumber);
+        phoneNo = MmsHttpClient.getMacroValue(mContext, macro, mmsConfig, subId);
+        assertThat(phoneNo).isEqualTo(subscriberPhoneNumber);
+        verify(mSubscriptionManager).getPhoneNumber(subId);
+
+        /* when getLine1Number returns null */
+        reset(mSubscriptionManager);
+        when(mSubscriptionManager.getPhoneNumber(subId)).thenReturn(subscriberPhoneNumber);
+        doReturn(null).when(mTelephonyManager).getLine1Number();
+        phoneNo = MmsHttpClient.getMacroValue(mContext, macro, mmsConfig, subId);
+        assertThat(phoneNo).isEqualTo(subscriberPhoneNumber);
+        verify(mSubscriptionManager).getPhoneNumber(subId);
+    }
+
+    @Test
+    public void getPhoneNumberForMacroLine1CountryCode() throws Exception {
+        String macro = MACRO_LINE1NOCOUNTRYCODE;
+        String emptyStr = "";
+        String phoneNo;
+        Bundle mmsConfig = new Bundle();
+
+        /* when getLine1Number returns valid number */
+        doReturn(line1Number).when(mTelephonyManager).getLine1Number();
+        phoneNo = MmsHttpClient.getMacroValue(mContext, macro, mmsConfig, subId);
+        assertThat(phoneNo).contains(line1Number);
+        // getLine1NumberAPI should be called
+        verify(mTelephonyManager).getLine1Number();
+        // getPhoneNumber should never be called
+        verify(mSubscriptionManager, never()).getPhoneNumber(subId);
+
+        /* when getLine1Number returns empty string */
+        doReturn(emptyStr).when(mTelephonyManager).getLine1Number();
+        when(mSubscriptionManager.getPhoneNumber(subId)).thenReturn(subscriberPhoneNumber);
+        phoneNo = MmsHttpClient.getMacroValue(mContext, macro, mmsConfig, subId);
+        assertThat(phoneNo).contains(subscriberPhoneNumber);
+        verify(mSubscriptionManager).getPhoneNumber(subId);
+
+        /* when getLine1Number returns null */
+        reset(mSubscriptionManager);
+        when(mSubscriptionManager.getPhoneNumber(subId)).thenReturn(subscriberPhoneNumber);
+        doReturn(null).when(mTelephonyManager).getLine1Number();
+        phoneNo = MmsHttpClient.getMacroValue(mContext, macro, mmsConfig, subId);
+        assertThat(phoneNo).contains(subscriberPhoneNumber);
+        verify(mSubscriptionManager).getPhoneNumber(subId);
+    }
+}
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/MmsStatsTest.java b/tests/unittests/src/com/android/mms/service/metrics/MmsStatsTest.java
new file mode 100644
index 0000000..2b2cae5
--- /dev/null
+++ b/tests/unittests/src/com/android/mms/service/metrics/MmsStatsTest.java
@@ -0,0 +1,154 @@
+/*
+ * 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__RESULT__MMS_RESULT_SUCCESS;
+import static com.android.mms.MmsStatsLog.OUTGOING_MMS__RESULT__MMS_RESULT_SUCCESS;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+import android.app.Activity;
+import android.content.Context;
+import android.telephony.ServiceState;
+import android.telephony.SubscriptionManager;
+import android.telephony.TelephonyManager;
+
+import com.android.mms.IncomingMms;
+import com.android.mms.OutgoingMms;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import org.mockito.ArgumentCaptor;
+
+public class MmsStatsTest {
+    // Mocked classes
+    private Context mContext;
+    private PersistMmsAtomsStorage mPersistMmsAtomsStorage;
+    private TelephonyManager mTelephonyManager;
+    private SubscriptionManager mSubscriptionManager;
+
+    @Before
+    public void setUp() {
+        mContext = mock(Context.class);
+        mPersistMmsAtomsStorage = mock(PersistMmsAtomsStorage.class);
+        mTelephonyManager = mock(TelephonyManager.class);
+        mSubscriptionManager = mock(SubscriptionManager.class);
+
+        doReturn(mSubscriptionManager).when(mContext).getSystemService(
+                Context.TELEPHONY_SUBSCRIPTION_SERVICE);
+    }
+
+    @After
+    public void tearDown() {
+        mContext = null;
+        mPersistMmsAtomsStorage = null;
+        mTelephonyManager = null;
+    }
+
+    @Test
+    public void addAtomToStorage_incomingMms_default() {
+        doReturn(null).when(mTelephonyManager).getServiceState();
+        doReturn(TelephonyManager.UNKNOWN_CARRIER_ID).when(mTelephonyManager).getSimCarrierId();
+        int inactiveSubId = 123;
+        MmsStats mmsStats = new MmsStats(mContext, mPersistMmsAtomsStorage, inactiveSubId,
+                mTelephonyManager, null, true);
+        mmsStats.addAtomToStorage(Activity.RESULT_OK);
+
+        ArgumentCaptor<IncomingMms> incomingMmsCaptor = ArgumentCaptor.forClass(IncomingMms.class);
+        verify(mPersistMmsAtomsStorage).addIncomingMms(incomingMmsCaptor.capture());
+        IncomingMms incomingMms = incomingMmsCaptor.getValue();
+        assertThat(incomingMms.getRat()).isEqualTo(TelephonyManager.NETWORK_TYPE_UNKNOWN);
+        assertThat(incomingMms.getResult()).isEqualTo(INCOMING_MMS__RESULT__MMS_RESULT_SUCCESS);
+        assertThat(incomingMms.getRoaming()).isEqualTo(ServiceState.ROAMING_TYPE_NOT_ROAMING);
+        assertThat(incomingMms.getSimSlotIndex()).isEqualTo(
+                SubscriptionManager.INVALID_SIM_SLOT_INDEX);
+        assertThat(incomingMms.getIsMultiSim()).isEqualTo(false);
+        assertThat(incomingMms.getIsEsim()).isEqualTo(false);
+        assertThat(incomingMms.getCarrierId()).isEqualTo(TelephonyManager.UNKNOWN_CARRIER_ID);
+        assertThat(incomingMms.getMmsCount()).isEqualTo(1);
+        assertThat(incomingMms.getRetryId()).isEqualTo(0);
+        assertThat(incomingMms.getHandledByCarrierApp()).isEqualTo(false);
+        assertThat(incomingMms.getIsManagedProfile()).isEqualTo(false);
+        verifyNoMoreInteractions(mPersistMmsAtomsStorage);
+    }
+
+    @Test
+    public void addAtomToStorage_outgoingMms_default() {
+        doReturn(null).when(mTelephonyManager).getServiceState();
+        doReturn(TelephonyManager.UNKNOWN_CARRIER_ID).when(mTelephonyManager).getSimCarrierId();
+        int inactiveSubId = 123;
+        MmsStats mmsStats = new MmsStats(mContext, mPersistMmsAtomsStorage, inactiveSubId,
+                mTelephonyManager, null, false);
+        mmsStats.addAtomToStorage(Activity.RESULT_OK);
+
+        ArgumentCaptor<OutgoingMms> outgoingMmsCaptor = ArgumentCaptor.forClass(OutgoingMms.class);
+        verify(mPersistMmsAtomsStorage).addOutgoingMms(outgoingMmsCaptor.capture());
+        OutgoingMms outgoingMms = outgoingMmsCaptor.getValue();
+        assertThat(outgoingMms.getRat()).isEqualTo(TelephonyManager.NETWORK_TYPE_UNKNOWN);
+        assertThat(outgoingMms.getResult()).isEqualTo(OUTGOING_MMS__RESULT__MMS_RESULT_SUCCESS);
+        assertThat(outgoingMms.getRoaming()).isEqualTo(ServiceState.ROAMING_TYPE_NOT_ROAMING);
+        assertThat(outgoingMms.getSimSlotIndex()).isEqualTo(
+                SubscriptionManager.INVALID_SIM_SLOT_INDEX);
+        assertThat(outgoingMms.getIsMultiSim()).isEqualTo(false);
+        assertThat(outgoingMms.getIsEsim()).isEqualTo(false);
+        assertThat(outgoingMms.getCarrierId()).isEqualTo(TelephonyManager.UNKNOWN_CARRIER_ID);
+        assertThat(outgoingMms.getMmsCount()).isEqualTo(1);
+        assertThat(outgoingMms.getRetryId()).isEqualTo(0);
+        assertThat(outgoingMms.getHandledByCarrierApp()).isEqualTo(false);
+        assertThat(outgoingMms.getIsFromDefaultApp()).isEqualTo(false);
+        assertThat(outgoingMms.getIsManagedProfile()).isEqualTo(false);
+        verifyNoMoreInteractions(mPersistMmsAtomsStorage);
+    }
+
+    @Test
+    public void getDataRoamingType_serviceState_notNull() {
+        ServiceState serviceState = mock(ServiceState.class);
+        doReturn(serviceState).when(mTelephonyManager).getServiceState();
+        MmsStats mmsStats = new MmsStats(mContext, mPersistMmsAtomsStorage, 1,
+                mTelephonyManager, null, true);
+        mmsStats.addAtomToStorage(Activity.RESULT_OK);
+
+        ArgumentCaptor<IncomingMms> incomingMmsCaptor = ArgumentCaptor.forClass(IncomingMms.class);
+        verify(mPersistMmsAtomsStorage).addIncomingMms(incomingMmsCaptor.capture());
+        IncomingMms incomingMms = incomingMmsCaptor.getValue();
+        assertThat(incomingMms.getRoaming()).isEqualTo(ServiceState.ROAMING_TYPE_NOT_ROAMING);
+    }
+
+
+    @Test
+    public void isDefaultMmsApp_subId_inactive() {
+        int inactiveSubId = 123;
+        doReturn(false).when(mSubscriptionManager)
+                .isActiveSubscriptionId(eq(inactiveSubId));
+
+        MmsStats mmsStats = new MmsStats(mContext, mPersistMmsAtomsStorage, inactiveSubId,
+                mTelephonyManager, null, false);
+        mmsStats.addAtomToStorage(Activity.RESULT_OK);
+
+        // getSubscriptionUserHandle should not be called if subID is inactive.
+        verify(mSubscriptionManager, never()).getSubscriptionUserHandle(eq(inactiveSubId));
+    }
+}
\ 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