Support pulled atoms for Telecom metrics

Flag: com.android.server.telecom.flags.telecom_metrics_support
Bug: 362394177
Test: manual
Test: atest TelecomUnitTests
Change-Id: I50cbc15b8f81bc86be5398cd605804743428f473
diff --git a/flags/Android.bp b/flags/Android.bp
index 45acacf..e374a4d 100644
--- a/flags/Android.bp
+++ b/flags/Android.bp
@@ -43,5 +43,6 @@
         "telecom_profile_user_flags.aconfig",
         "telecom_bluetoothdevicemanager_flags.aconfig",
         "telecom_non_critical_security_flags.aconfig",
+        "telecom_metrics_flags.aconfig",
     ],
 }
diff --git a/flags/telecom_metrics_flags.aconfig b/flags/telecom_metrics_flags.aconfig
new file mode 100644
index 0000000..e582e9e
--- /dev/null
+++ b/flags/telecom_metrics_flags.aconfig
@@ -0,0 +1,10 @@
+package: "com.android.server.telecom.flags"
+container: "system"
+
+# OWNER=huiwang TARGET=25Q1
+flag {
+  name: "telecom_metrics_support"
+  namespace: "telecom"
+  description: "Support telecom metrics"
+  bug: "362394177"
+}
diff --git a/proto/pulled_atoms.proto b/proto/pulled_atoms.proto
new file mode 100644
index 0000000..7360b6a
--- /dev/null
+++ b/proto/pulled_atoms.proto
@@ -0,0 +1,114 @@
+syntax = "proto2";
+
+package com.android.server.telecom;
+
+option java_package = "com.android.server.telecom";
+option java_outer_classname = "PulledAtomsClass";
+
+message PulledAtoms {
+  repeated CallStats call_stats = 1;
+  optional int64 call_stats_pull_timestamp_millis = 2;
+  repeated CallAudioRouteStats call_audio_route_stats = 3;
+  optional int64 call_audio_route_stats_pull_timestamp_millis = 4;
+  repeated TelecomApiStats telecom_api_stats = 5;
+  optional int64 telecom_api_stats_pull_timestamp_millis = 6;
+  repeated TelecomErrorStats telecom_error_stats = 7;
+  optional int64 telecom_error_stats_pull_timestamp_millis = 8;
+}
+
+/**
+ * Pulled atom to capture stats of the calls
+ * From frameworks/proto_logging/stats/atoms/telecomm/telecom_extension_atom.proto
+ */
+message CallStats {
+    // The value should be converted to android.telecom.CallDirectionEnum
+    // From frameworks/proto_logging/stats/enums/telecomm/enums.proto
+    optional int32 call_direction = 1;
+
+    // True if call is external. External calls are calls on connected Wear
+    // devices but show up in Telecom so the user can pull them onto the device.
+    optional bool external_call = 2;
+
+    // True if call is emergency call.
+    optional bool emergency_call = 3;
+
+    // True if there are multiple audio routes available
+    optional bool multiple_audio_available = 4;
+
+    // The value should be converted to android.telecom.AccountTypeEnum
+    // From frameworks/proto_logging/stats/enums/telecomm/enums.proto
+    optional int32 account_type = 5;
+
+    // UID of the package to init the call. This should always be -1/unknown for
+    // the private space calls
+    optional int32 uid = 6;
+
+    // Total number of the calls
+    optional int32 count = 7;
+
+    // Average elapsed time between CALL_STATE_ACTIVE to CALL_STATE_DISCONNECTED.
+    optional int32 average_duration_ms = 8;
+}
+
+/**
+ * Pulled atom to capture stats of the call audio route
+ * From frameworks/proto_logging/stats/atoms/telecomm/telecom_extension_atom.proto
+ */
+message CallAudioRouteStats {
+    // The value should be converted to android.telecom.CallAudioEnum
+    // From frameworks/proto_logging/stats/enums/telecomm/enums.proto
+    optional int32 call_audio_route_source = 1;
+
+    // The value should be converted to android.telecom.CallAudioEnum
+    // From frameworks/proto_logging/stats/enums/telecomm/enums.proto
+    optional int32 call_audio_route_dest = 2;
+
+    // True if the route is successful.
+    optional bool success = 3;
+
+    // True if the route is revert
+    optional bool revert = 4;
+
+    // Total number of the audio route
+    optional int32 count = 5;
+
+    // Average time from the audio route start to complete
+    optional int32 average_latency_ms = 6;
+}
+
+/**
+ * Pulled atom to capture stats of Telecom API usage
+ * From frameworks/proto_logging/stats/atoms/telecomm/telecom_extension_atom.proto
+ */
+message TelecomApiStats {
+    // The value should be converted to android.telecom.ApiNameEnum
+    // From frameworks/proto_logging/stats/enums/telecomm/enums.proto
+    optional int32 api_name = 1;
+
+    // UID of the caller. This is always -1/unknown for the private space.
+    optional int32 uid = 2;
+
+    // The value should be converted to android.telecom.ApiResultEnum
+    // From frameworks/proto_logging/stats/enums/telecomm/enums.proto
+    optional int32 api_result = 3;
+
+    // The number of times this event occurs
+    optional int32 count = 4;
+}
+
+/**
+ * Pulled atom to capture stats of Telecom module errors
+ * From frameworks/proto_logging/stats/atoms/telecomm/telecom_extension_atom.proto
+ */
+message TelecomErrorStats {
+    // The value should be converted to android.telecom.SubmoduleNameEnum
+    // From frameworks/proto_logging/stats/enums/telecomm/enums.proto
+    optional int32 submodule_name = 1;
+
+    // The value should be converted to android.telecom.ErrorNameEnum
+    // From frameworks/proto_logging/stats/enums/telecomm/enums.proto
+    optional int32 error_name = 2;
+
+    // The number of times this error occurs
+    optional int32 count = 3;
+}
diff --git a/src/com/android/server/telecom/AudioRoute.java b/src/com/android/server/telecom/AudioRoute.java
index 4f563a1..f402099 100644
--- a/src/com/android/server/telecom/AudioRoute.java
+++ b/src/com/android/server/telecom/AudioRoute.java
@@ -137,6 +137,7 @@
     private @AudioRouteType int mAudioRouteType;
     private String mBluetoothAddress;
     private AudioDeviceInfo mInfo;
+    private boolean mIsDestRouteForWatch;
     public static final Set<Integer> BT_AUDIO_DEVICE_INFO_TYPES = Set.of(
             AudioDeviceInfo.TYPE_BLE_HEADSET,
             AudioDeviceInfo.TYPE_BLE_SPEAKER,
@@ -241,6 +242,10 @@
         return mAudioRouteType;
     }
 
+    public boolean isWatch() {
+        return mIsDestRouteForWatch;
+    }
+
     String getBluetoothAddress() {
         return mBluetoothAddress;
     }
@@ -264,6 +269,8 @@
                         audioManager, bluetoothRouteManager);
                 // Special handling for SCO case.
                 if (mAudioRouteType == TYPE_BLUETOOTH_SCO) {
+                    // Set whether the dest route is for the watch
+                    mIsDestRouteForWatch = bluetoothRouteManager.isWatch(device);
                     // Check if the communication device was set for the device, even if
                     // BluetoothHeadset#connectAudio reports that the SCO connection wasn't
                     // successfully established.
diff --git a/src/com/android/server/telecom/Call.java b/src/com/android/server/telecom/Call.java
index dcd9486..c391641 100644
--- a/src/com/android/server/telecom/Call.java
+++ b/src/com/android/server/telecom/Call.java
@@ -1980,7 +1980,6 @@
                 PhoneAccount.EXTRA_LOG_SELF_MANAGED_CALLS, false);
     }
 
-    @VisibleForTesting
     public boolean isIncoming() {
         return mCallDirection == CALL_DIRECTION_INCOMING;
     }
@@ -2272,7 +2271,6 @@
      * @return The "age" of this call object in milliseconds, which typically also represents the
      *     period since this call was added to the set pending outgoing calls.
      */
-    @VisibleForTesting
     public long getAgeMillis() {
         if (mState == CallState.DISCONNECTED &&
                 (mDisconnectCause.getCode() == DisconnectCause.REJECTED ||
diff --git a/src/com/android/server/telecom/CallAnomalyWatchdog.java b/src/com/android/server/telecom/CallAnomalyWatchdog.java
index 497d7e6..384110c 100644
--- a/src/com/android/server/telecom/CallAnomalyWatchdog.java
+++ b/src/com/android/server/telecom/CallAnomalyWatchdog.java
@@ -32,6 +32,7 @@
 
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.util.IndentingPrintWriter;
+import com.android.server.telecom.metrics.TelecomMetricsController;
 import com.android.server.telecom.stats.CallStateChangedAtomWriter;
 import com.android.server.telecom.flags.FeatureFlags;
 
@@ -130,6 +131,7 @@
     private final Set<Call> mCallsPendingDestruction = Collections.newSetFromMap(
             new ConcurrentHashMap<>(2));
     private final LocalLog mLocalLog = new LocalLog(20);
+    private final TelecomMetricsController mMetricsController;
 
     /**
      * Enables the action to disconnect the call when the Transitory state and Intermediate state
@@ -163,13 +165,15 @@
             TelecomSystem.SyncRoot lock,
             FeatureFlags featureFlags,
             Timeouts.Adapter timeoutAdapter, ClockProxy clockProxy,
-            EmergencyCallDiagnosticLogger emergencyCallDiagnosticLogger) {
+            EmergencyCallDiagnosticLogger emergencyCallDiagnosticLogger,
+            TelecomMetricsController metricsController) {
         mScheduledExecutorService = executorService;
         mLock = lock;
         mFeatureFlags = featureFlags;
         mTimeoutAdapter = timeoutAdapter;
         mClockProxy = clockProxy;
         mEmergencyCallDiagnosticLogger = emergencyCallDiagnosticLogger;
+        mMetricsController = metricsController;
     }
 
     /**
@@ -185,6 +189,9 @@
     @Override
     public void onCallAdded(Call call) {
         maybeTrackCall(call);
+        if (mFeatureFlags.telecomMetricsSupport()) {
+            mMetricsController.getCallStats().onCallStart(call);
+        }
     }
 
     /**
@@ -206,6 +213,9 @@
     public void onCallRemoved(Call call) {
         Log.i(this, "onCallRemoved: call=%s", call.toString());
         stopTrackingCall(call);
+        if (mFeatureFlags.telecomMetricsSupport()) {
+            mMetricsController.getCallStats().onCallEnd(call);
+        }
     }
 
     /**
diff --git a/src/com/android/server/telecom/CallAudioRouteController.java b/src/com/android/server/telecom/CallAudioRouteController.java
index 0ed4308..d07d554 100644
--- a/src/com/android/server/telecom/CallAudioRouteController.java
+++ b/src/com/android/server/telecom/CallAudioRouteController.java
@@ -52,6 +52,7 @@
 import com.android.internal.util.IndentingPrintWriter;
 import com.android.server.telecom.bluetooth.BluetoothRouteManager;
 import com.android.server.telecom.flags.FeatureFlags;
+import com.android.server.telecom.metrics.TelecomMetricsController;
 
 import java.util.ArrayList;
 import java.util.HashMap;
@@ -172,13 +173,14 @@
     private boolean mIsMute;
     private boolean mIsPending;
     private boolean mIsActive;
+    private final TelecomMetricsController mMetricsController;
 
     public CallAudioRouteController(
             Context context, CallsManager callsManager,
             CallAudioManager.AudioServiceFactory audioServiceFactory,
             AudioRoute.Factory audioRouteFactory, WiredHeadsetManager wiredHeadsetManager,
             BluetoothRouteManager bluetoothRouteManager, StatusBarNotifier statusBarNotifier,
-            FeatureFlags featureFlags) {
+            FeatureFlags featureFlags, TelecomMetricsController metricsController) {
         mContext = context;
         mCallsManager = callsManager;
         mAudioManager = context.getSystemService(AudioManager.class);
@@ -189,6 +191,7 @@
         mBluetoothRouteManager = bluetoothRouteManager;
         mStatusBarNotifier = statusBarNotifier;
         mFeatureFlags = featureFlags;
+        mMetricsController = metricsController;
         mFocusType = NO_FOCUS;
         mIsScoAudioConnected = false;
         mTelecomLock = callsManager.getLock();
@@ -542,6 +545,9 @@
                 mIsScoAudioConnected);
         mIsActive = active;
         mPendingAudioRoute.evaluatePendingState();
+        if (mFeatureFlags.telecomMetricsSupport()) {
+            mMetricsController.getAudioRouteStats().onRouteEnter(mPendingAudioRoute);
+        }
     }
 
     private void handleWiredHeadsetConnected() {
@@ -973,6 +979,9 @@
             mIsPending = false;
             mPendingAudioRoute.clearPendingMessages();
             onCurrentRouteChanged();
+            if (mFeatureFlags.telecomMetricsSupport()) {
+                mMetricsController.getAudioRouteStats().onRouteExit(mPendingAudioRoute);
+            }
         }
     }
 
diff --git a/src/com/android/server/telecom/CallsManager.java b/src/com/android/server/telecom/CallsManager.java
index a1b8a50..028d8c1 100644
--- a/src/com/android/server/telecom/CallsManager.java
+++ b/src/com/android/server/telecom/CallsManager.java
@@ -134,6 +134,7 @@
 import com.android.server.telecom.components.ErrorDialogActivity;
 import com.android.server.telecom.components.TelecomBroadcastReceiver;
 import com.android.server.telecom.flags.FeatureFlags;
+import com.android.server.telecom.metrics.TelecomMetricsController;
 import com.android.server.telecom.stats.CallFailureCause;
 import com.android.server.telecom.ui.AudioProcessingNotification;
 import com.android.server.telecom.ui.CallRedirectionTimeoutDialogActivity;
@@ -616,7 +617,8 @@
             BluetoothDeviceManager bluetoothDeviceManager,
             FeatureFlags featureFlags,
             com.android.internal.telephony.flags.FeatureFlags telephonyFlags,
-            IncomingCallFilterGraphProvider incomingCallFilterGraphProvider) {
+            IncomingCallFilterGraphProvider incomingCallFilterGraphProvider,
+            TelecomMetricsController metricsController) {
 
         mContext = context;
         mLock = lock;
@@ -659,7 +661,7 @@
         } else {
             callAudioRouteAdapter = new CallAudioRouteController(context, this, audioServiceFactory,
                     new AudioRoute.Factory(), wiredHeadsetManager, mBluetoothRouteManager,
-                    statusBarNotifier, featureFlags);
+                    statusBarNotifier, featureFlags, metricsController);
         }
         callAudioRouteAdapter.initialize();
         bluetoothStateReceiver.setCallAudioRouteAdapter(callAudioRouteAdapter);
diff --git a/src/com/android/server/telecom/PendingAudioRoute.java b/src/com/android/server/telecom/PendingAudioRoute.java
index 396aca0..a544258 100644
--- a/src/com/android/server/telecom/PendingAudioRoute.java
+++ b/src/com/android/server/telecom/PendingAudioRoute.java
@@ -52,6 +52,7 @@
     private AudioRoute mDestRoute;
     private Set<Pair<Integer, String>> mPendingMessages;
     private boolean mActive;
+    private boolean mIsFailed;
     /**
      * The device that has been set for communication by Telecom
      */
@@ -72,7 +73,7 @@
         mOrigRoute = origRoute;
     }
 
-    AudioRoute getOrigRoute() {
+    public AudioRoute getOrigRoute() {
         return mOrigRoute;
     }
 
@@ -95,6 +96,7 @@
     public void onMessageReceived(Pair<Integer, String> message, String btAddressToExclude) {
         Log.i(this, "onMessageReceived: message - %s", message);
         if (message.first == PENDING_ROUTE_FAILED) {
+            mIsFailed = true;
             // Fallback to base route
             mCallAudioRouteController.sendMessageWithSessionInfo(
                     SWITCH_BASELINE_ROUTE, INCLUDE_BLUETOOTH_IN_BASELINE, btAddressToExclude);
@@ -139,4 +141,8 @@
     public void overrideDestRoute(AudioRoute route) {
         mDestRoute = route;
     }
+
+    public boolean isFailed() {
+        return mIsFailed;
+    }
 }
diff --git a/src/com/android/server/telecom/TelecomSystem.java b/src/com/android/server/telecom/TelecomSystem.java
index 1cbe846..fd1053f 100644
--- a/src/com/android/server/telecom/TelecomSystem.java
+++ b/src/com/android/server/telecom/TelecomSystem.java
@@ -48,6 +48,7 @@
 import com.android.server.telecom.components.UserCallIntentProcessor;
 import com.android.server.telecom.components.UserCallIntentProcessorFactory;
 import com.android.server.telecom.flags.FeatureFlags;
+import com.android.server.telecom.metrics.TelecomMetricsController;
 import com.android.server.telecom.ui.AudioProcessingNotification;
 import com.android.server.telecom.ui.CallStreamingNotification;
 import com.android.server.telecom.ui.DisconnectedCallNotifier;
@@ -373,10 +374,13 @@
                             BugreportManager.class), timeoutsAdapter, mContext.getSystemService(
                             DropBoxManager.class), asyncTaskExecutor, clockProxy);
 
+            TelecomMetricsController metricsController = featureFlags.telecomMetricsSupport()
+                    ? TelecomMetricsController.make(mContext) : null;
+
             CallAnomalyWatchdog callAnomalyWatchdog = new CallAnomalyWatchdog(
                     Executors.newSingleThreadScheduledExecutor(),
                     mLock, mFeatureFlags, timeoutsAdapter, clockProxy,
-                    emergencyCallDiagnosticLogger);
+                    emergencyCallDiagnosticLogger, metricsController);
 
             TransactionManager transactionManager = TransactionManager.getInstance();
 
@@ -428,7 +432,8 @@
                     bluetoothDeviceManager,
                     featureFlags,
                     telephonyFlags,
-                    IncomingCallFilterGraph::new);
+                    IncomingCallFilterGraph::new,
+                    metricsController);
 
             mIncomingCallNotifier = incomingCallNotifier;
             incomingCallNotifier.setCallsManagerProxy(new IncomingCallNotifier.CallsManagerProxy() {
diff --git a/src/com/android/server/telecom/metrics/ApiStats.java b/src/com/android/server/telecom/metrics/ApiStats.java
new file mode 100644
index 0000000..b37569f
--- /dev/null
+++ b/src/com/android/server/telecom/metrics/ApiStats.java
@@ -0,0 +1,148 @@
+/*
+ * Copyright (C) 2024 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.server.telecom.metrics;
+
+import static com.android.server.telecom.TelecomStatsLog.TELECOM_API_STATS;
+
+import android.annotation.NonNull;
+import android.app.StatsManager;
+import android.content.Context;
+import android.os.Looper;
+import android.util.StatsEvent;
+
+import androidx.annotation.VisibleForTesting;
+
+import com.android.server.telecom.TelecomStatsLog;
+import com.android.server.telecom.nano.PulledAtomsClass;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+public class ApiStats extends TelecomPulledAtom {
+
+    private static final String FILE_NAME = "api_stats";
+    private Map<ApiStatsKey, Integer> mApiStatsMap;
+
+    public ApiStats(@NonNull Context context, @NonNull Looper looper) {
+        super(context, looper);
+    }
+
+    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+    @Override
+    public int getTag() {
+        return TELECOM_API_STATS;
+    }
+
+    @Override
+    protected String getFileName() {
+        return FILE_NAME;
+    }
+
+    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+    @Override
+    public synchronized int onPull(final List<StatsEvent> data) {
+        if (mPulledAtoms.telecomApiStats.length != 0) {
+            Arrays.stream(mPulledAtoms.telecomApiStats).forEach(v -> data.add(
+                    TelecomStatsLog.buildStatsEvent(getTag(),
+                            v.getApiName(), v.getUid(), v.getApiResult(), v.getCount())));
+            return StatsManager.PULL_SUCCESS;
+        } else {
+            return StatsManager.PULL_SKIP;
+        }
+    }
+
+    @Override
+    protected synchronized void onLoad() {
+        if (mPulledAtoms.telecomApiStats != null) {
+            mApiStatsMap = new HashMap<>();
+            for (PulledAtomsClass.TelecomApiStats v : mPulledAtoms.telecomApiStats) {
+                mApiStatsMap.put(new ApiStatsKey(v.getApiName(), v.getUid(), v.getApiResult()),
+                        v.getCount());
+            }
+            mLastPulledTimestamps = mPulledAtoms.getTelecomApiStatsPullTimestampMillis();
+        }
+    }
+
+    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+    @Override
+    public synchronized void onAggregate() {
+        clearAtoms();
+        if (mApiStatsMap.isEmpty()) {
+            return;
+        }
+        mPulledAtoms.setTelecomApiStatsPullTimestampMillis(mLastPulledTimestamps);
+        mPulledAtoms.telecomApiStats =
+                new PulledAtomsClass.TelecomApiStats[mApiStatsMap.size()];
+        int[] index = new int[1];
+        mApiStatsMap.forEach((k, v) -> {
+            mPulledAtoms.telecomApiStats[index[0]] = new PulledAtomsClass.TelecomApiStats();
+            mPulledAtoms.telecomApiStats[index[0]].setApiName(k.mApiId);
+            mPulledAtoms.telecomApiStats[index[0]].setUid(k.mCallerUid);
+            mPulledAtoms.telecomApiStats[index[0]].setApiResult(k.mResult);
+            mPulledAtoms.telecomApiStats[index[0]].setCount(v);
+            index[0]++;
+        });
+        save(DELAY_FOR_PERSISTENT_MILLIS);
+    }
+
+    public void log(int apiId, int callerUid, int result) {
+        post(() -> {
+            ApiStatsKey key = new ApiStatsKey(apiId, callerUid, result);
+            mApiStatsMap.put(key, mApiStatsMap.getOrDefault(key, 0) + 1);
+            onAggregate();
+        });
+    }
+
+    static class ApiStatsKey {
+
+        int mApiId;
+        int mCallerUid;
+        int mResult;
+
+        ApiStatsKey(int apiId, int callerUid, int result) {
+            mApiId = apiId;
+            mCallerUid = callerUid;
+            mResult = result;
+        }
+
+        @Override
+        public boolean equals(Object other) {
+            if (this == other) {
+                return true;
+            }
+            if (other == null || !(other instanceof ApiStatsKey obj)) {
+                return false;
+            }
+            return this.mApiId == obj.mApiId && this.mCallerUid == obj.mCallerUid
+                    && this.mResult == obj.mResult;
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(mApiId, mCallerUid, mResult);
+        }
+
+        @Override
+        public String toString() {
+            return "[ApiStatsKey: mApiId=" + mApiId + ", mCallerUid=" + mCallerUid
+                    + ", mResult=" + mResult + "]";
+        }
+    }
+}
diff --git a/src/com/android/server/telecom/metrics/AudioRouteStats.java b/src/com/android/server/telecom/metrics/AudioRouteStats.java
new file mode 100644
index 0000000..8755402
--- /dev/null
+++ b/src/com/android/server/telecom/metrics/AudioRouteStats.java
@@ -0,0 +1,355 @@
+/*
+ * Copyright (C) 2024 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.server.telecom.metrics;
+
+import static com.android.server.telecom.AudioRoute.TYPE_BLUETOOTH_HA;
+import static com.android.server.telecom.AudioRoute.TYPE_BLUETOOTH_LE;
+import static com.android.server.telecom.AudioRoute.TYPE_BLUETOOTH_SCO;
+import static com.android.server.telecom.AudioRoute.TYPE_DOCK;
+import static com.android.server.telecom.AudioRoute.TYPE_EARPIECE;
+import static com.android.server.telecom.AudioRoute.TYPE_SPEAKER;
+import static com.android.server.telecom.AudioRoute.TYPE_STREAMING;
+import static com.android.server.telecom.AudioRoute.TYPE_WIRED;
+import static com.android.server.telecom.TelecomStatsLog.CALL_AUDIO_ROUTE_STATS;
+import static com.android.server.telecom.TelecomStatsLog.CALL_AUDIO_ROUTE_STATS__ROUTE_DEST__CALL_AUDIO_BLUETOOTH;
+import static com.android.server.telecom.TelecomStatsLog.CALL_AUDIO_ROUTE_STATS__ROUTE_DEST__CALL_AUDIO_BLUETOOTH_LE;
+import static com.android.server.telecom.TelecomStatsLog.CALL_AUDIO_ROUTE_STATS__ROUTE_DEST__CALL_AUDIO_EARPIECE;
+import static com.android.server.telecom.TelecomStatsLog.CALL_AUDIO_ROUTE_STATS__ROUTE_DEST__CALL_AUDIO_HEARING_AID;
+import static com.android.server.telecom.TelecomStatsLog.CALL_AUDIO_ROUTE_STATS__ROUTE_DEST__CALL_AUDIO_PHONE_SPEAKER;
+import static com.android.server.telecom.TelecomStatsLog.CALL_AUDIO_ROUTE_STATS__ROUTE_DEST__CALL_AUDIO_UNSPECIFIED;
+import static com.android.server.telecom.TelecomStatsLog.CALL_AUDIO_ROUTE_STATS__ROUTE_DEST__CALL_AUDIO_WATCH_SPEAKER;
+import static com.android.server.telecom.TelecomStatsLog.CALL_AUDIO_ROUTE_STATS__ROUTE_DEST__CALL_AUDIO_WIRED_HEADSET;
+import static com.android.server.telecom.TelecomStatsLog.CALL_AUDIO_ROUTE_STATS__ROUTE_SOURCE__CALL_AUDIO_BLUETOOTH;
+import static com.android.server.telecom.TelecomStatsLog.CALL_AUDIO_ROUTE_STATS__ROUTE_SOURCE__CALL_AUDIO_BLUETOOTH_LE;
+import static com.android.server.telecom.TelecomStatsLog.CALL_AUDIO_ROUTE_STATS__ROUTE_SOURCE__CALL_AUDIO_EARPIECE;
+import static com.android.server.telecom.TelecomStatsLog.CALL_AUDIO_ROUTE_STATS__ROUTE_SOURCE__CALL_AUDIO_HEARING_AID;
+import static com.android.server.telecom.TelecomStatsLog.CALL_AUDIO_ROUTE_STATS__ROUTE_SOURCE__CALL_AUDIO_PHONE_SPEAKER;
+import static com.android.server.telecom.TelecomStatsLog.CALL_AUDIO_ROUTE_STATS__ROUTE_SOURCE__CALL_AUDIO_UNSPECIFIED;
+import static com.android.server.telecom.TelecomStatsLog.CALL_AUDIO_ROUTE_STATS__ROUTE_SOURCE__CALL_AUDIO_WATCH_SPEAKER;
+import static com.android.server.telecom.TelecomStatsLog.CALL_AUDIO_ROUTE_STATS__ROUTE_SOURCE__CALL_AUDIO_WIRED_HEADSET;
+
+import android.annotation.NonNull;
+import android.app.StatsManager;
+import android.content.Context;
+import android.os.Looper;
+import android.os.Message;
+import android.os.SystemClock;
+import android.telecom.Log;
+import android.util.Pair;
+import android.util.StatsEvent;
+
+import androidx.annotation.VisibleForTesting;
+
+import com.android.server.telecom.AudioRoute;
+import com.android.server.telecom.PendingAudioRoute;
+import com.android.server.telecom.TelecomStatsLog;
+import com.android.server.telecom.nano.PulledAtomsClass;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+public class AudioRouteStats extends TelecomPulledAtom {
+    @VisibleForTesting
+    public static final long THRESHOLD_REVERT_MS = 5000;
+    @VisibleForTesting
+    public static final int EVENT_REVERT_THRESHOLD_EXPIRED = EVENT_SUB_BASE + 1;
+    private static final String TAG = AudioRouteStats.class.getSimpleName();
+    private static final String FILE_NAME = "audio_route_stats";
+    private Map<AudioRouteStatsKey, AudioRouteStatsData> mAudioRouteStatsMap;
+    private Pair<AudioRouteStatsKey, long[]> mCur;
+    private boolean mIsOngoing;
+
+    public AudioRouteStats(@NonNull Context context, @NonNull Looper looper) {
+        super(context, looper);
+    }
+
+    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+    @Override
+    public int getTag() {
+        return CALL_AUDIO_ROUTE_STATS;
+    }
+
+    @Override
+    protected String getFileName() {
+        return FILE_NAME;
+    }
+
+    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+    @Override
+    public synchronized int onPull(final List<StatsEvent> data) {
+        if (mPulledAtoms.callAudioRouteStats.length != 0) {
+            Arrays.stream(mPulledAtoms.callAudioRouteStats).forEach(v -> data.add(
+                    TelecomStatsLog.buildStatsEvent(getTag(),
+                            v.getCallAudioRouteSource(), v.getCallAudioRouteDest(),
+                            v.getSuccess(), v.getRevert(), v.getCount(), v.getAverageLatencyMs())));
+            return StatsManager.PULL_SUCCESS;
+        } else {
+            return StatsManager.PULL_SKIP;
+        }
+    }
+
+    @Override
+    protected synchronized void onLoad() {
+        if (mPulledAtoms.callAudioRouteStats != null) {
+            mAudioRouteStatsMap = new HashMap<>();
+            for (PulledAtomsClass.CallAudioRouteStats v : mPulledAtoms.callAudioRouteStats) {
+                mAudioRouteStatsMap.put(new AudioRouteStatsKey(v.getCallAudioRouteSource(),
+                                v.getCallAudioRouteDest(), v.getSuccess(), v.getRevert()),
+                        new AudioRouteStatsData(v.getCount(), v.getAverageLatencyMs()));
+            }
+            mLastPulledTimestamps = mPulledAtoms.getCallAudioRouteStatsPullTimestampMillis();
+        }
+    }
+
+    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+    @Override
+    public synchronized void onAggregate() {
+        Log.d(TAG, "onAggregate: %s", mAudioRouteStatsMap);
+        clearAtoms();
+        if (mAudioRouteStatsMap.isEmpty()) {
+            return;
+        }
+        mPulledAtoms.setCallAudioRouteStatsPullTimestampMillis(mLastPulledTimestamps);
+        mPulledAtoms.callAudioRouteStats =
+                new PulledAtomsClass.CallAudioRouteStats[mAudioRouteStatsMap.size()];
+        int[] index = new int[1];
+        mAudioRouteStatsMap.forEach((k, v) -> {
+            mPulledAtoms.callAudioRouteStats[index[0]] = new PulledAtomsClass.CallAudioRouteStats();
+            mPulledAtoms.callAudioRouteStats[index[0]].setCallAudioRouteSource(k.mSource);
+            mPulledAtoms.callAudioRouteStats[index[0]].setCallAudioRouteDest(k.mDest);
+            mPulledAtoms.callAudioRouteStats[index[0]].setSuccess(k.mIsSuccess);
+            mPulledAtoms.callAudioRouteStats[index[0]].setRevert(k.mIsRevert);
+            mPulledAtoms.callAudioRouteStats[index[0]].setCount(v.mCount);
+            mPulledAtoms.callAudioRouteStats[index[0]].setAverageLatencyMs(v.mAverageLatency);
+            index[0]++;
+        });
+        save(DELAY_FOR_PERSISTENT_MILLIS);
+    }
+
+    @VisibleForTesting
+    public void log(int source, int target, boolean isSuccess, boolean isRevert, int latency) {
+        post(() -> onLog(new AudioRouteStatsKey(source, target, isSuccess, isRevert), latency));
+    }
+
+    public void onRouteEnter(PendingAudioRoute pendingRoute) {
+        int sourceType = convertAudioType(pendingRoute.getOrigRoute(), true);
+        int destType = convertAudioType(pendingRoute.getDestRoute(), false);
+        long curTime = SystemClock.elapsedRealtime();
+
+        post(() -> {
+            // Ignore the transition route
+            if (!mIsOngoing) {
+                mIsOngoing = true;
+                // Check if the previous route is reverted as the revert time has not been expired.
+                if (mCur != null) {
+                    if (destType == mCur.first.getSource() && curTime - mCur.second[0]
+                            < THRESHOLD_REVERT_MS) {
+                        mCur.first.setRevert(true);
+                    }
+                    if (mCur.second[1] < 0) {
+                        mCur.second[1] = curTime;
+                    }
+                    onLog();
+                }
+                mCur = new Pair<>(new AudioRouteStatsKey(sourceType, destType), new long[]{curTime,
+                        -1});
+                if (hasMessages(EVENT_REVERT_THRESHOLD_EXPIRED)) {
+                    // Only keep the latest event
+                    removeMessages(EVENT_REVERT_THRESHOLD_EXPIRED);
+                }
+                sendMessageDelayed(
+                        obtainMessage(EVENT_REVERT_THRESHOLD_EXPIRED), THRESHOLD_REVERT_MS);
+            }
+        });
+    }
+
+    public void onRouteExit(PendingAudioRoute pendingRoute) {
+        // Check the dest type on the route exiting as it may be different as the enter
+        int destType = convertAudioType(pendingRoute.getDestRoute(), false);
+        boolean isSuccess = !pendingRoute.isFailed();
+        long curTime = SystemClock.elapsedRealtime();
+        post(() -> {
+            if (mIsOngoing) {
+                mIsOngoing = false;
+                // Should not be null unless the route is not done before the revert timer expired.
+                if (mCur != null) {
+                    mCur.first.setDestType(destType);
+                    mCur.first.setSuccess(isSuccess);
+                    mCur.second[1] = curTime;
+                }
+            }
+        });
+    }
+
+    private void onLog() {
+        if (mCur != null) {
+            // Ignore the case if the source and dest types are same
+            if (mCur.first.mSource != mCur.first.mDest) {
+                // The route should have been done before the revert timer expires. Otherwise, it
+                // would be logged as the failed case
+                if (mCur.second[1] < 0) {
+                    mCur.second[1] = SystemClock.elapsedRealtime();
+                }
+                onLog(mCur.first, (int) (mCur.second[1] - mCur.second[0]));
+            }
+            mCur = null;
+        }
+    }
+
+    private void onLog(AudioRouteStatsKey key, int latency) {
+        AudioRouteStatsData data = mAudioRouteStatsMap.computeIfAbsent(key,
+                k -> new AudioRouteStatsData(0, 0));
+        data.add(latency);
+        onAggregate();
+    }
+
+    private int convertAudioType(AudioRoute route, boolean isSource) {
+        if (route != null) {
+            switch (route.getType()) {
+                case TYPE_EARPIECE:
+                    return isSource ? CALL_AUDIO_ROUTE_STATS__ROUTE_SOURCE__CALL_AUDIO_EARPIECE
+                            : CALL_AUDIO_ROUTE_STATS__ROUTE_DEST__CALL_AUDIO_EARPIECE;
+                case TYPE_WIRED:
+                    return isSource ? CALL_AUDIO_ROUTE_STATS__ROUTE_SOURCE__CALL_AUDIO_WIRED_HEADSET
+                            : CALL_AUDIO_ROUTE_STATS__ROUTE_DEST__CALL_AUDIO_WIRED_HEADSET;
+                case TYPE_SPEAKER:
+                    return isSource ? CALL_AUDIO_ROUTE_STATS__ROUTE_SOURCE__CALL_AUDIO_PHONE_SPEAKER
+                            : CALL_AUDIO_ROUTE_STATS__ROUTE_DEST__CALL_AUDIO_PHONE_SPEAKER;
+                case TYPE_BLUETOOTH_LE:
+                    return isSource ? CALL_AUDIO_ROUTE_STATS__ROUTE_SOURCE__CALL_AUDIO_BLUETOOTH_LE
+                            : CALL_AUDIO_ROUTE_STATS__ROUTE_DEST__CALL_AUDIO_BLUETOOTH_LE;
+                case TYPE_BLUETOOTH_SCO:
+                    if (isSource) {
+                        return route.isWatch()
+                                ? CALL_AUDIO_ROUTE_STATS__ROUTE_SOURCE__CALL_AUDIO_WATCH_SPEAKER
+                                : CALL_AUDIO_ROUTE_STATS__ROUTE_SOURCE__CALL_AUDIO_BLUETOOTH;
+                    } else {
+                        return route.isWatch()
+                                ? CALL_AUDIO_ROUTE_STATS__ROUTE_DEST__CALL_AUDIO_WATCH_SPEAKER
+                                : CALL_AUDIO_ROUTE_STATS__ROUTE_DEST__CALL_AUDIO_BLUETOOTH;
+                    }
+                case TYPE_BLUETOOTH_HA:
+                    return isSource ? CALL_AUDIO_ROUTE_STATS__ROUTE_SOURCE__CALL_AUDIO_HEARING_AID
+                            : CALL_AUDIO_ROUTE_STATS__ROUTE_DEST__CALL_AUDIO_HEARING_AID;
+                case TYPE_DOCK:
+                    // Reserved for the future
+                case TYPE_STREAMING:
+                    // Reserved for the future
+                default:
+                    break;
+            }
+        }
+
+        return isSource ? CALL_AUDIO_ROUTE_STATS__ROUTE_SOURCE__CALL_AUDIO_UNSPECIFIED
+                : CALL_AUDIO_ROUTE_STATS__ROUTE_DEST__CALL_AUDIO_UNSPECIFIED;
+    }
+
+    @Override
+    public void handleMessage(Message msg) {
+        switch (msg.what) {
+            case EVENT_REVERT_THRESHOLD_EXPIRED:
+                onLog();
+                break;
+            default:
+                super.handleMessage(msg);
+        }
+    }
+
+    static class AudioRouteStatsKey {
+
+        final int mSource;
+        int mDest;
+        boolean mIsSuccess;
+        boolean mIsRevert;
+
+        AudioRouteStatsKey(int source, int dest) {
+            mSource = source;
+            mDest = dest;
+        }
+
+        AudioRouteStatsKey(int source, int dest, boolean isSuccess, boolean isRevert) {
+            mSource = source;
+            mDest = dest;
+            mIsSuccess = isSuccess;
+            mIsRevert = isRevert;
+        }
+
+        void setDestType(int dest) {
+            mDest = dest;
+        }
+
+        void setSuccess(boolean isSuccess) {
+            mIsSuccess = isSuccess;
+        }
+
+        void setRevert(boolean isRevert) {
+            mIsRevert = isRevert;
+        }
+
+        int getSource() {
+            return mSource;
+        }
+
+        @Override
+        public boolean equals(Object other) {
+            if (this == other) {
+                return true;
+            }
+            if (!(other instanceof AudioRouteStatsKey obj)) {
+                return false;
+            }
+            return this.mSource == obj.mSource && this.mDest == obj.mDest
+                    && this.mIsSuccess == obj.mIsSuccess && this.mIsRevert == obj.mIsRevert;
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(mSource, mDest, mIsSuccess, mIsRevert);
+        }
+
+        @Override
+        public String toString() {
+            return "[AudioRouteStatsKey: mSource=" + mSource + ", mDest=" + mDest
+                    + ", mIsSuccess=" + mIsSuccess + ", mIsRevert=" + mIsRevert + "]";
+        }
+    }
+
+    static class AudioRouteStatsData {
+
+        int mCount;
+        int mAverageLatency;
+
+        AudioRouteStatsData(int count, int averageLatency) {
+            mCount = count;
+            mAverageLatency = averageLatency;
+        }
+
+        void add(int latency) {
+            mCount++;
+            mAverageLatency += (latency - mAverageLatency) / mCount;
+        }
+
+        @Override
+        public String toString() {
+            return "[AudioRouteStatsData: mCount=" + mCount + ", mAverageLatency:"
+                    + mAverageLatency + "]";
+        }
+    }
+}
diff --git a/src/com/android/server/telecom/metrics/CallStats.java b/src/com/android/server/telecom/metrics/CallStats.java
new file mode 100644
index 0000000..39b0e6d
--- /dev/null
+++ b/src/com/android/server/telecom/metrics/CallStats.java
@@ -0,0 +1,264 @@
+/*
+ * Copyright (C) 2024 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.server.telecom.metrics;
+
+import static com.android.server.telecom.TelecomStatsLog.CALL_STATS;
+import static com.android.server.telecom.TelecomStatsLog.CALL_STATS__ACCOUNT_TYPE__ACCOUNT_MANAGED;
+import static com.android.server.telecom.TelecomStatsLog.CALL_STATS__ACCOUNT_TYPE__ACCOUNT_SELFMANAGED;
+import static com.android.server.telecom.TelecomStatsLog.CALL_STATS__ACCOUNT_TYPE__ACCOUNT_SIM;
+import static com.android.server.telecom.TelecomStatsLog.CALL_STATS__ACCOUNT_TYPE__ACCOUNT_UNKNOWN;
+import static com.android.server.telecom.TelecomStatsLog.CALL_STATS__ACCOUNT_TYPE__ACCOUNT_VOIP_API;
+import static com.android.server.telecom.TelecomStatsLog.CALL_STATS__CALL_DIRECTION__DIR_INCOMING;
+import static com.android.server.telecom.TelecomStatsLog.CALL_STATS__CALL_DIRECTION__DIR_OUTGOING;
+import static com.android.server.telecom.TelecomStatsLog.CALL_STATS__CALL_DIRECTION__DIR_UNKNOWN;
+
+import android.annotation.NonNull;
+import android.app.StatsManager;
+import android.content.Context;
+import android.os.Looper;
+import android.telecom.Log;
+import android.telecom.PhoneAccount;
+import android.util.StatsEvent;
+
+import androidx.annotation.VisibleForTesting;
+
+import com.android.server.telecom.Call;
+import com.android.server.telecom.TelecomStatsLog;
+import com.android.server.telecom.nano.PulledAtomsClass;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+public class CallStats extends TelecomPulledAtom {
+    private static final String TAG = CallStats.class.getSimpleName();
+
+    private static final String FILE_NAME = "call_stats";
+    private final Set<String> mOngoingCallsWithoutMultipleAudioDevices = new HashSet<>();
+    private final Set<String> mOngoingCallsWithMultipleAudioDevices = new HashSet<>();
+    private Map<CallStatsKey, CallStatsData> mCallStatsMap;
+    private boolean mHasMultipleAudioDevices;
+
+    public CallStats(@NonNull Context context, @NonNull Looper looper) {
+        super(context, looper);
+    }
+
+    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+    @Override
+    public int getTag() {
+        return CALL_STATS;
+    }
+
+    @Override
+    protected String getFileName() {
+        return FILE_NAME;
+    }
+
+    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+    @Override
+    public synchronized int onPull(final List<StatsEvent> data) {
+        if (mPulledAtoms.callStats.length != 0) {
+            Arrays.stream(mPulledAtoms.callStats).forEach(v -> data.add(
+                    TelecomStatsLog.buildStatsEvent(getTag(),
+                            v.getCallDirection(), v.getExternalCall(), v.getEmergencyCall(),
+                            v.getMultipleAudioAvailable(), v.getAccountType(), v.getUid(),
+                            v.getCount(), v.getAverageDurationMs())));
+            return StatsManager.PULL_SUCCESS;
+        } else {
+            return StatsManager.PULL_SKIP;
+        }
+    }
+
+    @Override
+    protected synchronized void onLoad() {
+        if (mPulledAtoms.callStats != null) {
+            mCallStatsMap = new HashMap<>();
+            for (PulledAtomsClass.CallStats v : mPulledAtoms.callStats) {
+                mCallStatsMap.put(new CallStatsKey(v.getCallDirection(),
+                                v.getExternalCall(), v.getEmergencyCall(),
+                                v.getMultipleAudioAvailable(),
+                                v.getAccountType(), v.getUid()),
+                        new CallStatsData(v.getCount(), v.getAverageDurationMs()));
+            }
+            mLastPulledTimestamps = mPulledAtoms.getCallStatsPullTimestampMillis();
+        }
+    }
+
+    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+    @Override
+    public synchronized void onAggregate() {
+        Log.d(TAG, "onAggregate: %s", mCallStatsMap);
+        clearAtoms();
+        if (mCallStatsMap.isEmpty()) {
+            return;
+        }
+        mPulledAtoms.setCallStatsPullTimestampMillis(mLastPulledTimestamps);
+        mPulledAtoms.callStats = new PulledAtomsClass.CallStats[mCallStatsMap.size()];
+        int[] index = new int[1];
+        mCallStatsMap.forEach((k, v) -> {
+            mPulledAtoms.callStats[index[0]] = new PulledAtomsClass.CallStats();
+            mPulledAtoms.callStats[index[0]].setCallDirection(k.mDirection);
+            mPulledAtoms.callStats[index[0]].setExternalCall(k.mIsExternal);
+            mPulledAtoms.callStats[index[0]].setEmergencyCall(k.mIsEmergency);
+            mPulledAtoms.callStats[index[0]].setMultipleAudioAvailable(k.mIsMultipleAudioAvailable);
+            mPulledAtoms.callStats[index[0]].setAccountType(k.mAccountType);
+            mPulledAtoms.callStats[index[0]].setUid(k.mUid);
+            mPulledAtoms.callStats[index[0]].setCount(v.mCount);
+            mPulledAtoms.callStats[index[0]].setAverageDurationMs(v.mAverageDuration);
+            index[0]++;
+        });
+        save(DELAY_FOR_PERSISTENT_MILLIS);
+    }
+
+    public void log(int direction, boolean isExternal, boolean isEmergency,
+                    boolean isMultipleAudioAvailable, int accountType, int uid, int duration) {
+        post(() -> {
+            CallStatsKey key = new CallStatsKey(direction, isExternal, isEmergency,
+                    isMultipleAudioAvailable, accountType, uid);
+            CallStatsData data = mCallStatsMap.computeIfAbsent(key, k -> new CallStatsData(0, 0));
+            data.add(duration);
+            onAggregate();
+        });
+    }
+
+    public void onCallStart(Call call) {
+        post(() -> {
+            if (mHasMultipleAudioDevices) {
+                mOngoingCallsWithMultipleAudioDevices.add(call.getId());
+            } else {
+                mOngoingCallsWithoutMultipleAudioDevices.add(call.getId());
+            }
+        });
+    }
+
+    public void onCallEnd(Call call) {
+        final int duration = (int) (call.getAgeMillis());
+        post(() -> {
+            final boolean hasMultipleAudioDevices = mOngoingCallsWithMultipleAudioDevices.remove(
+                    call.getId());
+            final int direction = call.isIncoming() ? CALL_STATS__CALL_DIRECTION__DIR_INCOMING
+                    : (call.isOutgoing() ? CALL_STATS__CALL_DIRECTION__DIR_OUTGOING
+                    : CALL_STATS__CALL_DIRECTION__DIR_UNKNOWN);
+            final int accountType = getAccountType(call.getPhoneAccountFromHandle());
+            final int uid = call.getAssociatedUser().getIdentifier();
+            log(direction, call.isExternalCall(), call.isEmergencyCall(), hasMultipleAudioDevices,
+                    accountType, uid, duration);
+        });
+    }
+
+    private int getAccountType(PhoneAccount account) {
+        if (account.hasCapabilities(PhoneAccount.CAPABILITY_SELF_MANAGED)) {
+            return account.hasCapabilities(
+                    PhoneAccount.CAPABILITY_SUPPORTS_TRANSACTIONAL_OPERATIONS)
+                    ? CALL_STATS__ACCOUNT_TYPE__ACCOUNT_VOIP_API
+                    : CALL_STATS__ACCOUNT_TYPE__ACCOUNT_SELFMANAGED;
+        }
+        if (account.hasCapabilities(PhoneAccount.CAPABILITY_CALL_PROVIDER)) {
+            return account.hasCapabilities(
+                    PhoneAccount.CAPABILITY_SIM_SUBSCRIPTION)
+                    ? CALL_STATS__ACCOUNT_TYPE__ACCOUNT_SIM
+                    : CALL_STATS__ACCOUNT_TYPE__ACCOUNT_MANAGED;
+        }
+        return CALL_STATS__ACCOUNT_TYPE__ACCOUNT_UNKNOWN;
+    }
+
+    public void onAudioDevicesChange(boolean hasMultipleAudioDevices) {
+        post(() -> {
+            if (mHasMultipleAudioDevices != hasMultipleAudioDevices) {
+                mHasMultipleAudioDevices = hasMultipleAudioDevices;
+                if (mHasMultipleAudioDevices) {
+                    mOngoingCallsWithMultipleAudioDevices.addAll(
+                            mOngoingCallsWithoutMultipleAudioDevices);
+                    mOngoingCallsWithoutMultipleAudioDevices.clear();
+                }
+            }
+        });
+    }
+
+    static class CallStatsKey {
+        final int mDirection;
+        final boolean mIsExternal;
+        final boolean mIsEmergency;
+        final boolean mIsMultipleAudioAvailable;
+        final int mAccountType;
+        final int mUid;
+
+        CallStatsKey(int direction, boolean isExternal, boolean isEmergency,
+                     boolean isMultipleAudioAvailable, int accountType, int uid) {
+            mDirection = direction;
+            mIsExternal = isExternal;
+            mIsEmergency = isEmergency;
+            mIsMultipleAudioAvailable = isMultipleAudioAvailable;
+            mAccountType = accountType;
+            mUid = uid;
+        }
+
+        @Override
+        public boolean equals(Object other) {
+            if (this == other) {
+                return true;
+            }
+            if (!(other instanceof CallStatsKey obj)) {
+                return false;
+            }
+            return this.mDirection == obj.mDirection && this.mIsExternal == obj.mIsExternal
+                    && this.mIsEmergency == obj.mIsEmergency
+                    && this.mIsMultipleAudioAvailable == obj.mIsMultipleAudioAvailable
+                    && this.mAccountType == obj.mAccountType && this.mUid == obj.mUid;
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(mDirection, mIsExternal, mIsEmergency, mIsMultipleAudioAvailable,
+                    mAccountType, mUid);
+        }
+
+        @Override
+        public String toString() {
+            return "[CallStatsKey: mDirection=" + mDirection + ", mIsExternal=" + mIsExternal
+                    + ", mIsEmergency=" + mIsEmergency + ", mIsMultipleAudioAvailable="
+                    + mIsMultipleAudioAvailable + ", mAccountType=" + mAccountType + ", mUid="
+                    + mUid + "]";
+        }
+    }
+
+    static class CallStatsData {
+
+        int mCount;
+        int mAverageDuration;
+
+        CallStatsData(int count, int averageDuration) {
+            mCount = count;
+            mAverageDuration = averageDuration;
+        }
+
+        void add(int duration) {
+            mCount++;
+            mAverageDuration += (duration - mAverageDuration) / mCount;
+        }
+
+        @Override
+        public String toString() {
+            return "[CallStatsData: mCount=" + mCount + ", mAverageDuration:" + mAverageDuration
+                    + "]";
+        }
+    }
+}
diff --git a/src/com/android/server/telecom/metrics/ErrorStats.java b/src/com/android/server/telecom/metrics/ErrorStats.java
new file mode 100644
index 0000000..e4d0a51
--- /dev/null
+++ b/src/com/android/server/telecom/metrics/ErrorStats.java
@@ -0,0 +1,143 @@
+/*
+ * Copyright (C) 2024 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.server.telecom.metrics;
+
+import static com.android.server.telecom.TelecomStatsLog.TELECOM_ERROR_STATS;
+
+import android.annotation.NonNull;
+import android.app.StatsManager;
+import android.content.Context;
+import android.os.Looper;
+import android.util.StatsEvent;
+
+import androidx.annotation.VisibleForTesting;
+
+import com.android.server.telecom.TelecomStatsLog;
+import com.android.server.telecom.nano.PulledAtomsClass;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+public class ErrorStats extends TelecomPulledAtom {
+
+    private static final String FILE_NAME = "error_stats";
+    private Map<ErrorStatsKey, Integer> mErrorStatsMap;
+
+    public ErrorStats(@NonNull Context context, @NonNull Looper looper) {
+        super(context, looper);
+    }
+
+    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+    @Override
+    public int getTag() {
+        return TELECOM_ERROR_STATS;
+    }
+
+    @Override
+    protected String getFileName() {
+        return FILE_NAME;
+    }
+
+    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+    @Override
+    public synchronized int onPull(final List<StatsEvent> data) {
+        if (mPulledAtoms.telecomErrorStats.length != 0) {
+            Arrays.stream(mPulledAtoms.telecomErrorStats).forEach(v -> data.add(
+                    TelecomStatsLog.buildStatsEvent(getTag(),
+                            v.getSubmoduleName(), v.getErrorName(), v.getCount())));
+            return StatsManager.PULL_SUCCESS;
+        } else {
+            return StatsManager.PULL_SKIP;
+        }
+    }
+
+    @Override
+    protected synchronized void onLoad() {
+        if (mPulledAtoms.telecomErrorStats != null) {
+            mErrorStatsMap = new HashMap<>();
+            for (PulledAtomsClass.TelecomErrorStats v : mPulledAtoms.telecomErrorStats) {
+                mErrorStatsMap.put(new ErrorStatsKey(v.getSubmoduleName(), v.getErrorName()),
+                        v.getCount());
+            }
+            mLastPulledTimestamps = mPulledAtoms.getTelecomErrorStatsPullTimestampMillis();
+        }
+    }
+
+    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+    @Override
+    public synchronized void onAggregate() {
+        clearAtoms();
+        if (mErrorStatsMap.isEmpty()) {
+            return;
+        }
+        mPulledAtoms.setTelecomErrorStatsPullTimestampMillis(mLastPulledTimestamps);
+        mPulledAtoms.telecomErrorStats =
+                new PulledAtomsClass.TelecomErrorStats[mErrorStatsMap.size()];
+        int[] index = new int[1];
+        mErrorStatsMap.forEach((k, v) -> {
+            mPulledAtoms.telecomErrorStats[index[0]] = new PulledAtomsClass.TelecomErrorStats();
+            mPulledAtoms.telecomErrorStats[index[0]].setSubmoduleName(k.mModuleId);
+            mPulledAtoms.telecomErrorStats[index[0]].setErrorName(k.mErrorId);
+            mPulledAtoms.telecomErrorStats[index[0]].setCount(v);
+            index[0]++;
+        });
+        save(DELAY_FOR_PERSISTENT_MILLIS);
+    }
+
+    public void log(int moduleId, int errorId) {
+        post(() -> {
+            ErrorStatsKey key = new ErrorStatsKey(moduleId, errorId);
+            mErrorStatsMap.put(key, mErrorStatsMap.getOrDefault(key, 0) + 1);
+            onAggregate();
+        });
+    }
+
+    static class ErrorStatsKey {
+
+        final int mModuleId;
+        final int mErrorId;
+
+        ErrorStatsKey(int moduleId, int errorId) {
+            mModuleId = moduleId;
+            mErrorId = errorId;
+        }
+
+        @Override
+        public boolean equals(Object other) {
+            if (this == other) {
+                return true;
+            }
+            if (!(other instanceof ErrorStatsKey obj)) {
+                return false;
+            }
+            return this.mModuleId == obj.mModuleId && this.mErrorId == obj.mErrorId;
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(mModuleId, mErrorId);
+        }
+
+        @Override
+        public String toString() {
+            return "[ErrorStatsKey: mModuleId=" + mModuleId + ", mErrorId=" + mErrorId + "]";
+        }
+    }
+}
diff --git a/src/com/android/server/telecom/metrics/TelecomMetricsController.java b/src/com/android/server/telecom/metrics/TelecomMetricsController.java
new file mode 100644
index 0000000..8903b02
--- /dev/null
+++ b/src/com/android/server/telecom/metrics/TelecomMetricsController.java
@@ -0,0 +1,132 @@
+/*
+ * Copyright (C) 2024 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.server.telecom.metrics;
+
+import static com.android.server.telecom.TelecomStatsLog.CALL_AUDIO_ROUTE_STATS;
+import static com.android.server.telecom.TelecomStatsLog.CALL_STATS;
+import static com.android.server.telecom.TelecomStatsLog.TELECOM_API_STATS;
+import static com.android.server.telecom.TelecomStatsLog.TELECOM_ERROR_STATS;
+
+import android.annotation.NonNull;
+import android.app.StatsManager;
+import android.content.Context;
+import android.os.HandlerThread;
+import android.telecom.Log;
+import android.util.StatsEvent;
+
+import androidx.annotation.VisibleForTesting;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+
+public class TelecomMetricsController implements StatsManager.StatsPullAtomCallback {
+
+    private static final String TAG = TelecomMetricsController.class.getSimpleName();
+
+    private final Context mContext;
+    private final HandlerThread mHandlerThread;
+    private final ConcurrentHashMap<Integer, TelecomPulledAtom> mStats = new ConcurrentHashMap<>();
+
+    private TelecomMetricsController(@NonNull Context context,
+                                     @NonNull HandlerThread handlerThread) {
+        mContext = context;
+        mHandlerThread = handlerThread;
+    }
+
+    @NonNull
+    public static TelecomMetricsController make(@NonNull Context context) {
+        Log.i(TAG, "TMC.iN1");
+        HandlerThread handlerThread = new HandlerThread(TAG);
+        handlerThread.start();
+        return make(context, handlerThread);
+    }
+
+    @VisibleForTesting
+    @NonNull
+    public static TelecomMetricsController make(@NonNull Context context,
+                                                @NonNull HandlerThread handlerThread) {
+        Log.i(TAG, "TMC.iN2");
+        Objects.requireNonNull(context);
+        Objects.requireNonNull(handlerThread);
+        return new TelecomMetricsController(context, handlerThread);
+    }
+
+    @NonNull
+    public ApiStats getApiStats() {
+        ApiStats stats = (ApiStats) mStats.get(TELECOM_API_STATS);
+        if (stats == null) {
+            stats = new ApiStats(mContext, mHandlerThread.getLooper());
+            registerAtom(stats.getTag(), stats);
+        }
+        return stats;
+    }
+
+    @NonNull
+    public AudioRouteStats getAudioRouteStats() {
+        AudioRouteStats stats = (AudioRouteStats) mStats.get(CALL_AUDIO_ROUTE_STATS);
+        if (stats == null) {
+            stats = new AudioRouteStats(mContext, mHandlerThread.getLooper());
+            registerAtom(stats.getTag(), stats);
+        }
+        return stats;
+    }
+
+    @NonNull
+    public CallStats getCallStats() {
+        CallStats stats = (CallStats) mStats.get(CALL_STATS);
+        if (stats == null) {
+            stats = new CallStats(mContext, mHandlerThread.getLooper());
+            registerAtom(stats.getTag(), stats);
+        }
+        return stats;
+    }
+
+    @NonNull
+    public ErrorStats getErrorStats() {
+        ErrorStats stats = (ErrorStats) mStats.get(TELECOM_ERROR_STATS);
+        if (stats == null) {
+            stats = new ErrorStats(mContext, mHandlerThread.getLooper());
+            registerAtom(stats.getTag(), stats);
+        }
+        return stats;
+    }
+
+    @Override
+    public int onPullAtom(final int atomTag, final List<StatsEvent> data) {
+        if (mStats.containsKey(atomTag)) {
+            return Objects.requireNonNull(mStats.get(atomTag)).pull(data);
+        }
+        return StatsManager.PULL_SKIP;
+    }
+
+    @VisibleForTesting
+    public Map<Integer, TelecomPulledAtom> getStats() {
+        return mStats;
+    }
+
+    @VisibleForTesting
+    public void registerAtom(int tag, TelecomPulledAtom atom) {
+        mStats.put(tag, atom);
+    }
+
+    public void destroy() {
+        mStats.clear();
+        mHandlerThread.quitSafely();
+    }
+}
diff --git a/src/com/android/server/telecom/metrics/TelecomPulledAtom.java b/src/com/android/server/telecom/metrics/TelecomPulledAtom.java
new file mode 100644
index 0000000..d6eb039
--- /dev/null
+++ b/src/com/android/server/telecom/metrics/TelecomPulledAtom.java
@@ -0,0 +1,135 @@
+/*
+ * Copyright (C) 2024 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.server.telecom.metrics;
+
+import android.annotation.NonNull;
+import android.app.StatsManager;
+import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.telecom.Log;
+import android.util.StatsEvent;
+
+import androidx.annotation.VisibleForTesting;
+
+import com.android.server.telecom.nano.PulledAtomsClass.PulledAtoms;
+
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
+import java.util.List;
+
+public abstract class TelecomPulledAtom extends Handler {
+    /**
+     * Min interval to persist the data.
+     */
+    protected static final int DELAY_FOR_PERSISTENT_MILLIS = 30000;
+    protected static final int EVENT_SUB_BASE = 1000;
+    private static final String TAG = TelecomPulledAtom.class.getSimpleName();
+    private static final long MIN_PULL_INTERVAL_MILLIS = 23L * 60 * 60 * 1000;
+    private static final int EVENT_SAVE = 1;
+    private final Context mContext;
+    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+    public PulledAtoms mPulledAtoms;
+    protected long mLastPulledTimestamps;
+
+    protected TelecomPulledAtom(@NonNull Context context, @NonNull Looper looper) {
+        super(looper);
+        mContext = context;
+        mPulledAtoms = loadAtomsFromFile();
+        onLoad();
+    }
+
+    public synchronized int pull(final List<StatsEvent> data) {
+        long cur = System.currentTimeMillis();
+        if (cur - mLastPulledTimestamps < MIN_PULL_INTERVAL_MILLIS) {
+            return StatsManager.PULL_SKIP;
+        }
+        mLastPulledTimestamps = cur;
+        return onPull(data);
+    }
+
+    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+    abstract public int getTag();
+
+    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+    public abstract int onPull(List<StatsEvent> data);
+
+    protected abstract void onLoad();
+
+    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+    public abstract void onAggregate();
+
+    public void onFlush() {
+        save(0);
+    }
+
+    protected abstract String getFileName();
+
+    private synchronized PulledAtoms loadAtomsFromFile() {
+        try {
+            return
+                    PulledAtoms.parseFrom(
+                            Files.readAllBytes(mContext.getFileStreamPath(getFileName()).toPath()));
+        } catch (NoSuchFileException e) {
+            Log.e(TAG, e, "the atom file not found");
+        } catch (IOException | NullPointerException e) {
+            Log.e(TAG, e, "cannot load/parse the atom file");
+        }
+        return makeNewPulledAtoms();
+    }
+
+    protected synchronized void clearAtoms() {
+        mPulledAtoms = makeNewPulledAtoms();
+    }
+
+    private synchronized void onSave() {
+        try (FileOutputStream stream = mContext.openFileOutput(getFileName(),
+                Context.MODE_PRIVATE)) {
+            Log.d(TAG, "save " + getTag());
+            stream.write(PulledAtoms.toByteArray(mPulledAtoms));
+        } catch (IOException e) {
+            Log.e(TAG, e, "cannot save the atom to file");
+        } catch (UnsupportedOperationException e) {
+            Log.e(TAG, e, "cannot open the file");
+        }
+    }
+
+    private PulledAtoms makeNewPulledAtoms() {
+        return new PulledAtoms();
+    }
+
+    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
+    public void save(int delayMillis) {
+        if (delayMillis > 0) {
+            if (!hasMessages(EVENT_SAVE)) {
+                sendMessageDelayed(obtainMessage(EVENT_SAVE), delayMillis);
+            }
+        } else {
+            onSave();
+        }
+    }
+
+    @Override
+    public void handleMessage(Message msg) {
+        if (msg.what == EVENT_SAVE) {
+            onSave();
+        }
+    }
+}
diff --git a/tests/src/com/android/server/telecom/tests/CallAnomalyWatchdogTest.java b/tests/src/com/android/server/telecom/tests/CallAnomalyWatchdogTest.java
index a6f63bc..d608d0a 100644
--- a/tests/src/com/android/server/telecom/tests/CallAnomalyWatchdogTest.java
+++ b/tests/src/com/android/server/telecom/tests/CallAnomalyWatchdogTest.java
@@ -45,6 +45,7 @@
 import com.android.server.telecom.PhoneNumberUtilsAdapter;
 import com.android.server.telecom.TelecomSystem;
 import com.android.server.telecom.Timeouts;
+import com.android.server.telecom.metrics.TelecomMetricsController;
 import com.android.server.telecom.ui.ToastFactory;
 
 import org.junit.After;
@@ -90,6 +91,7 @@
     @Mock private AnomalyReporterAdapter mAnomalyReporterAdapter;
 
     @Mock private EmergencyCallDiagnosticLogger mMockEmergencyCallDiagnosticLogger;
+    @Mock private TelecomMetricsController mMockTelecomMetricsController;
 
     @Override
     @Before
@@ -122,7 +124,8 @@
         doReturn(new ComponentName(mContext, CallTest.class))
                 .when(mMockConnectionService).getComponentName();
         mCallAnomalyWatchdog = new CallAnomalyWatchdog(mTestScheduledExecutorService, mLock,
-                mFeatureFlags, mTimeouts, mMockClockProxy, mMockEmergencyCallDiagnosticLogger);
+                mFeatureFlags, mTimeouts, mMockClockProxy, mMockEmergencyCallDiagnosticLogger,
+                mMockTelecomMetricsController);
         mCallAnomalyWatchdog.setAnomalyReporterAdapter(mAnomalyReporterAdapter);
         when(mMockCallsManager.getCurrentUserHandle()).thenReturn(UserHandle.CURRENT);
     }
diff --git a/tests/src/com/android/server/telecom/tests/CallAudioRouteControllerTest.java b/tests/src/com/android/server/telecom/tests/CallAudioRouteControllerTest.java
index 1254b8a..ade2a22 100644
--- a/tests/src/com/android/server/telecom/tests/CallAudioRouteControllerTest.java
+++ b/tests/src/com/android/server/telecom/tests/CallAudioRouteControllerTest.java
@@ -85,6 +85,7 @@
 import com.android.server.telecom.WiredHeadsetManager;
 import com.android.server.telecom.bluetooth.BluetoothDeviceManager;
 import com.android.server.telecom.bluetooth.BluetoothRouteManager;
+import com.android.server.telecom.metrics.TelecomMetricsController;
 
 import org.junit.After;
 import org.junit.Before;
@@ -116,6 +117,7 @@
     @Mock CallAudioManager mCallAudioManager;
     @Mock Call mCall;
     @Mock private TelecomSystem.SyncRoot mLock;
+    @Mock private TelecomMetricsController mMockTelecomMetricsController;
     private AudioRoute mEarpieceRoute;
     private AudioRoute mSpeakerRoute;
     private boolean mOverrideSpeakerToBus;
@@ -174,8 +176,8 @@
                 .thenReturn(BLUETOOTH_DEVICE_1);
         when(mAudioDeviceInfo.getAddress()).thenReturn(BT_ADDRESS_1);
         mController = new CallAudioRouteController(mContext, mCallsManager, mAudioServiceFactory,
-                mAudioRouteFactory, mWiredHeadsetManager,
-                mBluetoothRouteManager, mockStatusBarNotifier, mFeatureFlags);
+                mAudioRouteFactory, mWiredHeadsetManager, mBluetoothRouteManager,
+                mockStatusBarNotifier, mFeatureFlags, mMockTelecomMetricsController);
         mController.setAudioRouteFactory(mAudioRouteFactory);
         mController.setAudioManager(mAudioManager);
         mEarpieceRoute = new AudioRoute(AudioRoute.TYPE_EARPIECE, null, null);
diff --git a/tests/src/com/android/server/telecom/tests/CallsManagerTest.java b/tests/src/com/android/server/telecom/tests/CallsManagerTest.java
index 74f33c3..c2acfd6 100644
--- a/tests/src/com/android/server/telecom/tests/CallsManagerTest.java
+++ b/tests/src/com/android/server/telecom/tests/CallsManagerTest.java
@@ -134,6 +134,7 @@
 import com.android.server.telecom.callfiltering.CallFilteringResult;
 import com.android.server.telecom.flags.FeatureFlags;
 import com.android.server.telecom.callfiltering.IncomingCallFilterGraph;
+import com.android.server.telecom.metrics.TelecomMetricsController;
 import com.android.server.telecom.ui.AudioProcessingNotification;
 import com.android.server.telecom.ui.CallStreamingNotification;
 import com.android.server.telecom.ui.DisconnectedCallNotifier;
@@ -315,6 +316,7 @@
     @Mock private IncomingCallFilterGraph mIncomingCallFilterGraph;
     @Mock private Context mMockCreateContextAsUser;
     @Mock private UserManager mMockCurrentUserManager;
+    @Mock private TelecomMetricsController mMockTelecomMetricsController;
     private CallsManager mCallsManager;
 
     @Override
@@ -393,7 +395,8 @@
                 mFeatureFlags,
                 mTelephonyFlags,
                 (call, listener, context, timeoutsAdapter,
-                        mFeatureFlags, lock) -> mIncomingCallFilterGraph);
+                        mFeatureFlags, lock) -> mIncomingCallFilterGraph,
+                mMockTelecomMetricsController);
 
         when(mPhoneAccountRegistrar.getPhoneAccount(
                 eq(SELF_MANAGED_HANDLE), any())).thenReturn(SELF_MANAGED_ACCOUNT);
diff --git a/tests/src/com/android/server/telecom/tests/TelecomMetricsControllerTest.java b/tests/src/com/android/server/telecom/tests/TelecomMetricsControllerTest.java
new file mode 100644
index 0000000..e2ab8d6
--- /dev/null
+++ b/tests/src/com/android/server/telecom/tests/TelecomMetricsControllerTest.java
@@ -0,0 +1,157 @@
+/*
+ * Copyright (C) 2024 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.server.telecom.tests;
+
+import static com.android.server.telecom.TelecomStatsLog.CALL_AUDIO_ROUTE_STATS;
+import static com.android.server.telecom.TelecomStatsLog.CALL_STATS;
+import static com.android.server.telecom.TelecomStatsLog.TELECOM_API_STATS;
+import static com.android.server.telecom.TelecomStatsLog.TELECOM_ERROR_STATS;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import android.app.StatsManager;
+import android.os.HandlerThread;
+import android.util.StatsEvent;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import com.android.server.telecom.metrics.ApiStats;
+import com.android.server.telecom.metrics.AudioRouteStats;
+import com.android.server.telecom.metrics.CallStats;
+import com.android.server.telecom.metrics.ErrorStats;
+import com.android.server.telecom.metrics.TelecomMetricsController;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@RunWith(AndroidJUnit4.class)
+public class TelecomMetricsControllerTest extends TelecomTestCase {
+
+    @Mock
+    ApiStats mApiStats;
+    @Mock
+    AudioRouteStats mAudioRouteStats;
+    @Mock
+    CallStats mCallStats;
+    @Mock
+    ErrorStats mErrorStats;
+
+    HandlerThread mHandlerThread;
+
+    TelecomMetricsController mTelecomMetricsController;
+
+    @Override
+    @Before
+    public void setUp() throws Exception {
+        super.setUp();
+        mHandlerThread = new HandlerThread("TelecomMetricsControllerTest");
+        mHandlerThread.start();
+        mTelecomMetricsController = TelecomMetricsController.make(mContext, mHandlerThread);
+        assertThat(mTelecomMetricsController).isNotNull();
+        setUpStats();
+    }
+
+    @Override
+    @After
+    public void tearDown() throws Exception {
+        mTelecomMetricsController.destroy();
+        mHandlerThread.quitSafely();
+        super.tearDown();
+    }
+
+    @Test
+    public void testGetApiStatsReturnsSameInstance() {
+        ApiStats stats1 = mTelecomMetricsController.getApiStats();
+        ApiStats stats2 = mTelecomMetricsController.getApiStats();
+        assertThat(stats1).isSameInstanceAs(stats2);
+    }
+
+    @Test
+    public void testGetAudioRouteStatsReturnsSameInstance() {
+        AudioRouteStats stats1 = mTelecomMetricsController.getAudioRouteStats();
+        AudioRouteStats stats2 = mTelecomMetricsController.getAudioRouteStats();
+        assertThat(stats1).isSameInstanceAs(stats2);
+    }
+
+    @Test
+    public void testGetCallStatsReturnsSameInstance() {
+        CallStats stats1 = mTelecomMetricsController.getCallStats();
+        CallStats stats2 = mTelecomMetricsController.getCallStats();
+        assertThat(stats1).isSameInstanceAs(stats2);
+    }
+
+    @Test
+    public void testGetErrorStatsReturnsSameInstance() {
+        ErrorStats stats1 = mTelecomMetricsController.getErrorStats();
+        ErrorStats stats2 = mTelecomMetricsController.getErrorStats();
+        assertThat(stats1).isSameInstanceAs(stats2);
+    }
+
+    @Test
+    public void testOnPullAtomReturnsPullSkipIfAtomNotRegistered() {
+        mTelecomMetricsController.getStats().clear();
+
+        int result = mTelecomMetricsController.onPullAtom(TELECOM_API_STATS, null);
+        assertThat(result).isEqualTo(StatsManager.PULL_SKIP);
+    }
+
+    @Test
+    public void testRegisterAtomIsSameInstance() {
+        ApiStats stats = mock(ApiStats.class);
+
+        mTelecomMetricsController.registerAtom(TELECOM_API_STATS, stats);
+
+        assertThat(mTelecomMetricsController.getStats().get(TELECOM_API_STATS))
+                .isSameInstanceAs(stats);
+    }
+
+    @Test
+    public void testDestroy() {
+        mTelecomMetricsController.destroy();
+        assertThat(mTelecomMetricsController.getStats()).isEmpty();
+    }
+
+    @Test
+    public void testOnPullAtomIsPulled() {
+        final List<StatsEvent> data = new ArrayList<>();
+        final ArgumentCaptor<List<StatsEvent>> captor = ArgumentCaptor.forClass((Class) List.class);
+        doReturn(StatsManager.PULL_SUCCESS).when(mApiStats).pull(any());
+
+        int result = mTelecomMetricsController.onPullAtom(TELECOM_API_STATS, data);
+
+        verify(mApiStats).pull(captor.capture());
+        assertThat(result).isEqualTo(StatsManager.PULL_SUCCESS);
+        assertThat(captor.getValue()).isEqualTo(data);
+    }
+
+    private void setUpStats() {
+        mTelecomMetricsController.getStats().put(CALL_AUDIO_ROUTE_STATS,
+                mAudioRouteStats);
+        mTelecomMetricsController.getStats().put(CALL_STATS, mCallStats);
+        mTelecomMetricsController.getStats().put(TELECOM_API_STATS, mApiStats);
+        mTelecomMetricsController.getStats().put(TELECOM_ERROR_STATS, mErrorStats);
+    }
+}
diff --git a/tests/src/com/android/server/telecom/tests/TelecomPulledAtomTest.java b/tests/src/com/android/server/telecom/tests/TelecomPulledAtomTest.java
new file mode 100644
index 0000000..d188054
--- /dev/null
+++ b/tests/src/com/android/server/telecom/tests/TelecomPulledAtomTest.java
@@ -0,0 +1,815 @@
+/*
+ * Copyright (C) 2024 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.server.telecom.tests;
+
+import static com.android.server.telecom.AudioRoute.TYPE_BLUETOOTH_LE;
+import static com.android.server.telecom.AudioRoute.TYPE_EARPIECE;
+import static com.android.server.telecom.AudioRoute.TYPE_SPEAKER;
+import static com.android.server.telecom.TelecomStatsLog.CALL_AUDIO_ROUTE_STATS__ROUTE_SOURCE__CALL_AUDIO_BLUETOOTH_LE;
+import static com.android.server.telecom.TelecomStatsLog.CALL_AUDIO_ROUTE_STATS__ROUTE_SOURCE__CALL_AUDIO_EARPIECE;
+import static com.android.server.telecom.TelecomStatsLog.CALL_STATS__ACCOUNT_TYPE__ACCOUNT_SIM;
+import static com.android.server.telecom.TelecomStatsLog.CALL_STATS__CALL_DIRECTION__DIR_INCOMING;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.any;
+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.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.app.StatsManager;
+import android.content.Context;
+import android.os.Looper;
+import android.os.UserHandle;
+import android.telecom.PhoneAccount;
+import android.util.StatsEvent;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import com.android.server.telecom.AudioRoute;
+import com.android.server.telecom.Call;
+import com.android.server.telecom.PendingAudioRoute;
+import com.android.server.telecom.metrics.ApiStats;
+import com.android.server.telecom.metrics.AudioRouteStats;
+import com.android.server.telecom.metrics.CallStats;
+import com.android.server.telecom.metrics.ErrorStats;
+import com.android.server.telecom.nano.PulledAtomsClass;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+@RunWith(AndroidJUnit4.class)
+public class TelecomPulledAtomTest extends TelecomTestCase {
+    private static final long MIN_PULL_INTERVAL_MILLIS = 23L * 60 * 60 * 1000;
+    private static final long DEFAULT_TIMESTAMPS_MILLIS = 3000;
+    private static final int DELAY_FOR_PERSISTENT_MILLIS = 30000;
+    private static final int DELAY_TOLERANCE = 50;
+    private static final int TEST_TIMEOUT = (int) AudioRouteStats.THRESHOLD_REVERT_MS + 1000;
+    private static final String FILE_NAME_TEST_ATOM = "test_atom.pb";
+
+    private static final int VALUE_ATOM_COUNT = 1;
+
+    private static final int VALUE_UID = 10000 + 1;
+    private static final int VALUE_API_ID = 1;
+    private static final int VALUE_API_RESULT = 1;
+    private static final int VALUE_API_COUNT = 1;
+
+    private static final int VALUE_AUDIO_ROUTE_TYPE1 = 1;
+    private static final int VALUE_AUDIO_ROUTE_TYPE2 = 2;
+    private static final int VALUE_AUDIO_ROUTE_COUNT = 1;
+    private static final int VALUE_AUDIO_ROUTE_LATENCY = 300;
+
+    private static final int VALUE_CALL_DIRECTION = 1;
+    private static final int VALUE_CALL_ACCOUNT_TYPE = 1;
+    private static final int VALUE_CALL_COUNT = 1;
+    private static final int VALUE_CALL_DURATION = 3000;
+
+    private static final int VALUE_MODULE_ID = 1;
+    private static final int VALUE_ERROR_ID = 1;
+    private static final int VALUE_ERROR_COUNT = 1;
+
+    @Rule
+    public TemporaryFolder mTempFolder = new TemporaryFolder();
+    @Mock
+    FileOutputStream mFileOutputStream;
+    @Mock
+    PendingAudioRoute mMockPendingAudioRoute;
+    @Mock
+    AudioRoute mMockSourceRoute;
+    @Mock
+    AudioRoute mMockDestRoute;
+    private File mTempFile;
+    private Looper mLooper;
+    private Context mSpyContext;
+
+    @Before
+    @Override
+    public void setUp() throws Exception {
+        super.setUp();
+
+        mSpyContext = spy(mContext);
+        mLooper = Looper.getMainLooper();
+        mTempFile = mTempFolder.newFile(FILE_NAME_TEST_ATOM);
+        doReturn(mTempFile).when(mSpyContext).getFileStreamPath(anyString());
+        doReturn(mFileOutputStream).when(mSpyContext).openFileOutput(anyString(), anyInt());
+        doReturn(false).when(mMockPendingAudioRoute).isFailed();
+        doReturn(mMockSourceRoute).when(mMockPendingAudioRoute).getOrigRoute();
+        doReturn(mMockDestRoute).when(mMockPendingAudioRoute).getDestRoute();
+        doReturn(TYPE_EARPIECE).when(mMockSourceRoute).getType();
+        doReturn(TYPE_BLUETOOTH_LE).when(mMockDestRoute).getType();
+    }
+
+    @After
+    @Override
+    public void tearDown() throws Exception {
+        mTempFile.delete();
+        super.tearDown();
+    }
+
+    @Test
+    public void testNewPulledAtomsFromFileInvalid() throws Exception {
+        mTempFile.delete();
+
+        ApiStats apiStats = new ApiStats(mSpyContext, mLooper);
+
+        assertNotNull(apiStats.mPulledAtoms);
+        assertEquals(apiStats.mPulledAtoms.telecomApiStats.length, 0);
+
+        AudioRouteStats audioRouteStats = new AudioRouteStats(mSpyContext, mLooper);
+
+        assertNotNull(audioRouteStats.mPulledAtoms);
+        assertEquals(audioRouteStats.mPulledAtoms.callAudioRouteStats.length, 0);
+
+        CallStats callStats = new CallStats(mSpyContext, mLooper);
+
+        assertNotNull(callStats.mPulledAtoms);
+        assertEquals(callStats.mPulledAtoms.callStats.length, 0);
+
+        ErrorStats errorStats = new ErrorStats(mSpyContext, mLooper);
+
+        assertNotNull(errorStats.mPulledAtoms);
+        assertEquals(errorStats.mPulledAtoms.telecomErrorStats.length, 0);
+    }
+
+    @Test
+    public void testNewPulledAtomsFromFileValid() throws Exception {
+        createTestFileForApiStats(DEFAULT_TIMESTAMPS_MILLIS);
+        ApiStats apiStats = new ApiStats(mSpyContext, mLooper);
+
+        verifyTestDataForApiStats(apiStats.mPulledAtoms, DEFAULT_TIMESTAMPS_MILLIS);
+
+        createTestFileForAudioRouteStats(DEFAULT_TIMESTAMPS_MILLIS);
+        AudioRouteStats audioRouteStats = new AudioRouteStats(mSpyContext, mLooper);
+
+        verifyTestDataForAudioRouteStats(audioRouteStats.mPulledAtoms, DEFAULT_TIMESTAMPS_MILLIS);
+
+        createTestFileForCallStats(DEFAULT_TIMESTAMPS_MILLIS);
+        CallStats callStats = new CallStats(mSpyContext, mLooper);
+
+        verifyTestDataForCallStats(callStats.mPulledAtoms, DEFAULT_TIMESTAMPS_MILLIS);
+
+        createTestFileForErrorStats(DEFAULT_TIMESTAMPS_MILLIS);
+        ErrorStats errorStats = new ErrorStats(mSpyContext, mLooper);
+
+        verifyTestDataForErrorStats(errorStats.mPulledAtoms, DEFAULT_TIMESTAMPS_MILLIS);
+    }
+
+    @Test
+    public void testPullApiStatsLessThanMinPullIntervalShouldSkip() throws Exception {
+        createTestFileForApiStats(System.currentTimeMillis() - MIN_PULL_INTERVAL_MILLIS / 2);
+        ApiStats apiStats = spy(new ApiStats(mSpyContext, mLooper));
+        final List<StatsEvent> data = new ArrayList<>();
+
+        int result = apiStats.pull(data);
+
+        assertEquals(StatsManager.PULL_SKIP, result);
+        verify(apiStats, never()).onPull(any());
+        assertEquals(data.size(), 0);
+    }
+
+    @Test
+    public void testPullApiStatsGreaterThanMinPullIntervalShouldNotSkip() throws Exception {
+        createTestFileForApiStats(System.currentTimeMillis() - MIN_PULL_INTERVAL_MILLIS - 1);
+        ApiStats apiStats = spy(new ApiStats(mSpyContext, mLooper));
+        final List<StatsEvent> data = new ArrayList<>();
+
+        int result = apiStats.pull(data);
+
+        assertEquals(StatsManager.PULL_SUCCESS, result);
+        verify(apiStats).onPull(eq(data));
+        assertEquals(data.size(), apiStats.mPulledAtoms.telecomApiStats.length);
+    }
+
+    @Test
+    public void testPullAudioRouteStatsLessThanMinPullIntervalShouldSkip() throws Exception {
+        createTestFileForAudioRouteStats(System.currentTimeMillis() - MIN_PULL_INTERVAL_MILLIS / 2);
+        AudioRouteStats audioRouteStats = spy(new AudioRouteStats(mSpyContext, mLooper));
+        final List<StatsEvent> data = new ArrayList<>();
+
+        int result = audioRouteStats.pull(data);
+
+        assertEquals(StatsManager.PULL_SKIP, result);
+        verify(audioRouteStats, never()).onPull(any());
+        assertEquals(data.size(), 0);
+    }
+
+    @Test
+    public void testPullAudioRouteStatsGreaterThanMinPullIntervalShouldNotSkip() throws Exception {
+        createTestFileForAudioRouteStats(System.currentTimeMillis() - MIN_PULL_INTERVAL_MILLIS - 1);
+        AudioRouteStats audioRouteStats = spy(new AudioRouteStats(mSpyContext, mLooper));
+        final List<StatsEvent> data = new ArrayList<>();
+
+        int result = audioRouteStats.pull(data);
+
+        assertEquals(StatsManager.PULL_SUCCESS, result);
+        verify(audioRouteStats).onPull(eq(data));
+        assertEquals(data.size(), audioRouteStats.mPulledAtoms.callAudioRouteStats.length);
+    }
+
+    @Test
+    public void testPullCallStatsLessThanMinPullIntervalShouldSkip() throws Exception {
+        createTestFileForCallStats(System.currentTimeMillis() - MIN_PULL_INTERVAL_MILLIS / 2);
+        CallStats callStats = spy(new CallStats(mSpyContext, mLooper));
+        final List<StatsEvent> data = new ArrayList<>();
+
+        int result = callStats.pull(data);
+
+        assertEquals(StatsManager.PULL_SKIP, result);
+        verify(callStats, never()).onPull(any());
+        assertEquals(data.size(), 0);
+    }
+
+    @Test
+    public void testPullCallStatsGreaterThanMinPullIntervalShouldNotSkip() throws Exception {
+        createTestFileForCallStats(System.currentTimeMillis() - MIN_PULL_INTERVAL_MILLIS - 1);
+        CallStats callStats = spy(new CallStats(mSpyContext, mLooper));
+        final List<StatsEvent> data = new ArrayList<>();
+
+        int result = callStats.pull(data);
+
+        assertEquals(StatsManager.PULL_SUCCESS, result);
+        verify(callStats).onPull(eq(data));
+        assertEquals(data.size(), callStats.mPulledAtoms.callStats.length);
+    }
+
+    @Test
+    public void testPullErrorStatsLessThanMinPullIntervalShouldSkip() throws Exception {
+        createTestFileForErrorStats(System.currentTimeMillis() - MIN_PULL_INTERVAL_MILLIS / 2);
+        ErrorStats errorStats = spy(new ErrorStats(mSpyContext, mLooper));
+        final List<StatsEvent> data = new ArrayList<>();
+
+        int result = errorStats.pull(data);
+
+        assertEquals(StatsManager.PULL_SKIP, result);
+        verify(errorStats, never()).onPull(any());
+        assertEquals(data.size(), 0);
+    }
+
+    @Test
+    public void testPullErrorStatsGreaterThanMinPullIntervalShouldNotSkip() throws Exception {
+        createTestFileForErrorStats(System.currentTimeMillis() - MIN_PULL_INTERVAL_MILLIS - 1);
+        ErrorStats errorStats = spy(new ErrorStats(mSpyContext, mLooper));
+        final List<StatsEvent> data = new ArrayList<>();
+
+        int result = errorStats.pull(data);
+
+        assertEquals(StatsManager.PULL_SUCCESS, result);
+        verify(errorStats).onPull(eq(data));
+        assertEquals(data.size(), errorStats.mPulledAtoms.telecomErrorStats.length);
+    }
+
+    @Test
+    public void testApiStatsLog() throws Exception {
+        ApiStats apiStats = spy(new ApiStats(mSpyContext, mLooper));
+
+        apiStats.log(VALUE_API_ID, VALUE_UID, VALUE_API_RESULT);
+        waitForHandlerAction(apiStats, TEST_TIMEOUT);
+
+        verify(apiStats, times(1)).onAggregate();
+        verify(apiStats, times(1)).save(eq(DELAY_FOR_PERSISTENT_MILLIS));
+        assertEquals(apiStats.mPulledAtoms.telecomApiStats.length, 1);
+        verifyMessageForApiStats(apiStats.mPulledAtoms.telecomApiStats[0], VALUE_API_ID,
+                VALUE_UID, VALUE_API_RESULT, 1);
+
+        apiStats.log(VALUE_API_ID, VALUE_UID, VALUE_API_RESULT);
+        waitForHandlerAction(apiStats, TEST_TIMEOUT);
+
+        verify(apiStats, times(2)).onAggregate();
+        verify(apiStats, times(2)).save(eq(DELAY_FOR_PERSISTENT_MILLIS));
+        assertEquals(apiStats.mPulledAtoms.telecomApiStats.length, 1);
+        verifyMessageForApiStats(apiStats.mPulledAtoms.telecomApiStats[0], VALUE_API_ID,
+                VALUE_UID, VALUE_API_RESULT, 2);
+    }
+
+    @Test
+    public void testAudioRouteStatsLog() throws Exception {
+        AudioRouteStats audioRouteStats = spy(new AudioRouteStats(mSpyContext, mLooper));
+
+        audioRouteStats.log(VALUE_AUDIO_ROUTE_TYPE1, VALUE_AUDIO_ROUTE_TYPE2, true, false,
+                VALUE_AUDIO_ROUTE_LATENCY);
+        waitForHandlerAction(audioRouteStats, TEST_TIMEOUT);
+
+        verify(audioRouteStats, times(1)).onAggregate();
+        verify(audioRouteStats, times(1)).save(eq(DELAY_FOR_PERSISTENT_MILLIS));
+        assertEquals(audioRouteStats.mPulledAtoms.callAudioRouteStats.length, 1);
+        verifyMessageForAudioRouteStats(audioRouteStats.mPulledAtoms.callAudioRouteStats[0],
+                VALUE_AUDIO_ROUTE_TYPE1, VALUE_AUDIO_ROUTE_TYPE2, true, false, 1,
+                VALUE_AUDIO_ROUTE_LATENCY);
+
+        audioRouteStats.log(VALUE_AUDIO_ROUTE_TYPE1, VALUE_AUDIO_ROUTE_TYPE2, true, false,
+                VALUE_AUDIO_ROUTE_LATENCY);
+        waitForHandlerAction(audioRouteStats, TEST_TIMEOUT);
+
+        verify(audioRouteStats, times(2)).onAggregate();
+        verify(audioRouteStats, times(2)).save(eq(DELAY_FOR_PERSISTENT_MILLIS));
+        assertEquals(audioRouteStats.mPulledAtoms.callAudioRouteStats.length, 1);
+        verifyMessageForAudioRouteStats(audioRouteStats.mPulledAtoms.callAudioRouteStats[0],
+                VALUE_AUDIO_ROUTE_TYPE1, VALUE_AUDIO_ROUTE_TYPE2, true, false, 2,
+                VALUE_AUDIO_ROUTE_LATENCY);
+    }
+
+    @Test
+    public void testAudioRouteStatsOnEnterThenExit() throws Exception {
+        int latency = 500;
+        AudioRouteStats audioRouteStats = spy(new AudioRouteStats(mSpyContext, mLooper));
+
+        audioRouteStats.onRouteEnter(mMockPendingAudioRoute);
+        waitForHandlerActionDelayed(audioRouteStats, TEST_TIMEOUT, latency);
+        audioRouteStats.onRouteExit(mMockPendingAudioRoute);
+        waitForHandlerAction(audioRouteStats, 100);
+
+        // Verify that the stats should not be saved before the revert threshold is expired
+        verify(audioRouteStats, never()).onAggregate();
+        verify(audioRouteStats, never()).save(anyInt());
+        assertTrue(audioRouteStats.hasMessages(AudioRouteStats.EVENT_REVERT_THRESHOLD_EXPIRED));
+
+        // Verify that the stats should be saved when the revert threshold is expired
+        waitForHandlerActionDelayed(
+                audioRouteStats, TEST_TIMEOUT, AudioRouteStats.THRESHOLD_REVERT_MS);
+
+        verify(audioRouteStats, times(1)).onAggregate();
+        verify(audioRouteStats, times(1)).save(eq(DELAY_FOR_PERSISTENT_MILLIS));
+        assertEquals(audioRouteStats.mPulledAtoms.callAudioRouteStats.length, 1);
+        verifyMessageForAudioRouteStats(audioRouteStats.mPulledAtoms.callAudioRouteStats[0],
+                CALL_AUDIO_ROUTE_STATS__ROUTE_SOURCE__CALL_AUDIO_EARPIECE,
+                CALL_AUDIO_ROUTE_STATS__ROUTE_SOURCE__CALL_AUDIO_BLUETOOTH_LE, true, false, 1,
+                latency);
+    }
+
+    @Test
+    public void testAudioRouteStatsOnRevertToSourceInThreshold() throws Exception {
+        int delay = 100;
+        int latency = 500;
+        int duration = 1000;
+        AudioRouteStats audioRouteStats = spy(new AudioRouteStats(mSpyContext, mLooper));
+
+        audioRouteStats.onRouteEnter(mMockPendingAudioRoute);
+        waitForHandlerActionDelayed(audioRouteStats, TEST_TIMEOUT, latency);
+        audioRouteStats.onRouteExit(mMockPendingAudioRoute);
+        waitForHandlerAction(audioRouteStats, delay);
+
+        // Verify that the stats should not be saved before the revert threshold is expired
+        verify(audioRouteStats, never()).onAggregate();
+        verify(audioRouteStats, never()).save(anyInt());
+        assertTrue(audioRouteStats.hasMessages(AudioRouteStats.EVENT_REVERT_THRESHOLD_EXPIRED));
+
+        // Verify that the event should be saved as revert when routing back to the source before
+        // the revert threshold is expired
+        waitForHandlerActionDelayed(audioRouteStats, TEST_TIMEOUT, duration);
+
+        // Reverse the audio types
+        doReturn(TYPE_BLUETOOTH_LE).when(mMockSourceRoute).getType();
+        doReturn(TYPE_EARPIECE).when(mMockDestRoute).getType();
+
+        audioRouteStats.onRouteEnter(mMockPendingAudioRoute);
+        waitForHandlerAction(audioRouteStats, delay);
+
+        verify(audioRouteStats, times(1)).onAggregate();
+        verify(audioRouteStats, times(1)).save(eq(DELAY_FOR_PERSISTENT_MILLIS));
+        assertEquals(audioRouteStats.mPulledAtoms.callAudioRouteStats.length, 1);
+        verifyMessageForAudioRouteStats(audioRouteStats.mPulledAtoms.callAudioRouteStats[0],
+                CALL_AUDIO_ROUTE_STATS__ROUTE_SOURCE__CALL_AUDIO_EARPIECE,
+                CALL_AUDIO_ROUTE_STATS__ROUTE_SOURCE__CALL_AUDIO_BLUETOOTH_LE, true, true, 1,
+                latency);
+    }
+
+    @Test
+    public void testAudioRouteStatsOnRevertToSourceBeyondThreshold() throws Exception {
+        int delay = 100;
+        int latency = 500;
+        AudioRouteStats audioRouteStats = spy(new AudioRouteStats(mSpyContext, mLooper));
+
+        audioRouteStats.onRouteEnter(mMockPendingAudioRoute);
+        waitForHandlerActionDelayed(audioRouteStats, TEST_TIMEOUT, latency);
+        audioRouteStats.onRouteExit(mMockPendingAudioRoute);
+        waitForHandlerAction(audioRouteStats, delay);
+
+        // Verify that the stats should not be saved before the revert threshold is expired
+        verify(audioRouteStats, never()).onAggregate();
+        verify(audioRouteStats, never()).save(anyInt());
+        assertTrue(audioRouteStats.hasMessages(AudioRouteStats.EVENT_REVERT_THRESHOLD_EXPIRED));
+
+        // Verify that the event should not be saved as revert when routing back to the source
+        // after the revert threshold is expired
+        waitForHandlerActionDelayed(
+                audioRouteStats, TEST_TIMEOUT, AudioRouteStats.THRESHOLD_REVERT_MS);
+
+        // Reverse the audio types
+        doReturn(TYPE_BLUETOOTH_LE).when(mMockSourceRoute).getType();
+        doReturn(TYPE_EARPIECE).when(mMockDestRoute).getType();
+
+        audioRouteStats.onRouteEnter(mMockPendingAudioRoute);
+        waitForHandlerAction(audioRouteStats, delay);
+
+        verify(audioRouteStats, times(1)).onAggregate();
+        verify(audioRouteStats, times(1)).save(eq(DELAY_FOR_PERSISTENT_MILLIS));
+        assertEquals(audioRouteStats.mPulledAtoms.callAudioRouteStats.length, 1);
+        verifyMessageForAudioRouteStats(audioRouteStats.mPulledAtoms.callAudioRouteStats[0],
+                CALL_AUDIO_ROUTE_STATS__ROUTE_SOURCE__CALL_AUDIO_EARPIECE,
+                CALL_AUDIO_ROUTE_STATS__ROUTE_SOURCE__CALL_AUDIO_BLUETOOTH_LE, true, false, 1,
+                latency);
+    }
+
+    @Test
+    public void testAudioRouteStatsOnRouteToAnotherDestInThreshold() throws Exception {
+        int delay = 100;
+        int latency = 500;
+        int duration = 1000;
+        AudioRouteStats audioRouteStats = spy(new AudioRouteStats(mSpyContext, mLooper));
+
+        audioRouteStats.onRouteEnter(mMockPendingAudioRoute);
+        waitForHandlerActionDelayed(audioRouteStats, TEST_TIMEOUT, latency);
+        audioRouteStats.onRouteExit(mMockPendingAudioRoute);
+        waitForHandlerAction(audioRouteStats, delay);
+
+        // Verify that the stats should not be saved before the revert threshold is expired
+        verify(audioRouteStats, never()).onAggregate();
+        verify(audioRouteStats, never()).save(anyInt());
+        assertTrue(audioRouteStats.hasMessages(AudioRouteStats.EVENT_REVERT_THRESHOLD_EXPIRED));
+
+        // Verify that the event should not be saved as  revert when routing to a type different
+        // as the source before the revert threshold is expired
+        waitForHandlerActionDelayed(audioRouteStats, TEST_TIMEOUT, duration);
+
+        AudioRoute dest2 = mock(AudioRoute.class);
+        doReturn(TYPE_SPEAKER).when(dest2).getType();
+        doReturn(dest2).when(mMockPendingAudioRoute).getDestRoute();
+
+        audioRouteStats.onRouteEnter(mMockPendingAudioRoute);
+        waitForHandlerAction(audioRouteStats, delay);
+
+        verify(audioRouteStats, times(1)).onAggregate();
+        verify(audioRouteStats, times(1)).save(eq(DELAY_FOR_PERSISTENT_MILLIS));
+        assertEquals(audioRouteStats.mPulledAtoms.callAudioRouteStats.length, 1);
+        verifyMessageForAudioRouteStats(audioRouteStats.mPulledAtoms.callAudioRouteStats[0],
+                CALL_AUDIO_ROUTE_STATS__ROUTE_SOURCE__CALL_AUDIO_EARPIECE,
+                CALL_AUDIO_ROUTE_STATS__ROUTE_SOURCE__CALL_AUDIO_BLUETOOTH_LE, true, false, 1,
+                latency);
+    }
+
+    @Test
+    public void testAudioRouteStatsOnMultipleEnterWithoutExit() throws Exception {
+        int latency = 500;
+        AudioRouteStats audioRouteStats = spy(new AudioRouteStats(mSpyContext, mLooper));
+
+        audioRouteStats.onRouteEnter(mMockPendingAudioRoute);
+        waitForHandlerActionDelayed(audioRouteStats, TEST_TIMEOUT, latency);
+
+        doReturn(mMockDestRoute).when(mMockPendingAudioRoute).getOrigRoute();
+        AudioRoute dest2 = mock(AudioRoute.class);
+        doReturn(TYPE_SPEAKER).when(dest2).getType();
+        doReturn(dest2).when(mMockPendingAudioRoute).getDestRoute();
+        audioRouteStats.onRouteEnter(mMockPendingAudioRoute);
+        waitForHandlerActionDelayed(audioRouteStats, TEST_TIMEOUT, latency);
+
+        // Verify that the stats should not be saved without exit
+        verify(audioRouteStats, never()).onAggregate();
+        verify(audioRouteStats, never()).save(anyInt());
+        assertTrue(audioRouteStats.hasMessages(AudioRouteStats.EVENT_REVERT_THRESHOLD_EXPIRED));
+    }
+
+    @Test
+    public void testAudioRouteStatsOnMultipleEnterWithExit() throws Exception {
+        int latency = 500;
+        AudioRouteStats audioRouteStats = spy(new AudioRouteStats(mSpyContext, mLooper));
+
+        audioRouteStats.onRouteEnter(mMockPendingAudioRoute);
+        waitForHandlerActionDelayed(audioRouteStats, TEST_TIMEOUT, latency);
+        audioRouteStats.onRouteExit(mMockPendingAudioRoute);
+        waitForHandlerAction(audioRouteStats, 100);
+
+        doReturn(mMockDestRoute).when(mMockPendingAudioRoute).getOrigRoute();
+        AudioRoute dest2 = mock(AudioRoute.class);
+        doReturn(TYPE_SPEAKER).when(dest2).getType();
+        doReturn(dest2).when(mMockPendingAudioRoute).getDestRoute();
+        audioRouteStats.onRouteEnter(mMockPendingAudioRoute);
+        waitForHandlerActionDelayed(audioRouteStats, TEST_TIMEOUT, latency);
+
+        // Verify that the stats should be saved after exit
+        verify(audioRouteStats, times(1)).onAggregate();
+        verify(audioRouteStats, times(1)).save(anyInt());
+        assertTrue(audioRouteStats.hasMessages(AudioRouteStats.EVENT_REVERT_THRESHOLD_EXPIRED));
+    }
+
+    @Test
+    public void testAudioRouteStatsOnRouteToSameDestWithExit() throws Exception {
+        int latency = 500;
+        AudioRouteStats audioRouteStats = spy(new AudioRouteStats(mSpyContext, mLooper));
+        doReturn(mMockSourceRoute).when(mMockPendingAudioRoute).getDestRoute();
+
+        audioRouteStats.onRouteEnter(mMockPendingAudioRoute);
+        waitForHandlerActionDelayed(audioRouteStats, TEST_TIMEOUT, latency);
+
+        // Enter again to trigger the log
+        AudioRoute dest2 = mock(AudioRoute.class);
+        doReturn(TYPE_SPEAKER).when(dest2).getType();
+        doReturn(dest2).when(mMockPendingAudioRoute).getDestRoute();
+        audioRouteStats.onRouteEnter(mMockPendingAudioRoute);
+        waitForHandlerActionDelayed(audioRouteStats, TEST_TIMEOUT, latency);
+
+        // Verify that the stats should not be saved without exit
+        verify(audioRouteStats, never()).onAggregate();
+        verify(audioRouteStats, never()).save(anyInt());
+        assertTrue(audioRouteStats.hasMessages(AudioRouteStats.EVENT_REVERT_THRESHOLD_EXPIRED));
+    }
+
+    @Test
+    public void testCallStatsLog() throws Exception {
+        CallStats callStats = spy(new CallStats(mSpyContext, mLooper));
+
+        callStats.log(VALUE_CALL_DIRECTION, false, false, true, VALUE_CALL_ACCOUNT_TYPE,
+                VALUE_UID, VALUE_CALL_DURATION);
+        waitForHandlerAction(callStats, TEST_TIMEOUT);
+
+        verify(callStats, times(1)).onAggregate();
+        verify(callStats, times(1)).save(eq(DELAY_FOR_PERSISTENT_MILLIS));
+        assertEquals(callStats.mPulledAtoms.callStats.length, 1);
+        verifyMessageForCallStats(callStats.mPulledAtoms.callStats[0], VALUE_CALL_DIRECTION,
+                false, false, true, VALUE_CALL_ACCOUNT_TYPE, VALUE_UID, 1, VALUE_CALL_DURATION);
+
+        callStats.log(VALUE_CALL_DIRECTION, false, false, true, VALUE_CALL_ACCOUNT_TYPE,
+                VALUE_UID, VALUE_CALL_DURATION);
+        waitForHandlerAction(callStats, TEST_TIMEOUT);
+
+        verify(callStats, times(2)).onAggregate();
+        verify(callStats, times(2)).save(eq(DELAY_FOR_PERSISTENT_MILLIS));
+        assertEquals(callStats.mPulledAtoms.callStats.length, 1);
+        verifyMessageForCallStats(callStats.mPulledAtoms.callStats[0], VALUE_CALL_DIRECTION,
+                false, false, true, VALUE_CALL_ACCOUNT_TYPE, VALUE_UID, 2, VALUE_CALL_DURATION);
+    }
+
+    @Test
+    public void testCallStatsOnStartThenEnd() throws Exception {
+        int duration = 1000;
+        UserHandle uh = UserHandle.of(UserHandle.USER_SYSTEM);
+        PhoneAccount account = mock(PhoneAccount.class);
+        Call call = mock(Call.class);
+        doReturn(true).when(call).isIncoming();
+        doReturn(account).when(call).getPhoneAccountFromHandle();
+        doReturn((long) duration).when(call).getAgeMillis();
+        doReturn(false).when(account).hasCapabilities(eq(PhoneAccount.CAPABILITY_SELF_MANAGED));
+        doReturn(true).when(account).hasCapabilities(eq(PhoneAccount.CAPABILITY_CALL_PROVIDER));
+        doReturn(true).when(account).hasCapabilities(eq(PhoneAccount.CAPABILITY_SIM_SUBSCRIPTION));
+        doReturn(uh).when(call).getAssociatedUser();
+        CallStats callStats = spy(new CallStats(mSpyContext, mLooper));
+
+        callStats.onCallStart(call);
+        waitForHandlerAction(callStats, TEST_TIMEOUT);
+
+        callStats.onCallEnd(call);
+        waitForHandlerAction(callStats, TEST_TIMEOUT);
+
+        verify(callStats, times(1)).log(eq(CALL_STATS__CALL_DIRECTION__DIR_INCOMING),
+                eq(false), eq(false), eq(false), eq(CALL_STATS__ACCOUNT_TYPE__ACCOUNT_SIM),
+                eq(UserHandle.USER_SYSTEM), eq(duration));
+    }
+
+    @Test
+    public void testCallStatsOnMultipleAudioDevices() throws Exception {
+        int duration = 1000;
+        UserHandle uh = UserHandle.of(UserHandle.USER_SYSTEM);
+        PhoneAccount account = mock(PhoneAccount.class);
+        Call call = mock(Call.class);
+        doReturn(true).when(call).isIncoming();
+        doReturn(account).when(call).getPhoneAccountFromHandle();
+        doReturn((long) duration).when(call).getAgeMillis();
+        doReturn(false).when(account).hasCapabilities(eq(PhoneAccount.CAPABILITY_SELF_MANAGED));
+        doReturn(true).when(account).hasCapabilities(eq(PhoneAccount.CAPABILITY_CALL_PROVIDER));
+        doReturn(true).when(account).hasCapabilities(eq(PhoneAccount.CAPABILITY_SIM_SUBSCRIPTION));
+        doReturn(uh).when(call).getAssociatedUser();
+        CallStats callStats = spy(new CallStats(mSpyContext, mLooper));
+
+        callStats.onCallStart(call);
+        waitForHandlerAction(callStats, TEST_TIMEOUT);
+
+        callStats.onAudioDevicesChange(true);
+        waitForHandlerAction(callStats, TEST_TIMEOUT);
+
+        callStats.onCallEnd(call);
+        waitForHandlerAction(callStats, TEST_TIMEOUT);
+
+        verify(callStats, times(1)).log(eq(CALL_STATS__CALL_DIRECTION__DIR_INCOMING),
+                eq(false), eq(false), eq(true), eq(CALL_STATS__ACCOUNT_TYPE__ACCOUNT_SIM),
+                eq(UserHandle.USER_SYSTEM), eq(duration));
+    }
+
+    @Test
+    public void testErrorStatsLog() throws Exception {
+        ErrorStats errorStats = spy(new ErrorStats(mSpyContext, mLooper));
+
+        errorStats.log(VALUE_MODULE_ID, VALUE_ERROR_ID);
+        waitForHandlerAction(errorStats, TEST_TIMEOUT);
+
+        verify(errorStats, times(1)).onAggregate();
+        verify(errorStats, times(1)).save(eq(DELAY_FOR_PERSISTENT_MILLIS));
+        assertEquals(errorStats.mPulledAtoms.telecomErrorStats.length, 1);
+        verifyMessageForErrorStats(errorStats.mPulledAtoms.telecomErrorStats[0], VALUE_MODULE_ID,
+                VALUE_ERROR_ID, 1);
+
+        errorStats.log(VALUE_MODULE_ID, VALUE_ERROR_ID);
+        waitForHandlerAction(errorStats, TEST_TIMEOUT);
+
+        verify(errorStats, times(2)).onAggregate();
+        verify(errorStats, times(2)).save(eq(DELAY_FOR_PERSISTENT_MILLIS));
+        assertEquals(errorStats.mPulledAtoms.telecomErrorStats.length, 1);
+        verifyMessageForErrorStats(errorStats.mPulledAtoms.telecomErrorStats[0], VALUE_MODULE_ID,
+                VALUE_ERROR_ID, 2);
+    }
+
+    private void createTestFileForApiStats(long timestamps) throws IOException {
+        PulledAtomsClass.PulledAtoms atom = new PulledAtomsClass.PulledAtoms();
+        atom.telecomApiStats =
+                new PulledAtomsClass.TelecomApiStats[VALUE_ATOM_COUNT];
+        for (int i = 0; i < VALUE_ATOM_COUNT; i++) {
+            atom.telecomApiStats[i] = new PulledAtomsClass.TelecomApiStats();
+            atom.telecomApiStats[i].setApiName(VALUE_API_ID + i);
+            atom.telecomApiStats[i].setUid(VALUE_UID);
+            atom.telecomApiStats[i].setApiResult(VALUE_API_RESULT);
+            atom.telecomApiStats[i].setCount(VALUE_API_COUNT);
+        }
+        atom.setTelecomApiStatsPullTimestampMillis(timestamps);
+
+        FileOutputStream stream = new FileOutputStream(mTempFile);
+        stream.write(PulledAtomsClass.PulledAtoms.toByteArray(atom));
+        stream.close();
+    }
+
+    private void verifyTestDataForApiStats(final PulledAtomsClass.PulledAtoms atom,
+                                           long timestamps) {
+        assertNotNull(atom);
+        assertEquals(atom.getTelecomApiStatsPullTimestampMillis(), timestamps);
+        assertNotNull(atom.telecomApiStats);
+        assertEquals(atom.telecomApiStats.length, VALUE_ATOM_COUNT);
+        for (int i = 0; i < VALUE_ATOM_COUNT; i++) {
+            assertNotNull(atom.telecomApiStats[i]);
+            verifyMessageForApiStats(atom.telecomApiStats[i], VALUE_API_ID + i, VALUE_UID,
+                    VALUE_API_RESULT, VALUE_API_COUNT);
+        }
+    }
+
+    private void verifyMessageForApiStats(final PulledAtomsClass.TelecomApiStats msg, int apiId,
+                                          int uid, int result, int count) {
+        assertEquals(msg.getApiName(), apiId);
+        assertEquals(msg.getUid(), uid);
+        assertEquals(msg.getApiResult(), result);
+        assertEquals(msg.getCount(), count);
+    }
+
+    private void createTestFileForAudioRouteStats(long timestamps) throws IOException {
+        PulledAtomsClass.PulledAtoms atom = new PulledAtomsClass.PulledAtoms();
+        atom.callAudioRouteStats =
+                new PulledAtomsClass.CallAudioRouteStats[VALUE_ATOM_COUNT];
+        for (int i = 0; i < VALUE_ATOM_COUNT; i++) {
+            atom.callAudioRouteStats[i] = new PulledAtomsClass.CallAudioRouteStats();
+            atom.callAudioRouteStats[i].setCallAudioRouteSource(VALUE_AUDIO_ROUTE_TYPE1);
+            atom.callAudioRouteStats[i].setCallAudioRouteDest(VALUE_AUDIO_ROUTE_TYPE2);
+            atom.callAudioRouteStats[i].setSuccess(true);
+            atom.callAudioRouteStats[i].setRevert(false);
+            atom.callAudioRouteStats[i].setCount(VALUE_AUDIO_ROUTE_COUNT);
+            atom.callAudioRouteStats[i].setAverageLatencyMs(VALUE_AUDIO_ROUTE_LATENCY);
+        }
+        atom.setCallAudioRouteStatsPullTimestampMillis(timestamps);
+        FileOutputStream stream = new FileOutputStream(mTempFile);
+        stream.write(PulledAtomsClass.PulledAtoms.toByteArray(atom));
+        stream.close();
+    }
+
+    private void verifyTestDataForAudioRouteStats(final PulledAtomsClass.PulledAtoms atom,
+                                                  long timestamps) {
+        assertNotNull(atom);
+        assertEquals(atom.getCallAudioRouteStatsPullTimestampMillis(), timestamps);
+        assertNotNull(atom.callAudioRouteStats);
+        assertEquals(atom.callAudioRouteStats.length, VALUE_ATOM_COUNT);
+        for (int i = 0; i < VALUE_ATOM_COUNT; i++) {
+            assertNotNull(atom.callAudioRouteStats[i]);
+            verifyMessageForAudioRouteStats(atom.callAudioRouteStats[i], VALUE_AUDIO_ROUTE_TYPE1,
+                    VALUE_AUDIO_ROUTE_TYPE2, true, false, VALUE_AUDIO_ROUTE_COUNT,
+                    VALUE_AUDIO_ROUTE_LATENCY);
+        }
+    }
+
+    private void verifyMessageForAudioRouteStats(
+            final PulledAtomsClass.CallAudioRouteStats msg, int source, int dest, boolean success,
+            boolean revert, int count, int latency) {
+        assertEquals(msg.getCallAudioRouteSource(), source);
+        assertEquals(msg.getCallAudioRouteDest(), dest);
+        assertEquals(msg.getSuccess(), success);
+        assertEquals(msg.getRevert(), revert);
+        assertEquals(msg.getCount(), count);
+        assertTrue(Math.abs(latency - msg.getAverageLatencyMs()) < DELAY_TOLERANCE);
+    }
+
+    private void createTestFileForCallStats(long timestamps) throws IOException {
+        PulledAtomsClass.PulledAtoms atom = new PulledAtomsClass.PulledAtoms();
+        atom.callStats =
+                new PulledAtomsClass.CallStats[VALUE_ATOM_COUNT];
+        for (int i = 0; i < VALUE_ATOM_COUNT; i++) {
+            atom.callStats[i] = new PulledAtomsClass.CallStats();
+            atom.callStats[i].setCallDirection(VALUE_CALL_DIRECTION);
+            atom.callStats[i].setExternalCall(false);
+            atom.callStats[i].setEmergencyCall(false);
+            atom.callStats[i].setMultipleAudioAvailable(false);
+            atom.callStats[i].setAccountType(VALUE_CALL_ACCOUNT_TYPE);
+            atom.callStats[i].setUid(VALUE_UID);
+            atom.callStats[i].setCount(VALUE_CALL_COUNT);
+            atom.callStats[i].setAverageDurationMs(VALUE_CALL_DURATION);
+        }
+        atom.setCallStatsPullTimestampMillis(timestamps);
+        FileOutputStream stream = new FileOutputStream(mTempFile);
+        stream.write(PulledAtomsClass.PulledAtoms.toByteArray(atom));
+        stream.close();
+    }
+
+    private void verifyTestDataForCallStats(final PulledAtomsClass.PulledAtoms atom,
+                                            long timestamps) {
+        assertNotNull(atom);
+        assertEquals(atom.getCallStatsPullTimestampMillis(), timestamps);
+        assertNotNull(atom.callStats);
+        assertEquals(atom.callStats.length, VALUE_ATOM_COUNT);
+        for (int i = 0; i < VALUE_ATOM_COUNT; i++) {
+            assertNotNull(atom.callStats[i]);
+            verifyMessageForCallStats(atom.callStats[i], VALUE_CALL_DIRECTION, false, false,
+                    false, VALUE_CALL_ACCOUNT_TYPE, VALUE_UID, VALUE_CALL_COUNT,
+                    VALUE_CALL_DURATION);
+        }
+    }
+
+    private void verifyMessageForCallStats(final PulledAtomsClass.CallStats msg,
+            int direction, boolean external, boolean emergency, boolean multipleAudio,
+            int accountType, int uid, int count, int duration) {
+        assertEquals(msg.getCallDirection(), direction);
+        assertEquals(msg.getExternalCall(), external);
+        assertEquals(msg.getEmergencyCall(), emergency);
+        assertEquals(msg.getMultipleAudioAvailable(), multipleAudio);
+        assertEquals(msg.getAccountType(), accountType);
+        assertEquals(msg.getUid(), uid);
+        assertEquals(msg.getCount(), count);
+        assertEquals(msg.getAverageDurationMs(), duration);
+    }
+
+    private void createTestFileForErrorStats(long timestamps) throws IOException {
+        PulledAtomsClass.PulledAtoms atom = new PulledAtomsClass.PulledAtoms();
+        atom.telecomErrorStats =
+                new PulledAtomsClass.TelecomErrorStats[VALUE_ATOM_COUNT];
+        for (int i = 0; i < VALUE_ATOM_COUNT; i++) {
+            atom.telecomErrorStats[i] = new PulledAtomsClass.TelecomErrorStats();
+            atom.telecomErrorStats[i].setSubmoduleName(VALUE_MODULE_ID);
+            atom.telecomErrorStats[i].setErrorName(VALUE_ERROR_ID);
+            atom.telecomErrorStats[i].setCount(VALUE_ERROR_COUNT);
+        }
+        atom.setTelecomErrorStatsPullTimestampMillis(timestamps);
+        FileOutputStream stream = new FileOutputStream(mTempFile);
+        stream.write(PulledAtomsClass.PulledAtoms.toByteArray(atom));
+        stream.close();
+    }
+
+    private void verifyTestDataForErrorStats(
+            final PulledAtomsClass.PulledAtoms atom, long timestamps) {
+        assertNotNull(atom);
+        assertEquals(atom.getTelecomErrorStatsPullTimestampMillis(), timestamps);
+        assertNotNull(atom.telecomErrorStats);
+        assertEquals(atom.telecomErrorStats.length, VALUE_ATOM_COUNT);
+        for (int i = 0; i < VALUE_ATOM_COUNT; i++) {
+            assertNotNull(atom.telecomErrorStats[i]);
+            verifyMessageForErrorStats(atom.telecomErrorStats[i], VALUE_MODULE_ID, VALUE_ERROR_ID
+                    , VALUE_ERROR_COUNT);
+        }
+    }
+
+    private void verifyMessageForErrorStats(final PulledAtomsClass.TelecomErrorStats msg,
+            int moduleId, int errorId, int count) {
+        assertEquals(msg.getSubmoduleName(), moduleId);
+        assertEquals(msg.getErrorName(), errorId);
+        assertEquals(msg.getCount(), count);
+    }
+}