[MS82.1] Support network stats data migration process

This includes:
1. Provide NetworkStatsRecorder#importCollectionLocked to
   allow caller to write a NetworkStatsCollection object to
   a standalone file.
2. Do not clear the collection in NetworkStatsRecorder#
   CombiningRewriter, this is safe since pending set will
   still be cleared during forcePersistLocked.
3. Implement the migration process, including recovery and
   safety measures.

Test: NetworkStatsServiceTest NetworkStatsCollectionTest
Test: manual test with
      adb shell device_config put tethering \
      netstats_store_files_in_apexdata true
      adb shell device_config put tethering \
      netstats_import_legacy_target_attempts 1
Ignore-AOSP-First: in a topic with internal-only changes
Bug: 230289468
Change-Id: Ic2002cbfd5a6b3c2226fa8dab22481f8ce656574
diff --git a/service-t/src/com/android/server/net/NetworkStatsRecorder.java b/service-t/src/com/android/server/net/NetworkStatsRecorder.java
index 6f070d7..d73e342 100644
--- a/service-t/src/com/android/server/net/NetworkStatsRecorder.java
+++ b/service-t/src/com/android/server/net/NetworkStatsRecorder.java
@@ -21,6 +21,7 @@
 import static android.net.TrafficStats.MB_IN_BYTES;
 import static android.text.format.DateUtils.YEAR_IN_MILLIS;
 
+import android.annotation.NonNull;
 import android.net.NetworkIdentitySet;
 import android.net.NetworkStats;
 import android.net.NetworkStats.NonMonotonicObserver;
@@ -68,7 +69,7 @@
 
     private static final String TAG_NETSTATS_DUMP = "netstats_dump";
 
-    /** Dump before deleting in {@link #recoverFromWtf()}. */
+    /** Dump before deleting in {@link #recoverAndDeleteData()}. */
     private static final boolean DUMP_BEFORE_DELETE = true;
 
     private final FileRotator mRotator;
@@ -156,6 +157,15 @@
         return mSinceBoot;
     }
 
+    public long getBucketDuration() {
+        return mBucketDuration;
+    }
+
+    @NonNull
+    public String getCookie() {
+        return mCookie;
+    }
+
     /**
      * Load complete history represented by {@link FileRotator}. Caches
      * internally as a {@link WeakReference}, and updated with future
@@ -189,10 +199,10 @@
             res.recordCollection(mPending);
         } catch (IOException e) {
             Log.wtf(TAG, "problem completely reading network stats", e);
-            recoverFromWtf();
+            recoverAndDeleteData();
         } catch (OutOfMemoryError e) {
             Log.wtf(TAG, "problem completely reading network stats", e);
-            recoverFromWtf();
+            recoverAndDeleteData();
         }
         return res;
     }
@@ -300,10 +310,10 @@
                 mPending.reset();
             } catch (IOException e) {
                 Log.wtf(TAG, "problem persisting pending stats", e);
-                recoverFromWtf();
+                recoverAndDeleteData();
             } catch (OutOfMemoryError e) {
                 Log.wtf(TAG, "problem persisting pending stats", e);
-                recoverFromWtf();
+                recoverAndDeleteData();
             }
         }
     }
@@ -319,10 +329,10 @@
                 mRotator.rewriteAll(new RemoveUidRewriter(mBucketDuration, uids));
             } catch (IOException e) {
                 Log.wtf(TAG, "problem removing UIDs " + Arrays.toString(uids), e);
-                recoverFromWtf();
+                recoverAndDeleteData();
             } catch (OutOfMemoryError e) {
                 Log.wtf(TAG, "problem removing UIDs " + Arrays.toString(uids), e);
-                recoverFromWtf();
+                recoverAndDeleteData();
             }
         }
 
@@ -347,8 +357,7 @@
 
     /**
      * Rewriter that will combine current {@link NetworkStatsCollection} values
-     * with anything read from disk, and write combined set to disk. Clears the
-     * original {@link NetworkStatsCollection} when finished writing.
+     * with anything read from disk, and write combined set to disk.
      */
     private static class CombiningRewriter implements FileRotator.Rewriter {
         private final NetworkStatsCollection mCollection;
@@ -375,7 +384,6 @@
         @Override
         public void write(OutputStream out) throws IOException {
             mCollection.write(out);
-            mCollection.reset();
         }
     }
 
@@ -456,6 +464,23 @@
     }
 
     /**
+     * Import a specified {@link NetworkStatsCollection} instance into this recorder,
+     * and write it into a standalone file.
+     * @param collection The target {@link NetworkStatsCollection} instance to be imported.
+     */
+    public void importCollectionLocked(@NonNull NetworkStatsCollection collection)
+            throws IOException {
+        if (mRotator != null) {
+            mRotator.rewriteSingle(new CombiningRewriter(collection), collection.getStartMillis(),
+                    collection.getEndMillis());
+        }
+
+        if (mComplete != null) {
+            throw new IllegalStateException("cannot import data when data already loaded");
+        }
+    }
+
+    /**
      * Rewriter that will remove any histories or persisted data points before the
      * specified cutoff time, only writing data back when modified.
      */
@@ -501,10 +526,10 @@
                         mBucketDuration, cutoffMillis));
             } catch (IOException e) {
                 Log.wtf(TAG, "problem importing netstats", e);
-                recoverFromWtf();
+                recoverAndDeleteData();
             } catch (OutOfMemoryError e) {
                 Log.wtf(TAG, "problem importing netstats", e);
-                recoverFromWtf();
+                recoverAndDeleteData();
             }
         }
 
@@ -555,7 +580,7 @@
      * Recover from {@link FileRotator} failure by dumping state to
      * {@link DropBoxManager} and deleting contents.
      */
-    private void recoverFromWtf() {
+    void recoverAndDeleteData() {
         if (DUMP_BEFORE_DELETE) {
             final ByteArrayOutputStream os = new ByteArrayOutputStream();
             try {
diff --git a/service-t/src/com/android/server/net/NetworkStatsService.java b/service-t/src/com/android/server/net/NetworkStatsService.java
index b2d8b5e..61e22a1 100644
--- a/service-t/src/com/android/server/net/NetworkStatsService.java
+++ b/service-t/src/com/android/server/net/NetworkStatsService.java
@@ -67,6 +67,7 @@
 import android.app.AlarmManager;
 import android.app.PendingIntent;
 import android.app.usage.NetworkStatsManager;
+import android.content.ApexEnvironment;
 import android.content.BroadcastReceiver;
 import android.content.ContentResolver;
 import android.content.Context;
@@ -100,6 +101,7 @@
 import android.net.UnderlyingNetworkInfo;
 import android.net.Uri;
 import android.net.netstats.IUsageCallback;
+import android.net.netstats.NetworkStatsDataMigrationUtils;
 import android.net.netstats.provider.INetworkStatsProvider;
 import android.net.netstats.provider.INetworkStatsProviderCallback;
 import android.net.netstats.provider.NetworkStatsProvider;
@@ -118,6 +120,7 @@
 import android.os.SystemClock;
 import android.os.Trace;
 import android.os.UserHandle;
+import android.provider.DeviceConfig;
 import android.provider.Settings;
 import android.provider.Settings.Global;
 import android.service.NetworkInterfaceProto;
@@ -143,6 +146,7 @@
 import com.android.net.module.util.BinderUtils;
 import com.android.net.module.util.BpfMap;
 import com.android.net.module.util.CollectionUtils;
+import com.android.net.module.util.DeviceConfigUtils;
 import com.android.net.module.util.IBpfMap;
 import com.android.net.module.util.LocationPermissionChecker;
 import com.android.net.module.util.NetworkStatsUtils;
@@ -155,7 +159,9 @@
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.PrintWriter;
+import java.nio.file.Path;
 import java.time.Clock;
+import java.time.Instant;
 import java.time.ZoneOffset;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -232,6 +238,22 @@
     private static final String STATS_MAP_B_PATH =
             "/sys/fs/bpf/net_shared/map_netd_stats_map_B";
 
+    /**
+     * DeviceConfig flag used to indicate whether the files should be stored in the apex data
+     * directory.
+     */
+    static final String NETSTATS_STORE_FILES_IN_APEXDATA = "netstats_store_files_in_apexdata";
+    /**
+     * DeviceConfig flag is used to indicate whether the legacy files need to be imported, and
+     * retry count before giving up. Only valid when {@link #NETSTATS_STORE_FILES_IN_APEXDATA}
+     * set to true. Note that the value gets rollback when the mainline module gets rollback.
+     */
+    static final String NETSTATS_IMPORT_LEGACY_TARGET_ATTEMPTS =
+            "netstats_import_legacy_target_attempts";
+    static final int DEFAULT_NETSTATS_IMPORT_LEGACY_TARGET_ATTEMPTS = 1;
+    static final String NETSTATS_IMPORT_ATTEMPTS_COUNTER_NAME = "import.attempts";
+    static final String NETSTATS_IMPORT_SUCCESS_COUNTER_NAME = "import.successes";
+
     private final Context mContext;
     private final NetworkStatsFactory mStatsFactory;
     private final AlarmManager mAlarmManager;
@@ -239,8 +261,7 @@
     private final NetworkStatsSettings mSettings;
     private final NetworkStatsObservers mStatsObservers;
 
-    private final File mSystemDir;
-    private final File mBaseDir;
+    private final File mStatsDir;
 
     private final PowerManager.WakeLock mWakeLock;
 
@@ -250,6 +271,12 @@
     protected INetd mNetd;
     private final AlertObserver mAlertObserver = new AlertObserver();
 
+    // Persistent counters that backed by AtomicFile which stored in the data directory as a file,
+    // to track attempts/successes count across reboot. Note that these counter values will be
+    // rollback as the module rollbacks.
+    private PersistentInt mImportLegacyAttemptsCounter = null;
+    private PersistentInt mImportLegacySuccessesCounter = null;
+
     @VisibleForTesting
     public static final String ACTION_NETWORK_STATS_POLL =
             "com.android.server.action.NETWORK_STATS_POLL";
@@ -405,16 +432,6 @@
     @NonNull
     private final BpfInterfaceMapUpdater mInterfaceMapUpdater;
 
-    private static @NonNull File getDefaultSystemDir() {
-        return new File(Environment.getDataDirectory(), "system");
-    }
-
-    private static @NonNull File getDefaultBaseDir() {
-        File baseDir = new File(getDefaultSystemDir(), "netstats");
-        baseDir.mkdirs();
-        return baseDir;
-    }
-
     private static @NonNull Clock getDefaultClock() {
         return new BestClock(ZoneOffset.UTC, SystemClock.currentNetworkTimeClock(),
                 Clock.systemUTC());
@@ -506,8 +523,7 @@
                 INetd.Stub.asInterface((IBinder) context.getSystemService(Context.NETD_SERVICE)),
                 alarmManager, wakeLock, getDefaultClock(),
                 new DefaultNetworkStatsSettings(), new NetworkStatsFactory(context),
-                new NetworkStatsObservers(), getDefaultSystemDir(), getDefaultBaseDir(),
-                new Dependencies());
+                new NetworkStatsObservers(), new Dependencies());
 
         return service;
     }
@@ -517,8 +533,8 @@
     @VisibleForTesting
     NetworkStatsService(Context context, INetd netd, AlarmManager alarmManager,
             PowerManager.WakeLock wakeLock, Clock clock, NetworkStatsSettings settings,
-            NetworkStatsFactory factory, NetworkStatsObservers statsObservers, File systemDir,
-            File baseDir, @NonNull Dependencies deps) {
+            NetworkStatsFactory factory, NetworkStatsObservers statsObservers,
+            @NonNull Dependencies deps) {
         mContext = Objects.requireNonNull(context, "missing Context");
         mNetd = Objects.requireNonNull(netd, "missing Netd");
         mAlarmManager = Objects.requireNonNull(alarmManager, "missing AlarmManager");
@@ -527,9 +543,11 @@
         mWakeLock = Objects.requireNonNull(wakeLock, "missing WakeLock");
         mStatsFactory = Objects.requireNonNull(factory, "missing factory");
         mStatsObservers = Objects.requireNonNull(statsObservers, "missing NetworkStatsObservers");
-        mSystemDir = Objects.requireNonNull(systemDir, "missing systemDir");
-        mBaseDir = Objects.requireNonNull(baseDir, "missing baseDir");
         mDeps = Objects.requireNonNull(deps, "missing Dependencies");
+        mStatsDir = mDeps.getOrCreateStatsDir();
+        if (!mStatsDir.exists()) {
+            throw new IllegalStateException("Persist data directory does not exist: " + mStatsDir);
+        }
 
         final HandlerThread handlerThread = mDeps.makeHandlerThread();
         handlerThread.start();
@@ -556,6 +574,79 @@
     @VisibleForTesting
     public static class Dependencies {
         /**
+         * Get or create the directory that stores the persisted data usage.
+         */
+        @NonNull
+        public File getOrCreateStatsDir() {
+            final boolean storeInApexDataDir = getStoreFilesInApexData();
+
+            final File statsDataDir;
+            if (storeInApexDataDir) {
+                final File apexDataDir = ApexEnvironment
+                        .getApexEnvironment(DeviceConfigUtils.TETHERING_MODULE_NAME)
+                        .getDeviceProtectedDataDir();
+                statsDataDir = new File(apexDataDir, "netstats");
+
+            } else {
+                final File systemDataDir = new File(Environment.getDataDirectory(), "system");
+                statsDataDir = new File(systemDataDir, "netstats");
+            }
+
+            if (statsDataDir.exists() || statsDataDir.mkdirs()) {
+                return statsDataDir;
+            }
+            throw new IllegalStateException("Cannot write into stats data directory: "
+                    + statsDataDir);
+        }
+
+        /**
+         * Get the count of import legacy target attempts.
+         */
+        public int getImportLegacyTargetAttempts() {
+            return DeviceConfigUtils.getDeviceConfigPropertyInt(
+                    DeviceConfig.NAMESPACE_TETHERING,
+                    NETSTATS_IMPORT_LEGACY_TARGET_ATTEMPTS,
+                    DEFAULT_NETSTATS_IMPORT_LEGACY_TARGET_ATTEMPTS);
+        }
+
+        /**
+         * Create the persistent counter that counts total import legacy stats attempts.
+         */
+        public PersistentInt createImportLegacyAttemptsCounter(@NonNull Path path)
+                throws IOException {
+            // TODO: Modify PersistentInt to call setStartTime every time a write is made.
+            //  Create and pass a real logger here.
+            return new PersistentInt(path.toString(), null /* logger */);
+        }
+
+        /**
+         * Create the persistent counter that counts total import legacy stats successes.
+         */
+        public PersistentInt createImportLegacySuccessesCounter(@NonNull Path path)
+                throws IOException {
+            return new PersistentInt(path.toString(), null /* logger */);
+        }
+
+        /**
+         * Get the flag of storing files in the apex data directory.
+         * @return whether to store files in the apex data directory.
+         */
+        public boolean getStoreFilesInApexData() {
+            return DeviceConfigUtils.getDeviceConfigPropertyBoolean(
+                    DeviceConfig.NAMESPACE_TETHERING,
+                    NETSTATS_STORE_FILES_IN_APEXDATA, true);
+        }
+
+        /**
+         * Read legacy persisted network stats from disk.
+         */
+        @NonNull
+        public NetworkStatsCollection readPlatformCollection(
+                @NonNull String prefix, long bucketDuration) throws IOException {
+            return NetworkStatsDataMigrationUtils.readPlatformCollection(prefix, bucketDuration);
+        }
+
+        /**
          * Create a HandlerThread to use in NetworkStatsService.
          */
         @NonNull
@@ -697,7 +788,7 @@
 
             updatePersistThresholdsLocked();
 
-            // upgrade any legacy stats, migrating them to rotated files
+            // upgrade any legacy stats
             maybeUpgradeLegacyStatsLocked();
 
             // read historical network stats from disk, since policy service
@@ -761,7 +852,7 @@
         final DropBoxManager dropBox = (DropBoxManager) mContext.getSystemService(
                 Context.DROPBOX_SERVICE);
         return new NetworkStatsRecorder(new FileRotator(
-                mBaseDir, prefix, config.rotateAgeMillis, config.deleteAgeMillis),
+                mStatsDir, prefix, config.rotateAgeMillis, config.deleteAgeMillis),
                 mNonMonotonicObserver, dropBox, prefix, config.bucketDuration, includeTags);
     }
 
@@ -791,32 +882,151 @@
         mSystemReady = false;
     }
 
+    private static class MigrationInfo {
+        public final NetworkStatsRecorder recorder;
+        public NetworkStatsCollection collection;
+        public boolean imported;
+        MigrationInfo(@NonNull final NetworkStatsRecorder recorder) {
+            this.recorder = recorder;
+            collection = null;
+            imported = false;
+        }
+    }
+
     @GuardedBy("mStatsLock")
     private void maybeUpgradeLegacyStatsLocked() {
-        File file;
-        try {
-            file = new File(mSystemDir, "netstats.bin");
-            if (file.exists()) {
-                mDevRecorder.importLegacyNetworkLocked(file);
-                file.delete();
-            }
-
-            file = new File(mSystemDir, "netstats_xt.bin");
-            if (file.exists()) {
-                file.delete();
-            }
-
-            file = new File(mSystemDir, "netstats_uid.bin");
-            if (file.exists()) {
-                mUidRecorder.importLegacyUidLocked(file);
-                mUidTagRecorder.importLegacyUidLocked(file);
-                file.delete();
-            }
-        } catch (IOException e) {
-            Log.wtf(TAG, "problem during legacy upgrade", e);
-        } catch (OutOfMemoryError e) {
-            Log.wtf(TAG, "problem during legacy upgrade", e);
+        final boolean storeFilesInApexData = mDeps.getStoreFilesInApexData();
+        if (!storeFilesInApexData) {
+            return;
         }
+        try {
+            mImportLegacyAttemptsCounter = mDeps.createImportLegacyAttemptsCounter(
+                    mStatsDir.toPath().resolve(NETSTATS_IMPORT_ATTEMPTS_COUNTER_NAME));
+            mImportLegacySuccessesCounter = mDeps.createImportLegacySuccessesCounter(
+                    mStatsDir.toPath().resolve(NETSTATS_IMPORT_SUCCESS_COUNTER_NAME));
+        } catch (IOException e) {
+            Log.wtf(TAG, "Failed to create persistent counters, skip.", e);
+            return;
+        }
+
+        final int targetAttempts = mDeps.getImportLegacyTargetAttempts();
+        final int attempts;
+        try {
+            attempts = mImportLegacyAttemptsCounter.get();
+        } catch (IOException e) {
+            Log.wtf(TAG, "Failed to read attempts counter, skip.", e);
+            return;
+        }
+        if (attempts >= targetAttempts) return;
+
+        Log.i(TAG, "Starting import : attempts " + attempts + "/" + targetAttempts);
+
+        final List<MigrationInfo> migrations = List.of(
+                new MigrationInfo(mDevRecorder), new MigrationInfo(mXtRecorder),
+                new MigrationInfo(mUidRecorder), new MigrationInfo(mUidTagRecorder)
+        );
+        long migrationEndTime = Long.MIN_VALUE;
+        try {
+            // First, read all legacy collections. This is OEM code and it can throw. Don't
+            // commit any data to disk until all are read.
+            for (final MigrationInfo migration : migrations) {
+                migration.collection = readPlatformCollectionForRecorder(migration.recorder);
+            }
+
+            // Find the latest end time.
+            for (final MigrationInfo migration : migrations) {
+                final long migrationEnd = migration.collection.getEndMillis();
+                if (migrationEnd > migrationEndTime) migrationEndTime = migrationEnd;
+            }
+
+            // Reading all collections from legacy data has succeeded. At this point it is
+            // safe to start overwriting the files on disk. The next step is to remove all
+            // data in the new location that overlaps with imported data. This ensures that
+            // any data in the new location that was created by a previous failed import is
+            // ignored. After that, write the imported data into the recorder. The code
+            // below can still possibly throw (disk error or OutOfMemory for example), but
+            // does not depend on code from non-mainline code.
+            Log.i(TAG, "Rewriting data with imported collections with cutoff "
+                    + Instant.ofEpochMilli(migrationEndTime));
+            for (final MigrationInfo migration : migrations) {
+                migration.imported = true;
+                migration.recorder.removeDataBefore(migrationEndTime);
+                if (migration.collection.isEmpty()) continue;
+                migration.recorder.importCollectionLocked(migration.collection);
+            }
+
+            Log.i(TAG, "Successfully imported platform collections");
+        } catch (Throwable e) {
+            // The code above calls OEM code that may behave differently across devices.
+            // It can throw any exception including RuntimeExceptions and
+            // OutOfMemoryErrors. Try to recover anyway.
+            Log.wtf(TAG, "Platform data import failed. Remaining tries "
+                    + (targetAttempts - attempts), e);
+
+            // Failed this time around : try again next time unless we're out of tries.
+            try {
+                mImportLegacyAttemptsCounter.set(attempts + 1);
+            } catch (IOException ex) {
+                Log.wtf(TAG, "Failed to update attempts counter.", ex);
+            }
+
+            // Try to remove any data from the failed import.
+            if (migrationEndTime > Long.MIN_VALUE) {
+                try {
+                    for (final MigrationInfo migration : migrations) {
+                        if (migration.imported) {
+                            migration.recorder.removeDataBefore(migrationEndTime);
+                        }
+                    }
+                } catch (Throwable f) {
+                    // If rollback still throws, there isn't much left to do. Try nuking
+                    // all data, since that's the last stop. If nuking still throws, the
+                    // framework will reboot, and if there are remaining tries, the migration
+                    // process will retry, which is fine because it's idempotent.
+                    for (final MigrationInfo migration : migrations) {
+                        migration.recorder.recoverAndDeleteData();
+                    }
+                }
+            }
+
+            return;
+        }
+
+        // Success ! No need to import again next time.
+        try {
+            mImportLegacyAttemptsCounter.set(targetAttempts);
+            // The successes counter is only for debugging. Hence, the synchronization
+            // between these two counters are not very critical.
+            final int successCount = mImportLegacySuccessesCounter.get();
+            mImportLegacySuccessesCounter.set(successCount + 1);
+        } catch (IOException e) {
+            Log.wtf(TAG, "Succeed but failed to update counters.", e);
+        }
+    }
+
+    @GuardedBy("mStatsLock")
+    @NonNull
+    private NetworkStatsCollection readPlatformCollectionForRecorder(
+            @NonNull final NetworkStatsRecorder rec) throws IOException {
+        final String prefix = rec.getCookie();
+        Log.i(TAG, "Importing platform collection for prefix " + prefix);
+        final NetworkStatsCollection collection = Objects.requireNonNull(
+                mDeps.readPlatformCollection(prefix, rec.getBucketDuration()),
+                "Imported platform collection for prefix " + prefix + " must not be null");
+
+        final long bootTimestamp = System.currentTimeMillis() - SystemClock.elapsedRealtime();
+        if (!collection.isEmpty() && bootTimestamp < collection.getStartMillis()) {
+            throw new IllegalArgumentException("Platform collection for prefix " + prefix
+                    + " contains data that could not possibly come from the previous boot "
+                    + "(start timestamp = " + Instant.ofEpochMilli(collection.getStartMillis())
+                    + ", last booted at " + Instant.ofEpochMilli(bootTimestamp));
+        }
+
+        Log.i(TAG, "Successfully read platform collection spanning from "
+                // Instant uses ISO-8601 for toString()
+                + Instant.ofEpochMilli(collection.getStartMillis()).toString() + " to "
+                + Instant.ofEpochMilli(collection.getEndMillis()).toString());
+        return collection;
     }
 
     /**
@@ -2102,10 +2312,32 @@
                 return;
             }
 
+            pw.println("Directory:");
+            pw.increaseIndent();
+            pw.println(mStatsDir);
+            pw.decreaseIndent();
+
             pw.println("Configs:");
             pw.increaseIndent();
             pw.print(NETSTATS_COMBINE_SUBTYPE_ENABLED, mSettings.getCombineSubtypeEnabled());
             pw.println();
+            pw.print(NETSTATS_STORE_FILES_IN_APEXDATA, mDeps.getStoreFilesInApexData());
+            pw.println();
+            pw.print(NETSTATS_IMPORT_LEGACY_TARGET_ATTEMPTS, mDeps.getImportLegacyTargetAttempts());
+            pw.println();
+            if (mDeps.getStoreFilesInApexData()) {
+                try {
+                    pw.print("platform legacy stats import attempts count",
+                            mImportLegacyAttemptsCounter.get());
+                    pw.println();
+                    pw.print("platform legacy stats import successes count",
+                            mImportLegacySuccessesCounter.get());
+                    pw.println();
+                } catch (IOException e) {
+                    pw.println("(failed to dump platform legacy stats import counters)");
+                }
+            }
+
             pw.decreaseIndent();
 
             pw.println("Active interfaces:");
diff --git a/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java b/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
index ceeb997..24731bf 100644
--- a/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
+++ b/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
@@ -94,8 +94,10 @@
 import android.net.LinkProperties;
 import android.net.Network;
 import android.net.NetworkCapabilities;
+import android.net.NetworkIdentity;
 import android.net.NetworkStateSnapshot;
 import android.net.NetworkStats;
+import android.net.NetworkStatsCollection;
 import android.net.NetworkStatsHistory;
 import android.net.NetworkTemplate;
 import android.net.TelephonyNetworkSpecifier;
@@ -143,9 +145,11 @@
 import org.mockito.MockitoAnnotations;
 
 import java.io.File;
+import java.nio.file.Path;
 import java.time.Clock;
 import java.time.ZoneOffset;
 import java.util.Objects;
+import java.util.Set;
 import java.util.concurrent.Executor;
 import java.util.concurrent.atomic.AtomicBoolean;
 
@@ -220,6 +224,12 @@
     private ContentObserver mContentObserver;
     private Handler mHandler;
     private TetheringManager.TetheringEventCallback mTetheringEventCallback;
+    private NetworkStatsCollection mPlatformNetworkStatsCollection =
+            new NetworkStatsCollection(30 * HOUR_IN_MILLIS);
+    private boolean mStoreFilesInApexData = false;
+    private int mImportLegacyTargetAttempts = 0;
+    private @Mock PersistentInt mImportLegacyAttemptsCounter;
+    private @Mock PersistentInt mImportLegacySuccessesCounter;
 
     private class MockContext extends BroadcastInterceptingContext {
         private final Context mBaseContext;
@@ -295,8 +305,7 @@
         mHandlerThread = new HandlerThread("HandlerThread");
         final NetworkStatsService.Dependencies deps = makeDependencies();
         mService = new NetworkStatsService(mServiceContext, mNetd, mAlarmManager, wakeLock,
-                mClock, mSettings, mStatsFactory, new NetworkStatsObservers(), mStatsDir,
-                getBaseDir(mStatsDir), deps);
+                mClock, mSettings, mStatsFactory, new NetworkStatsObservers(), deps);
 
         mElapsedRealtime = 0L;
 
@@ -339,6 +348,39 @@
     private NetworkStatsService.Dependencies makeDependencies() {
         return new NetworkStatsService.Dependencies() {
             @Override
+            public File getOrCreateStatsDir() {
+                return mStatsDir;
+            }
+
+            @Override
+            public boolean getStoreFilesInApexData() {
+                return mStoreFilesInApexData;
+            }
+
+            @Override
+            public int getImportLegacyTargetAttempts() {
+                return mImportLegacyTargetAttempts;
+            }
+
+            @Override
+            public PersistentInt createImportLegacyAttemptsCounter(
+                    @androidx.annotation.NonNull Path path) {
+                return mImportLegacyAttemptsCounter;
+            }
+
+            @Override
+            public PersistentInt createImportLegacySuccessesCounter(
+                    @androidx.annotation.NonNull Path path) {
+                return mImportLegacySuccessesCounter;
+            }
+
+            @Override
+            public NetworkStatsCollection readPlatformCollection(
+                    @NonNull String prefix, long bucketDuration) {
+                return mPlatformNetworkStatsCollection;
+            }
+
+            @Override
             public HandlerThread makeHandlerThread() {
                 return mHandlerThread;
             }
@@ -1704,10 +1746,65 @@
         assertNetworkTotal(sTemplateImsi1, 0L, 0L, 0L, 0L, 0);
     }
 
-    private static File getBaseDir(File statsDir) {
-        File baseDir = new File(statsDir, "netstats");
-        baseDir.mkdirs();
-        return baseDir;
+    /**
+     * Verify the service will perform data migration process can be controlled by the device flag.
+     */
+    @Test
+    public void testDataMigration() throws Exception {
+        assertStatsFilesExist(false);
+        expectDefaultSettings();
+        final long bucketDuration = 30 * HOUR_IN_MILLIS;
+        final NetworkIdentity ident = new NetworkIdentity.Builder()
+                .setType(TYPE_MOBILE)
+                .setMetered(true)
+                .setSubscriberId(IMSI_1).build();
+        final NetworkStatsCollection.Key key = new NetworkStatsCollection.Key(
+                Set.of(ident), UID_ALL, SET_FOREGROUND, 0x0 /* tag */);
+        final NetworkStatsHistory history = new NetworkStatsHistory.Builder(bucketDuration, 0)
+                .addEntry(new NetworkStatsHistory.Entry(0, 10, 31, 3, 50, 5, 1)).build();
+
+        // Mock mobile traffic which will be reported by
+        // NetworkStatsDataMigrationUtils and verify it won't be absorbed if the flag is not set.
+        // TODO: Also mock UID traffic when service queries with PREFIX_UID. And
+        //  verify with assertUidTotal.
+        mPlatformNetworkStatsCollection = new NetworkStatsCollection.Builder(bucketDuration)
+                .addEntry(key, history).build();
+        mStoreFilesInApexData = true;
+        mImportLegacyTargetAttempts = 0;
+        mServiceContext.sendBroadcast(new Intent(Intent.ACTION_SHUTDOWN));
+        assertStatsFilesExist(false);
+
+        // Mock zero usage and boot through serviceReady(), verify there is no imported data.
+        expectDefaultSettings();
+        expectNetworkStatsUidDetail(buildEmptyStats());
+        expectSystemReady();
+        mService.systemReady();
+        assertStatsFilesExist(false);
+
+        // Set the flag and reboot, verify the imported data is not there until next boot.
+        mImportLegacyTargetAttempts = 3;
+        mServiceContext.sendBroadcast(new Intent(Intent.ACTION_SHUTDOWN));
+        assertStatsFilesExist(false);
+
+        // Boot through systemReady() again.
+        expectDefaultSettings();
+        expectNetworkStatsUidDetail(buildEmptyStats());
+        expectSystemReady();
+        mService.systemReady();
+
+        // After systemReady(), the service should have historical stats loaded again.
+        // Thus, verify
+        //  1. The stats are absorbed by the recorder.
+        //  2. The imported data are persisted.
+        //  3. The attempts count is set to target attempts count to indicate a successful
+        //     migration.
+        assertNetworkTotal(sTemplateImsi1, 31L, 3L, 50L, 5L, 1);
+        assertStatsFilesExist(true);
+        verify(mImportLegacyAttemptsCounter).set(3);
+        verify(mImportLegacySuccessesCounter).set(1);
+
+        // TODO: Verify upgrading with Exception won't damege original data and
+        //  will decrease the retry counter by 1.
     }
 
     private void assertNetworkTotal(NetworkTemplate template, long rxBytes, long rxPackets,
@@ -1816,11 +1913,10 @@
     }
 
     private void assertStatsFilesExist(boolean exist) {
-        final File basePath = new File(mStatsDir, "netstats");
         if (exist) {
-            assertTrue(basePath.list().length > 0);
+            assertTrue(mStatsDir.list().length > 0);
         } else {
-            assertTrue(basePath.list().length == 0);
+            assertTrue(mStatsDir.list().length == 0);
         }
     }