Snap for 12338107 from fe4b4da630119823c375338fe869515f0b2dde95 to mainline-cellbroadcast-release

Change-Id: I929a05d13f9aa107a3c726cdb3491f0c275101c6
diff --git a/src/android/net/ip/ConnectivityPacketTracker.java b/src/android/net/ip/ConnectivityPacketTracker.java
index c071a1b..ce4f6ae 100644
--- a/src/android/net/ip/ConnectivityPacketTracker.java
+++ b/src/android/net/ip/ConnectivityPacketTracker.java
@@ -111,7 +111,6 @@
         }
     }
 
-    public static final int MAX_CAPTURE_TIME_MS = 300000;
     private static final String TAG = ConnectivityPacketTracker.class.getSimpleName();
     private static final boolean DBG = false;
     private static final String MARK_START = "--- START ---";
@@ -148,11 +147,6 @@
      * @param isCapture {@code true} to enable capture, {@code false} to disable capture
      */
     public void setCapture(boolean isCapture) {
-        // prevent configure the duplicated events
-        if (mCapturing == isCapture) {
-            return;
-        }
-
         mCapturing = isCapture;
         if (!isCapture) {
             mPacketCache.evictAll();
@@ -185,12 +179,8 @@
         mDisplayName = null;
     }
 
-    public boolean isCapturing() {
-        return mCapturing;
-    }
-
     @VisibleForTesting(visibility = PRIVATE)
-    public int getTotalCapturePacketCount() {
+    public int getCapturePacketTypeCount() {
         return mPacketCache.size();
     }
 
diff --git a/src/android/net/ip/IpClient.java b/src/android/net/ip/IpClient.java
index 8d60804..d0dda68 100644
--- a/src/android/net/ip/IpClient.java
+++ b/src/android/net/ip/IpClient.java
@@ -16,6 +16,10 @@
 
 package android.net.ip;
 
+import static android.net.IIpMemoryStore.NETWORK_EVENT_NUD_FAILURE_ROAM;
+import static android.net.IIpMemoryStore.NETWORK_EVENT_NUD_FAILURE_CONFIRM;
+import static android.net.IIpMemoryStore.NETWORK_EVENT_NUD_FAILURE_ORGANIC;
+import static android.net.IIpMemoryStore.NETWORK_EVENT_NUD_FAILURE_MAC_ADDRESS_CHANGED;
 import static android.net.RouteInfo.RTN_UNICAST;
 import static android.net.RouteInfo.RTN_UNREACHABLE;
 import static android.net.dhcp.DhcpResultsParcelableUtil.toStableParcelable;
@@ -26,7 +30,6 @@
 import static android.net.ip.IIpClientCallbacks.DTIM_MULTIPLIER_RESET;
 import static android.net.ip.IpClient.IpClientCommands.CMD_ADDRESSES_CLEARED;
 import static android.net.ip.IpClient.IpClientCommands.CMD_ADD_KEEPALIVE_PACKET_FILTER_TO_APF;
-import static android.net.ip.IpClient.IpClientCommands.CMD_APF_STOP_CAPTURE;
 import static android.net.ip.IpClient.IpClientCommands.CMD_COMPLETE_PRECONNECTION;
 import static android.net.ip.IpClient.IpClientCommands.CMD_CONFIRM;
 import static android.net.ip.IpClient.IpClientCommands.CMD_JUMP_RUNNING_TO_STOPPING;
@@ -46,6 +49,9 @@
 import static android.net.ip.IpClient.IpClientCommands.EVENT_DHCPACTION_TIMEOUT;
 import static android.net.ip.IpClient.IpClientCommands.EVENT_IPV6_AUTOCONF_TIMEOUT;
 import static android.net.ip.IpClient.IpClientCommands.EVENT_NETLINK_LINKPROPERTIES_CHANGED;
+import static android.net.ip.IpClient.IpClientCommands.EVENT_NUD_FAILURE_QUERY_FAILURE;
+import static android.net.ip.IpClient.IpClientCommands.EVENT_NUD_FAILURE_QUERY_SUCCESS;
+import static android.net.ip.IpClient.IpClientCommands.EVENT_NUD_FAILURE_QUERY_TIMEOUT;
 import static android.net.ip.IpClient.IpClientCommands.EVENT_PRE_DHCP_ACTION_COMPLETE;
 import static android.net.ip.IpClient.IpClientCommands.EVENT_PROVISIONING_TIMEOUT;
 import static android.net.ip.IpClient.IpClientCommands.EVENT_READ_PACKET_FILTER_COMPLETE;
@@ -82,6 +88,7 @@
 import static com.android.networkstack.util.NetworkStackUtils.IPCLIENT_GARP_NA_ROAMING_VERSION;
 import static com.android.networkstack.util.NetworkStackUtils.IPCLIENT_IGNORE_LOW_RA_LIFETIME_VERSION;
 import static com.android.networkstack.util.NetworkStackUtils.IPCLIENT_POPULATE_LINK_ADDRESS_LIFETIME_VERSION;
+import static com.android.networkstack.util.NetworkStackUtils.IP_REACHABILITY_IGNORE_NUD_FAILURE_VERSION;
 import static com.android.networkstack.util.NetworkStackUtils.createInet6AddressFromEui64;
 import static com.android.networkstack.util.NetworkStackUtils.macAddressToEui64;
 import static com.android.server.util.PermissionUtil.enforceNetworkStackCallingPermission;
@@ -115,6 +122,8 @@
 import android.net.dhcp.DhcpClient;
 import android.net.dhcp.DhcpPacket;
 import android.net.dhcp6.Dhcp6Client;
+import android.net.ipmemorystore.OnNetworkEventCountRetrievedListener;
+import android.net.ipmemorystore.Status;
 import android.net.metrics.IpConnectivityLog;
 import android.net.metrics.IpManagerEvent;
 import android.net.networkstack.aidl.dhcp.DhcpOption;
@@ -598,8 +607,9 @@
         static final int CMD_UPDATE_APF_CAPABILITIES = 19;
         static final int EVENT_IPV6_AUTOCONF_TIMEOUT = 20;
         static final int CMD_UPDATE_APF_DATA_SNAPSHOT = 21;
-        // Triggered by ApfShellCommand
-        static final int CMD_APF_STOP_CAPTURE = 22;
+        static final int EVENT_NUD_FAILURE_QUERY_TIMEOUT = 22;
+        static final int EVENT_NUD_FAILURE_QUERY_SUCCESS = 23;
+        static final int EVENT_NUD_FAILURE_QUERY_FAILURE = 24;
         // Internal commands to use instead of trying to call transitionTo() inside
         // a given State's enter() method. Calling transitionTo() from enter/exit
         // encounters a Log.wtf() that can cause trouble on eng builds.
@@ -686,6 +696,24 @@
     static final String CONFIG_IPV6_AUTOCONF_TIMEOUT = "ipclient_ipv6_autoconf_timeout";
     private static final int DEFAULT_IPV6_AUTOCONF_TIMEOUT_MS = 5000;
 
+    private static final int IPMEMORYSTORE_TIMEOUT_MS = 1000;
+    @VisibleForTesting
+    static final long SIX_HOURS_IN_MS = 6 * 3600 * 1000L;
+    @VisibleForTesting
+    public static final long ONE_DAY_IN_MS = 4 * SIX_HOURS_IN_MS;
+    @VisibleForTesting
+    public static final long ONE_WEEK_IN_MS = 7 * ONE_DAY_IN_MS;
+    @VisibleForTesting
+    static final String CONFIG_NUD_FAILURE_COUNT_DAILY_THRESHOLD =
+            "nud_failure_count_daily_threshold";
+    @VisibleForTesting
+    static final int DEFAULT_NUD_FAILURE_COUNT_DAILY_THRESHOLD = 10;
+    @VisibleForTesting
+    static final String CONFIG_NUD_FAILURE_COUNT_WEEKLY_THRESHOLD =
+            "nud_failure_count_weekly_threshold";
+    @VisibleForTesting
+    static final int DEFAULT_NUD_FAILURE_COUNT_WEEKLY_THRESHOLD = 20;
+
     private static final boolean NO_CALLBACKS = false;
     private static final boolean SEND_CALLBACKS = true;
 
@@ -725,12 +753,24 @@
                     "KT GiGA WiFi", "marente"
     ));
 
+    // The NUD failure event types to query. Although only NETWORK_EVENT_NUD_FAILURE_ORGANIC event
+    // is stored in the database currently, this array is still maintained to include other event
+    // types for testing and future expansion.
+    @VisibleForTesting
+    public static final int[] NETWORK_EVENT_NUD_FAILURE_TYPES = new int[] {
+            NETWORK_EVENT_NUD_FAILURE_ROAM,
+            NETWORK_EVENT_NUD_FAILURE_CONFIRM,
+            NETWORK_EVENT_NUD_FAILURE_ORGANIC,
+            NETWORK_EVENT_NUD_FAILURE_MAC_ADDRESS_CHANGED
+    };
+
     private final State mStoppedState = new StoppedState();
     private final State mStoppingState = new StoppingState();
     private final State mClearingIpAddressesState = new ClearingIpAddressesState();
     private final State mStartedState = new StartedState();
     private final State mRunningState = new RunningState();
     private final State mPreconnectingState = new PreconnectingState();
+    private final State mNudFailureQueryState = new NudFailureQueryState();
 
     private final String mTag;
     private final Context mContext;
@@ -767,6 +807,9 @@
     // Polling interval to update APF data snapshot
     private final long mApfCounterPollingIntervalMs;
 
+    private final int mNudFailureCountDailyThreshold;
+    private final int mNudFailureCountWeeklyThreshold;
+
     // Experiment flag read from device config.
     private final boolean mDhcp6PrefixDelegationEnabled;
     private final boolean mUseNewApfFilter;
@@ -776,6 +819,7 @@
     private final boolean mApfShouldHandleArpOffload;
     private final boolean mApfShouldHandleNdOffload;
     private final boolean mApfShouldHandleMdnsOffload;
+    private final boolean mIgnoreNudFailureEnabled;
 
     private InterfaceParams mInterfaceParams;
 
@@ -791,7 +835,6 @@
     private String mTcpBufferSizes;
     private ProxyInfo mHttpProxy;
     private AndroidPacketFilter mApfFilter;
-    private ConnectivityPacketTracker mPacketTracker;
     private String mL2Key; // The L2 key for this network, for writing into the memory store
     private String mCluster; // The cluster for this network, for writing into the memory store
     private int mCreatorUid; // Uid of app creating the wifi configuration
@@ -804,6 +847,15 @@
     private int mMaxDtimMultiplier = DTIM_MULTIPLIER_RESET;
     private ApfCapabilities mCurrentApfCapabilities;
     private WakeupMessage mIpv6AutoconfTimeoutAlarm = null;
+    private boolean mIgnoreNudFailure;
+    // An array of NUD failure event count associated with the query database since the timestamps
+    // in the past, and is always initialized to null in StoppedState. Currently supported array
+    // elements are as follows:
+    // element 0: failures in the past week
+    // element 1: failures in the past day
+    // element 2: failures in the past 6h
+    @Nullable
+    private int[] mNudFailureEventCounts = null;
 
     /**
      * Reading the snapshot is an asynchronous operation initiated by invoking
@@ -1032,6 +1084,14 @@
         mApfShouldHandleMdnsOffload = false;
         mPopulateLinkAddressLifetime = mDependencies.isFeatureEnabled(context,
                 IPCLIENT_POPULATE_LINK_ADDRESS_LIFETIME_VERSION);
+        mIgnoreNudFailureEnabled = mDependencies.isFeatureEnabled(mContext,
+                IP_REACHABILITY_IGNORE_NUD_FAILURE_VERSION);
+        mNudFailureCountDailyThreshold = mDependencies.getDeviceConfigPropertyInt(
+                CONFIG_NUD_FAILURE_COUNT_DAILY_THRESHOLD,
+                DEFAULT_NUD_FAILURE_COUNT_DAILY_THRESHOLD);
+        mNudFailureCountWeeklyThreshold = mDependencies.getDeviceConfigPropertyInt(
+                CONFIG_NUD_FAILURE_COUNT_WEEKLY_THRESHOLD,
+                DEFAULT_NUD_FAILURE_COUNT_WEEKLY_THRESHOLD);
 
         IpClientLinkObserver.Configuration config = new IpClientLinkObserver.Configuration(
                 mMinRdnssLifetimeSec, mPopulateLinkAddressLifetime);
@@ -1212,6 +1272,7 @@
         addState(mStoppedState);
         addState(mStartedState);
             addState(mPreconnectingState, mStartedState);
+            addState(mNudFailureQueryState, mStartedState);
             addState(mClearingIpAddressesState, mStartedState);
             addState(mRunningState, mStartedState);
         addState(mStoppingState);
@@ -1531,8 +1592,8 @@
         }
 
         final CompletableFuture<String> result = new CompletableFuture<>();
-        final Handler handler = getHandler();
-        handler.post(() -> {
+
+        getHandler().post(() -> {
             try {
                 if (mApfFilter == null) {
                     // IpClient has either stopped or the interface does not support APF.
@@ -1570,42 +1631,6 @@
                         Objects.requireNonNull(snapshot, "No data snapshot recorded.");
                         result.complete(snapshot);
                         break;
-                    case "start-capture":
-                        if (mPacketTracker.isCapturing()) {
-                            result.complete("PacketTracker is already capturing");
-                        } else {
-                            // remove scheduled stop event if it already in the queue
-                            if (handler.hasMessages(IpClientCommands.CMD_APF_STOP_CAPTURE)) {
-                                handler.removeMessages(IpClientCommands.CMD_APF_STOP_CAPTURE);
-                            }
-
-                            mPacketTracker.setCapture(true);
-                            // capture up to 300 sec and stop capturing
-                            sendMessageDelayed(
-                                    IpClientCommands.CMD_APF_STOP_CAPTURE,
-                                    ConnectivityPacketTracker.MAX_CAPTURE_TIME_MS
-                            );
-                            result.complete("success");
-                        }
-                        break;
-                    case "matched-packet-counts":
-                        Objects.requireNonNull(optarg, "No packet pattern provided");
-                        int pktCnt = mPacketTracker.getMatchedPacketCount(optarg);
-                        result.complete(String.valueOf(pktCnt));
-                        break;
-                    case "stop-capture":
-                        if (!mPacketTracker.isCapturing()) {
-                            result.complete("PacketTracker already stop capturing");
-                        } else {
-                            // remove scheduled stop event if it already in the queue
-                            if (handler.hasMessages(IpClientCommands.CMD_APF_STOP_CAPTURE)) {
-                                handler.removeMessages(IpClientCommands.CMD_APF_STOP_CAPTURE);
-                            }
-
-                            sendMessage(IpClientCommands.CMD_APF_STOP_CAPTURE);
-                            result.complete("success");
-                        }
-                        break;
                     default:
                         throw new IllegalArgumentException("Invalid apf command: " + cmd);
                 }
@@ -2516,6 +2541,39 @@
         return true;
     }
 
+    // In order to avoid overflowing the database (the maximum is 10MB) in case of a NUD failure
+    // happens frequently (e.g, every 30s in a broken network), we stop writing the NUD failure
+    // event to database if the event count in past 6h has exceeded the daily threshold.
+    private boolean shouldStopWritingNudFailureEventToDatabase() {
+        // NUD failure query has not completed yet.
+        if (mNudFailureEventCounts == null) return true;
+        return mNudFailureEventCounts[2] >= mNudFailureCountDailyThreshold;
+    }
+
+    private void maybeStoreNudFailureToDatabase(final NudEventType type) {
+        if (!mIgnoreNudFailureEnabled) return;
+        final int event = IpReachabilityMonitor.nudEventTypeToNetworkEvent(type);
+        // So far only NUD failure events due to organic kernel check are stored, which can be
+        // expanded to other causes later if necessary.
+        if (event != NETWORK_EVENT_NUD_FAILURE_ORGANIC) return;
+        if (shouldStopWritingNudFailureEventToDatabase()) return;
+
+        final long now = System.currentTimeMillis();
+        final long expiry = now + ONE_WEEK_IN_MS;
+        mIpMemoryStore.storeNetworkEvent(mCluster, now, expiry, event,
+                status -> {
+                    if (!status.isSuccess()) {
+                        Log.e(TAG, "Failed to store NUD failure event");
+                    }
+                });
+        if (DBG) {
+            Log.d(TAG, "store network event " + type
+                    + " at " + now
+                    + " expire at " + expiry
+                    + " with cluster " + mCluster);
+        }
+    }
+
     private boolean startIpReachabilityMonitor() {
         try {
             mIpReachabilityMonitor = mDependencies.getIpReachabilityMonitor(
@@ -2526,6 +2584,8 @@
                     new IpReachabilityMonitor.Callback() {
                         @Override
                         public void notifyLost(String logMsg, NudEventType type) {
+                            maybeStoreNudFailureToDatabase(type);
+                            if (mIgnoreNudFailure) return;
                             final int version = mCallback.getInterfaceVersion();
                             if (version >= VERSION_ADDED_REACHABILITY_FAILURE) {
                                 final int reason = nudEventTypeToInt(type);
@@ -2740,6 +2800,7 @@
             mGratuitousNaTargetAddresses.clear();
             mMulticastNsSourceAddresses.clear();
             mDelegatedPrefixes.clear();
+            mNudFailureEventCounts = null;
 
             resetLinkProperties();
             if (mStartTimeMillis > 0) {
@@ -2764,7 +2825,9 @@
 
                 case CMD_START:
                     mConfiguration = (android.net.shared.ProvisioningConfiguration) msg.obj;
-                    transitionTo(mClearingIpAddressesState);
+                    transitionTo(mIgnoreNudFailureEnabled
+                            ? mNudFailureQueryState
+                            : mClearingIpAddressesState);
                     break;
 
                 case EVENT_NETLINK_LINKPROPERTIES_CHANGED:
@@ -3191,7 +3254,82 @@
         return mConfiguration.mIPv4ProvisioningMode != PROV_IPV4_DISABLED;
     }
 
+    private boolean shouldIgnoreNudFailure(@NonNull final int[] eventCounts) {
+        if (!mIgnoreNudFailureEnabled) return false;
+        if (eventCounts.length == 0) return false;
+
+        final int countInPastOneWeek = eventCounts[0];
+        final int countInPastOneDay = eventCounts[1];
+        return countInPastOneDay >= mNudFailureCountDailyThreshold
+                || countInPastOneWeek >= mNudFailureCountWeeklyThreshold;
+    }
+
+    class NudFailureQueryState extends State {
+        // This listener runs in a different thread (the Executor used in the IpMemoryStoreService)
+        // and it needs to be volatile to allow access by other threads than the IpClient state
+        // machine handler, which should be fine since it only accesses the mListener and calls
+        // sendMessage.
+        private volatile OnNetworkEventCountRetrievedListener mListener =
+                new OnNetworkEventCountRetrievedListener() {
+                    @Override
+                    public void onNetworkEventCountRetrieved(Status status, int[] counts) {
+                        if (mListener != this) return;
+                        if (counts.length == 0) {
+                            if (!status.isSuccess()) {
+                                Log.e(TAG, "Error retrieving NUD failure event count: " + status);
+                            }
+                            sendMessage(EVENT_NUD_FAILURE_QUERY_FAILURE);
+                            return;
+                        }
+                        sendMessage(EVENT_NUD_FAILURE_QUERY_SUCCESS, counts);
+                    }};
+
+        @Override
+        public void enter() {
+            super.enter();
+            // Set a timeout for retrieving NUD failure event counts.
+            sendMessageDelayed(EVENT_NUD_FAILURE_QUERY_TIMEOUT, IPMEMORYSTORE_TIMEOUT_MS);
+            final long now = System.currentTimeMillis();
+            final long[] sinceTimes = new long[3];
+            sinceTimes[0] = now - ONE_WEEK_IN_MS;
+            sinceTimes[1] = now - ONE_DAY_IN_MS;
+            sinceTimes[2] = now - SIX_HOURS_IN_MS;
+            mIpMemoryStore.retrieveNetworkEventCount(mCluster, sinceTimes,
+                    NETWORK_EVENT_NUD_FAILURE_TYPES, mListener);
+        }
+
+        @Override
+        public boolean processMessage(Message message) {
+            switch (message.what) {
+                case EVENT_NUD_FAILURE_QUERY_FAILURE:
+                case EVENT_NUD_FAILURE_QUERY_TIMEOUT:
+                    // TODO: log query result with metrics.
+                    transitionTo(mClearingIpAddressesState);
+                    return HANDLED;
+
+                case EVENT_NUD_FAILURE_QUERY_SUCCESS:
+                    mNudFailureEventCounts = (int[]) message.obj;
+                    mIgnoreNudFailure = shouldIgnoreNudFailure(mNudFailureEventCounts);
+                    transitionTo(mClearingIpAddressesState);
+                    return HANDLED;
+
+                default:
+                    deferMessage(message); // e.g. LP updated during this state.
+                    return HANDLED;
+            }
+        }
+
+        @Override
+        public void exit() {
+            super.exit();
+            removeMessages(EVENT_NUD_FAILURE_QUERY_FAILURE);
+            removeMessages(EVENT_NUD_FAILURE_QUERY_TIMEOUT);
+            removeMessages(EVENT_NUD_FAILURE_QUERY_SUCCESS);
+        }
+    }
+
     class RunningState extends State {
+        private ConnectivityPacketTracker mPacketTracker;
         private boolean mDhcpActionInFlight;
 
         @Override
@@ -3676,9 +3814,7 @@
                     mCallback.startReadPacketFilter("polling");
                     sendMessageDelayed(CMD_UPDATE_APF_DATA_SNAPSHOT, mApfCounterPollingIntervalMs);
                     break;
-                case CMD_APF_STOP_CAPTURE:
-                    mPacketTracker.setCapture(false);
-                    break;
+
                 default:
                     return NOT_HANDLED;
             }
diff --git a/src/com/android/networkstack/ipmemorystore/IpMemoryStoreDatabase.java b/src/com/android/networkstack/ipmemorystore/IpMemoryStoreDatabase.java
index 00026a0..b00c03d 100644
--- a/src/com/android/networkstack/ipmemorystore/IpMemoryStoreDatabase.java
+++ b/src/com/android/networkstack/ipmemorystore/IpMemoryStoreDatabase.java
@@ -39,6 +39,7 @@
 
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
+import java.io.File;
 import java.net.InetAddress;
 import java.net.UnknownHostException;
 import java.util.ArrayList;
@@ -146,6 +147,42 @@
         public static final String DROP_TABLE = "DROP TABLE IF EXISTS " + TABLENAME;
     }
 
+    /**
+     * Contract class for the network events table.
+     */
+    public static final class NetworkEventsContract {
+        private NetworkEventsContract() {}
+
+        public static final String TABLENAME = "NetworkEvents";
+
+        public static final String COLNAME_CLUSTER = "cluster";
+        public static final String COLTYPE_CLUSTER = "TEXT NOT NULL";
+
+        public static final String COLNAME_TIMESTAMP = "timestamp";
+        public static final String COLTYPE_TIMESTAMP = "BIGINT";
+
+        public static final String COLNAME_EVENTTYPE = "eventType";
+        public static final String COLTYPE_EVENTTYPE = "INTEGER";
+
+        public static final String COLNAME_EXPIRY = "expiry";
+        // Milliseconds since the Epoch, in true Java style
+        public static final String COLTYPE_EXPIRY = "BIGINT";
+
+        public static final String CREATE_TABLE = "CREATE TABLE IF NOT EXISTS "
+                + TABLENAME           + " ("
+                + COLNAME_CLUSTER     + " " + COLTYPE_CLUSTER    + ", "
+                + COLNAME_TIMESTAMP   + " " + COLTYPE_TIMESTAMP  + ", "
+                + COLNAME_EVENTTYPE   + " " + COLTYPE_EVENTTYPE  + ", "
+                + COLNAME_EXPIRY      + " " + COLTYPE_EXPIRY     + ")";
+        public static final String INDEX_NAME = "idx_" + COLNAME_CLUSTER + "_" + COLNAME_TIMESTAMP
+                + "_" + COLNAME_EVENTTYPE;
+        public static final String CREATE_INDEX = "CREATE INDEX IF NOT EXISTS " + INDEX_NAME
+                + " ON " + TABLENAME
+                + " (" + COLNAME_CLUSTER + ", " + COLNAME_TIMESTAMP + ", " + COLNAME_EVENTTYPE
+                + ")";
+        public static final String DROP_TABLE = "DROP TABLE IF EXISTS " + TABLENAME;
+    }
+
     // To save memory when the DB is not used, close it after 30s of inactivity. This is
     // determined manually based on what feels right.
     private static final long IDLE_CONNECTION_TIMEOUT_MS = 30_000;
@@ -153,12 +190,11 @@
     /** The SQLite DB helper */
     public static class DbHelper extends SQLiteOpenHelper {
         // Update this whenever changing the schema.
-        // DO NOT CHANGE without solid testing for downgrades, and checking onDowngrade
-        // below: b/171340630
         @VisibleForTesting
-        static final int SCHEMA_VERSION = 4;
-        private static final String DATABASE_FILENAME = "IpMemoryStore.db";
+        static final int SCHEMA_VERSION = 5;
+        private static final String DATABASE_FILENAME = "IpMemoryStoreV2.db";
         private static final String TRIGGER_NAME = "delete_cascade_to_private";
+        private static final String LEGACY_DATABASE_FILENAME = "IpMemoryStore.db";
 
         public DbHelper(@NonNull final Context context) {
             super(context, DATABASE_FILENAME, null, SCHEMA_VERSION);
@@ -176,6 +212,8 @@
         public void onCreate(@NonNull final SQLiteDatabase db) {
             db.execSQL(NetworkAttributesContract.CREATE_TABLE);
             db.execSQL(PrivateDataContract.CREATE_TABLE);
+            db.execSQL(NetworkEventsContract.CREATE_TABLE);
+            db.execSQL(NetworkEventsContract.CREATE_INDEX);
             createTrigger(db);
         }
 
@@ -201,11 +239,20 @@
                 if (oldVersion < 4) {
                     createTrigger(db);
                 }
+
+                if (oldVersion < 5) {
+                    // upgrade from version 4 to version 5, the NetworkEventsTable doesn't exist
+                    // on previous version and onCreate won't be called during upgrade, therefore,
+                    // create the table manually.
+                    db.execSQL(NetworkEventsContract.CREATE_TABLE);
+                    db.execSQL(NetworkEventsContract.CREATE_INDEX);
+                }
             } catch (SQLiteException e) {
                 Log.e(TAG, "Could not upgrade to the new version", e);
                 // create database with new version
                 db.execSQL(NetworkAttributesContract.DROP_TABLE);
                 db.execSQL(PrivateDataContract.DROP_TABLE);
+                db.execSQL(NetworkEventsContract.DROP_TABLE);
                 onCreate(db);
             }
         }
@@ -217,6 +264,7 @@
             // Downgrades always nuke all data and recreate an empty table.
             db.execSQL(NetworkAttributesContract.DROP_TABLE);
             db.execSQL(PrivateDataContract.DROP_TABLE);
+            db.execSQL(NetworkEventsContract.DROP_TABLE);
             onCreate(db);
         }
 
@@ -230,6 +278,33 @@
                     + "; END;";
             db.execSQL(createTrigger);
         }
+
+        /**
+         * Renames the database file to prevent crashes during downgrades.
+         * <p>
+         * Previous versions (before 5) has a bug(b/171340630) that would cause a crash when
+         * onDowngrade is triggered. We cannot just bump the schema version without
+         * renaming the database filename, because only bumping the schema version still causes
+         * crash when downgrading to an older version.
+         * <p>
+         * After rename the db file, if the module is rolled back, the legacy file is not present.
+         * The code will create a new legacy database, and will trigger onCreate path. The new
+         * database will continue to exist, but the legacy code does not know about it.
+         * <p>
+         * In later stage, if the module is rolled forward again, the legacy database will overwrite
+         * the new database, the user's data will be preserved.
+         */
+        public static void maybeRenameDatabaseFile(Context context) {
+            final File legacyDb = context.getDatabasePath(LEGACY_DATABASE_FILENAME);
+            if (legacyDb.exists()) {
+                final File newDb = context.getDatabasePath(DATABASE_FILENAME);
+                final boolean result = legacyDb.renameTo(newDb);
+                if (!result) {
+                    Log.w(TAG, "failed to rename the IP Memory store database to "
+                            + DATABASE_FILENAME);
+                }
+            }
+        }
     }
 
     @NonNull
@@ -308,6 +383,22 @@
         return values;
     }
 
+    /**
+     * Convert a network event (including cluster, timestamp of when it happened, expiry and
+     * event type) into content values to store them in a table compliant with the ontract defined
+     * in NetworkEventsContract.
+     */
+    @NonNull
+    private static ContentValues toContentValues(@NonNull final String cluster,
+            final long timestamp, final long expiry, final int eventType) {
+        final ContentValues values = new ContentValues();
+        values.put(NetworkEventsContract.COLNAME_CLUSTER, cluster);
+        values.put(NetworkEventsContract.COLNAME_TIMESTAMP, timestamp);
+        values.put(NetworkEventsContract.COLNAME_EVENTTYPE, eventType);
+        values.put(NetworkEventsContract.COLNAME_EXPIRY, expiry);
+        return values;
+    }
+
     @Nullable
     private static NetworkAttributes readNetworkAttributesLine(@NonNull final Cursor cursor) {
         // Make sure the data hasn't expired
@@ -459,6 +550,7 @@
             try {
                 db.delete(NetworkAttributesContract.TABLENAME, null, null);
                 db.delete(PrivateDataContract.TABLENAME, null, null);
+                db.delete(NetworkEventsContract.TABLENAME, null, null);
                 try (Cursor cursorNetworkAttributes = db.query(
                         // table name
                         NetworkAttributesContract.TABLENAME,
@@ -485,6 +577,19 @@
                         "1")) { // limit
                     if (0 != cursorPrivateData.getCount()) continue;
                 }
+                try (Cursor cursorNetworkEvents = db.query(
+                        // table name
+                        NetworkEventsContract.TABLENAME,
+                        // column name
+                        new String[] { NetworkEventsContract.COLNAME_CLUSTER },
+                        null, // selection
+                        null, // selectionArgs
+                        null, // groupBy
+                        null, // having
+                        null, // orderBy
+                        "1")) { // limit
+                    if (0 != cursorNetworkEvents.getCount()) continue;
+                }
                 db.setTransactionSuccessful();
             } catch (SQLiteException e) {
                 Log.e(TAG, "Could not wipe the data in database", e);
@@ -669,10 +774,15 @@
     static int dropAllExpiredRecords(@NonNull final SQLiteDatabase db) {
         db.beginTransaction();
         try {
+            final long currentTimestamp = System.currentTimeMillis();
             // Deletes NetworkAttributes that have expired.
             db.delete(NetworkAttributesContract.TABLENAME,
                     NetworkAttributesContract.COLNAME_EXPIRYDATE + " < ?",
-                    new String[]{Long.toString(System.currentTimeMillis())});
+                    new String[]{Long.toString(currentTimestamp)});
+            // Deletes NetworkEvents that have expired.
+            db.delete(NetworkEventsContract.TABLENAME,
+                    NetworkEventsContract.COLNAME_EXPIRY + " < ?",
+                    new String[]{Long.toString(currentTimestamp)});
             db.setTransactionSuccessful();
         } catch (SQLiteException e) {
             Log.e(TAG, "Could not delete data from memory store", e);
@@ -755,15 +865,62 @@
 
     static int storeNetworkEvent(@NonNull final SQLiteDatabase db, @NonNull final String cluster,
             final long timestamp, final long expiry, final int eventType) {
-        // TODO: implement this.
-        return Status.SUCCESS;
+        final ContentValues cv = toContentValues(cluster, timestamp, expiry, eventType);
+        db.beginTransaction();
+        try {
+            final long resultId = db.insertOrThrow(NetworkEventsContract.TABLENAME,
+                    null /* nullColumnHack */, cv);
+            if (resultId < 0) {
+                // Should not fail to insert a row to NetworkEvents table which doesn't have
+                // uniqueness constraint.
+                return Status.ERROR_STORAGE;
+            }
+            db.setTransactionSuccessful();
+            return Status.SUCCESS;
+        } catch (SQLiteException e) {
+            // No space left on disk or something
+            Log.e(TAG, "Could not write to the memory store", e);
+        } finally {
+            db.endTransaction();
+        }
+        return Status.ERROR_STORAGE;
     }
 
     static int[] retrieveNetworkEventCount(@NonNull final SQLiteDatabase db,
             @NonNull final String cluster, @NonNull final long[] sinceTimes,
             @NonNull final int[] eventTypes) {
-        // TODO: implement this.
-        return new int[0];
+        final int[] counts = new int[sinceTimes.length];
+        for (int i = 0; i < counts.length; i++) {
+            final String[] selectionArgs = new String[eventTypes.length + 2];
+            selectionArgs[0] = cluster;
+            selectionArgs[1] = String.valueOf(sinceTimes[i]);
+            for (int j = 0; j < eventTypes.length; j++) {
+                selectionArgs[j + 2] = String.valueOf(eventTypes[j]);
+            }
+            final StringBuilder selectionBuilder =
+                    new StringBuilder(NetworkEventsContract.COLNAME_CLUSTER + " = ? " + "AND "
+                            + NetworkEventsContract.COLNAME_TIMESTAMP + " >= ? " + "AND "
+                            + NetworkEventsContract.COLNAME_EVENTTYPE + " IN (");
+            for (int k = 0; k < eventTypes.length; k++) {
+                selectionBuilder.append("?");
+                if (k < eventTypes.length - 1) {
+                    selectionBuilder.append(",");
+                }
+            }
+            selectionBuilder.append(")");
+            try (Cursor cursor = db.query(
+                    NetworkEventsContract.TABLENAME,
+                    new String[] {"COUNT(*)"}, // columns
+                    selectionBuilder.toString(),
+                    selectionArgs,
+                    null, // groupBy
+                    null, // having
+                    null)) { // orderBy
+                cursor.moveToFirst();
+                counts[i] = cursor.getInt(0);
+            }
+        }
+        return counts;
     }
 
     // Helper methods
diff --git a/src/com/android/networkstack/ipmemorystore/IpMemoryStoreService.java b/src/com/android/networkstack/ipmemorystore/IpMemoryStoreService.java
index a05c5cb..de06b64 100644
--- a/src/com/android/networkstack/ipmemorystore/IpMemoryStoreService.java
+++ b/src/com/android/networkstack/ipmemorystore/IpMemoryStoreService.java
@@ -87,6 +87,9 @@
      * @param context the context to access storage with.
      */
     public IpMemoryStoreService(@NonNull final Context context) {
+        // Before doing anything at all, rename the legacy database if necessary.
+        IpMemoryStoreDatabase.DbHelper.maybeRenameDatabaseFile(context);
+
         // Note that constructing the service will access the disk and block
         // for some time, but it should make no difference to the clients. Because
         // the interface is one-way, clients fire and forget requests, and the callback
@@ -513,6 +516,11 @@
                             makeStatus(ERROR_ILLEGAL_ARGUMENT), new int[0] /* counts */);
                     return;
                 }
+                if (0 == sinceTimes.length) {
+                    listener.onNetworkEventCountRetrieved(
+                            makeStatus(ERROR_ILLEGAL_ARGUMENT), new int[0] /* counts */);
+                    return;
+                }
                 if (null == mDb) {
                     listener.onNetworkEventCountRetrieved(
                             makeStatus(ERROR_DATABASE_CANNOT_BE_OPENED), new int[0] /* counts */);
diff --git a/src/com/android/networkstack/util/NetworkStackUtils.java b/src/com/android/networkstack/util/NetworkStackUtils.java
index 0c1eb39..fce06e4 100755
--- a/src/com/android/networkstack/util/NetworkStackUtils.java
+++ b/src/com/android/networkstack/util/NetworkStackUtils.java
@@ -280,6 +280,12 @@
      */
     public static final String DNS_DDR_VERSION = "dns_ddr_version";
 
+    /**
+     * Experiment flag to ignore all NUD failures if we've seen too many NUD failure in a network.
+     */
+    public static final String IP_REACHABILITY_IGNORE_NUD_FAILURE_VERSION =
+            "ip_reachability_ignore_nud_failure_version";
+
     /**** BEGIN Feature Kill Switch Flags ****/
 
     /**
diff --git a/src/com/android/server/NetworkStackService.java b/src/com/android/server/NetworkStackService.java
index 780642f..4d10c3b 100644
--- a/src/com/android/server/NetworkStackService.java
+++ b/src/com/android/server/NetworkStackService.java
@@ -627,14 +627,6 @@
                 pw.println("        Format: <apfVersion>,<maxProgramSize>,<packetFormat>");
                 pw.println("      read");
                 pw.println("        reads and returns the current state of APF memory.");
-                pw.println("      start-capture");
-                pw.println("        start capture packets in the received buffer.");
-                pw.println("        The capture is up to 300 sec, then it will stop.");
-                pw.println("      stop-capture");
-                pw.println("        stop capture packets and clear the received buffer.");
-                pw.println("      matched-packet-counts <pkt-hex-string>");
-                pw.println("        the <pkt-hex-string> starts from ether header.");
-                pw.println("        Expect to do full packet match.");
             }
         }
 
diff --git a/tests/integration/common/android/net/ip/IpClientIntegrationTestCommon.java b/tests/integration/common/android/net/ip/IpClientIntegrationTestCommon.java
index 8c81de5..f7e1c4d 100644
--- a/tests/integration/common/android/net/ip/IpClientIntegrationTestCommon.java
+++ b/tests/integration/common/android/net/ip/IpClientIntegrationTestCommon.java
@@ -41,8 +41,15 @@
 import static android.net.ip.IpClient.CONFIG_IPV6_AUTOCONF_TIMEOUT;
 import static android.net.ip.IpClient.CONFIG_ACCEPT_RA_MIN_LFT;
 import static android.net.ip.IpClient.CONFIG_APF_COUNTER_POLLING_INTERVAL_SECS;
+import static android.net.ip.IpClient.CONFIG_NUD_FAILURE_COUNT_DAILY_THRESHOLD;
+import static android.net.ip.IpClient.CONFIG_NUD_FAILURE_COUNT_WEEKLY_THRESHOLD;
 import static android.net.ip.IpClient.DEFAULT_ACCEPT_RA_MIN_LFT;
 import static android.net.ip.IpClient.DEFAULT_APF_COUNTER_POLLING_INTERVAL_SECS;
+import static android.net.ip.IpClient.DEFAULT_NUD_FAILURE_COUNT_DAILY_THRESHOLD;
+import static android.net.ip.IpClient.DEFAULT_NUD_FAILURE_COUNT_WEEKLY_THRESHOLD;
+import static android.net.ip.IpClient.ONE_DAY_IN_MS;
+import static android.net.ip.IpClient.ONE_WEEK_IN_MS;
+import static android.net.ip.IpClient.SIX_HOURS_IN_MS;
 import static android.net.ip.IpClientLinkObserver.CLAT_PREFIX;
 import static android.net.ip.IpClientLinkObserver.CONFIG_SOCKET_RECV_BUFSIZE;
 import static android.net.ip.IpReachabilityMonitor.NUD_MCAST_RESOLICIT_NUM;
@@ -83,6 +90,7 @@
 import static com.android.networkstack.util.NetworkStackUtils.IP_REACHABILITY_IGNORE_INCOMPLETE_IPV6_DEFAULT_ROUTER_VERSION;
 import static com.android.networkstack.util.NetworkStackUtils.IP_REACHABILITY_IGNORE_INCOMPLETE_IPV6_DNS_SERVER_VERSION;
 import static com.android.networkstack.util.NetworkStackUtils.IP_REACHABILITY_IGNORE_NEVER_REACHABLE_NEIGHBOR_VERSION;
+import static com.android.networkstack.util.NetworkStackUtils.IP_REACHABILITY_IGNORE_NUD_FAILURE_VERSION;
 import static com.android.networkstack.util.NetworkStackUtils.IP_REACHABILITY_IGNORE_ORGANIC_NUD_FAILURE_VERSION;
 import static com.android.networkstack.util.NetworkStackUtils.IP_REACHABILITY_ROUTER_MAC_CHANGE_FAILURE_ONLY_AFTER_ROAM_VERSION;
 import static com.android.testutils.MiscAsserts.assertThrows;
@@ -133,6 +141,7 @@
 import android.content.res.Resources;
 import android.net.ConnectivityManager;
 import android.net.DhcpResultsParcelable;
+import android.net.IIpMemoryStore;
 import android.net.INetd;
 import android.net.InetAddresses;
 import android.net.InterfaceConfigurationParcel;
@@ -167,6 +176,7 @@
 import android.net.dhcp6.Dhcp6SolicitPacket;
 import android.net.ipmemorystore.NetworkAttributes;
 import android.net.ipmemorystore.OnNetworkAttributesRetrievedListener;
+import android.net.ipmemorystore.OnNetworkEventCountRetrievedListener;
 import android.net.ipmemorystore.Status;
 import android.net.networkstack.TestNetworkStackServiceClient;
 import android.net.networkstack.aidl.dhcp.DhcpOption;
@@ -190,6 +200,7 @@
 import android.system.ErrnoException;
 import android.system.Os;
 import android.util.Log;
+import android.util.Pair;
 
 import androidx.annotation.NonNull;
 import androidx.test.InstrumentationRegistry;
@@ -372,6 +383,7 @@
 
     protected IpClient mIpc;
     protected Dependencies mDependencies;
+    protected List<Pair<String, Pair<Long, Integer>>> mNetworkEvents = new ArrayList<>();
 
     /***** END signature required test members *****/
 
@@ -691,6 +703,11 @@
 
     protected abstract int readNudSolicitNumPostRoamingFromResource();
 
+    protected abstract void storeNetworkEvent(String cluster, long now, long expiry, int eventType);
+
+    protected abstract int[] getStoredNetworkEventCount(String cluster, long[] sinceTimes,
+            int[] eventType, long timeout);
+
     protected final boolean testSkipped() {
         if (!useNetworkStackSignature() && !TestNetworkStackServiceClient.isSupported()) {
             fail("Device running root tests doesn't support TestNetworkStackServiceClient.");
@@ -821,6 +838,19 @@
         when(mPackageManager.getPackagesForUid(TEST_DEVICE_OWNER_APP_UID)).thenReturn(
                 new String[] { TEST_DEVICE_OWNER_APP_PACKAGE });
 
+        // Retrieve the network event count.
+        doAnswer(invocation -> {
+            final String cluster = invocation.getArgument(0);
+            final long[] sinceTimes = invocation.getArgument(1);
+            final int[] eventType = invocation.getArgument(2);
+            ((OnNetworkEventCountRetrievedListener) invocation.getArgument(3))
+                    .onNetworkEventCountRetrieved(
+                            new Status(SUCCESS),
+                            getStoredNetworkEventCount(cluster, sinceTimes, eventType,
+                                    0 /* timeout not used */));
+            return null;
+        }).when(mIpMemoryStore).retrieveNetworkEventCount(eq(TEST_CLUSTER), any(), any(), any());
+
         setDeviceConfigProperty(IpClient.CONFIG_MIN_RDNSS_LIFETIME, 67);
         setDeviceConfigProperty(DhcpClient.DHCP_RESTART_CONFIG_DELAY, 10);
         setDeviceConfigProperty(DhcpClient.ARP_FIRST_PROBE_DELAY_MS, 10);
@@ -844,6 +874,12 @@
         // Set the polling interval to update APF data snapshot.
         setDeviceConfigProperty(CONFIG_APF_COUNTER_POLLING_INTERVAL_SECS,
                 DEFAULT_APF_COUNTER_POLLING_INTERVAL_SECS);
+
+        // Set the NUD failure event count daily and weekly thresholds.
+        setDeviceConfigProperty(CONFIG_NUD_FAILURE_COUNT_DAILY_THRESHOLD,
+                DEFAULT_NUD_FAILURE_COUNT_DAILY_THRESHOLD);
+        setDeviceConfigProperty(CONFIG_NUD_FAILURE_COUNT_WEEKLY_THRESHOLD,
+                DEFAULT_NUD_FAILURE_COUNT_WEEKLY_THRESHOLD);
     }
 
     private void awaitIpClientShutdown() throws Exception {
@@ -4307,6 +4343,9 @@
                 // neighbor reachability checking relevant test cases, that guarantees
                 // avoidingBadLinks() always returns true which is expected.
                 .withoutMultinetworkPolicyTracker()
+                // Make cluster as non-null to test the NUD failure event count query logic.
+                .withLayer2Information(new Layer2Information(TEST_L2KEY, TEST_CLUSTER,
+                       MacAddress.fromString(TEST_DEFAULT_BSSID)))
                 .build();
         startIpClientProvisioning(config);
         verify(mCb, timeout(TEST_TIMEOUT_MS)).setFallbackMulticastFilter(true);
@@ -5986,4 +6025,159 @@
         doDhcpHostnameSettingTest(IIpClient.HOSTNAME_SETTING_DO_NOT_SEND,
                 false /* isHostnameConfigurationEnabled */, false /* expectSendHostname */);
     }
+
+    // Store the network event to database multiple times.
+    private void storeNudFailureEvents(long when, long expiry, int times, int eventType) {
+        for (int i = 0; i < times; i++) {
+            storeNetworkEvent(TEST_CLUSTER, when, expiry, eventType);
+            when += 60 * 1000; // event interval is 1min
+            expiry += 60 * 1000; // expiry also delays 1min
+        }
+    }
+
+    @Test
+    @Flag(name = IP_REACHABILITY_IGNORE_NUD_FAILURE_VERSION, enabled = true)
+    @SignatureRequiredTest(reason = "need to delete cluster from real db in tearDown")
+    public void testIgnoreNudFailuresIfTooManyInPastDay() throws Exception {
+        // // NUD failure event count exceeds daily threshold nor weekly.
+        final long when = System.currentTimeMillis() - ONE_DAY_IN_MS / 2; // 12h ago
+        final long expiry = when + ONE_WEEK_IN_MS;
+        storeNudFailureEvents(when, expiry, 10, IIpMemoryStore.NETWORK_EVENT_NUD_FAILURE_ROAM);
+
+        runIpReachabilityMonitorProbeFailedTest();
+        assertNeverNotifyNeighborLost();
+    }
+
+    @Test
+    @Flag(name = IP_REACHABILITY_IGNORE_NUD_FAILURE_VERSION, enabled = false)
+    @SignatureRequiredTest(reason = "need to delete cluster from real db in tearDown")
+    public void testIgnoreNudFailuresIfTooManyInPastDay_flagOff() throws Exception {
+        // NUD failure event count exceeds daily threshold nor weekly.
+        final long when = System.currentTimeMillis() - ONE_DAY_IN_MS / 2; // 12h ago
+        final long expiry = when + ONE_WEEK_IN_MS;
+        storeNudFailureEvents(when, expiry, 19, IIpMemoryStore.NETWORK_EVENT_NUD_FAILURE_ROAM);
+
+        runIpReachabilityMonitorProbeFailedTest();
+        assertNotifyNeighborLost(ROUTER_LINK_LOCAL /* targetIp */,
+                NudEventType.NUD_POST_ROAMING_FAILED_CRITICAL);
+    }
+
+    @Test
+    @Flag(name = IP_REACHABILITY_IGNORE_NUD_FAILURE_VERSION, enabled = true)
+    @SignatureRequiredTest(reason = "need to delete cluster from real db in tearDown")
+    public void testIgnoreNudFailuresIfTooManyInPastDay_notUpToThreshold()
+            throws Exception {
+        // NUD failure event count doesn't exceed either weekly threshold nor daily.
+        final long when = System.currentTimeMillis() - ONE_DAY_IN_MS / 2; // 12h ago
+        final long expiry = when + ONE_WEEK_IN_MS;
+        storeNudFailureEvents(when, expiry, 9, IIpMemoryStore.NETWORK_EVENT_NUD_FAILURE_ROAM);
+
+        runIpReachabilityMonitorProbeFailedTest();
+        assertNotifyNeighborLost(ROUTER_LINK_LOCAL /* targetIp */,
+                NudEventType.NUD_POST_ROAMING_FAILED_CRITICAL);
+    }
+
+    @Test
+    @Flag(name = IP_REACHABILITY_IGNORE_NUD_FAILURE_VERSION, enabled = true)
+    @SignatureRequiredTest(reason = "need to delete cluster from real db in tearDown")
+    public void testIgnoreNudFailuresIfTooManyInPastWeek() throws Exception {
+        // NUD failure event count exceeds the weekly threshold, but not daily threshold in the past
+        // day.
+        long when = System.currentTimeMillis() - ONE_WEEK_IN_MS / 2; // half a week ago
+        long expiry = when + ONE_WEEK_IN_MS;
+        storeNudFailureEvents(when, expiry, 11, IIpMemoryStore.NETWORK_EVENT_NUD_FAILURE_ROAM);
+
+        when = System.currentTimeMillis() - ONE_DAY_IN_MS / 2; // 12h ago
+        expiry = when + ONE_WEEK_IN_MS;
+        storeNudFailureEvents(when, expiry, 9, IIpMemoryStore.NETWORK_EVENT_NUD_FAILURE_ROAM);
+
+        runIpReachabilityMonitorProbeFailedTest();
+        assertNeverNotifyNeighborLost();
+    }
+
+    @Test
+    @Flag(name = IP_REACHABILITY_IGNORE_NUD_FAILURE_VERSION, enabled = false)
+    @SignatureRequiredTest(reason = "need to delete cluster from real db in tearDown")
+    public void testIgnoreNudFailuresIfTooManyInPastWeek_flagOff() throws Exception {
+        // NUD failure event count exceeds the weekly threshold, but not daily threshold in the past
+        // day.
+        long when = System.currentTimeMillis() - ONE_WEEK_IN_MS / 2; // half a week ago
+        long expiry = when + ONE_WEEK_IN_MS;
+        storeNudFailureEvents(when, expiry, 11, IIpMemoryStore.NETWORK_EVENT_NUD_FAILURE_ROAM);
+
+        when = System.currentTimeMillis() - ONE_DAY_IN_MS / 2; // 12h ago
+        expiry = when + ONE_WEEK_IN_MS;
+        storeNudFailureEvents(when, expiry, 9, IIpMemoryStore.NETWORK_EVENT_NUD_FAILURE_ROAM);
+
+        runIpReachabilityMonitorProbeFailedTest();
+        assertNotifyNeighborLost(ROUTER_LINK_LOCAL /* targetIp */,
+                NudEventType.NUD_POST_ROAMING_FAILED_CRITICAL);
+    }
+
+    @Test
+    @Flag(name = IP_REACHABILITY_IGNORE_NUD_FAILURE_VERSION, enabled = true)
+    @SignatureRequiredTest(reason = "need to delete cluster from real db in tearDown")
+    public void testIgnoreNudFailuresIfTooManyInPastWeek_notUpToThreshold() throws Exception {
+        // NUD failure event count doesn't exceed either weekly threshold nor daily.
+        long when = System.currentTimeMillis() - ONE_WEEK_IN_MS / 2; // half a week ago
+        long expiry = when + ONE_WEEK_IN_MS;
+        storeNudFailureEvents(when, expiry, 10, IIpMemoryStore.NETWORK_EVENT_NUD_FAILURE_ROAM);
+
+        when = System.currentTimeMillis() - ONE_DAY_IN_MS / 2; // 12h ago
+        expiry = when + ONE_WEEK_IN_MS;
+        storeNudFailureEvents(when, expiry, 9, IIpMemoryStore.NETWORK_EVENT_NUD_FAILURE_ROAM);
+
+        runIpReachabilityMonitorProbeFailedTest();
+        assertNotifyNeighborLost(ROUTER_LINK_LOCAL /* targetIp */,
+                NudEventType.NUD_POST_ROAMING_FAILED_CRITICAL);
+    }
+
+    @Test
+    @Flag(name = IP_REACHABILITY_IGNORE_NUD_FAILURE_VERSION, enabled = true)
+    @SignatureRequiredTest(reason = "need to delete cluster from real db in tearDown")
+    public void testIgnoreNudFailuresIfTooManyInPastWeek_stopWritingEvent() throws Exception {
+        long when = (long) (System.currentTimeMillis() - SIX_HOURS_IN_MS * 0.9);
+        long expiry = when + ONE_WEEK_IN_MS;
+        storeNudFailureEvents(when, expiry, 10, IIpMemoryStore.NETWORK_EVENT_NUD_FAILURE_ORGANIC);
+
+        runIpReachabilityMonitorAddressResolutionTest(IPV6_OFF_LINK_DNS_SERVER,
+                ROUTER_LINK_LOCAL /* targetIp */,
+                false /* expectNeighborLost */);
+        verify(mIpMemoryStore, never()).storeNetworkEvent(any(), anyLong(), anyLong(),
+                eq(IIpMemoryStore.NETWORK_EVENT_NUD_FAILURE_ORGANIC), any());
+    }
+
+    @Test
+    @Flag(name = IP_REACHABILITY_IGNORE_NUD_FAILURE_VERSION, enabled = false)
+    @SignatureRequiredTest(reason = "need to delete cluster from real db in tearDown")
+    public void testIgnoreNudFailuresIfTooManyInPastWeek_stopWritingEvent_flagOff()
+            throws Exception {
+        long when = (long) (System.currentTimeMillis() - SIX_HOURS_IN_MS * 0.9);
+        long expiry = when + ONE_WEEK_IN_MS;
+        storeNudFailureEvents(when, expiry, 10, IIpMemoryStore.NETWORK_EVENT_NUD_FAILURE_ORGANIC);
+
+        runIpReachabilityMonitorAddressResolutionTest(IPV6_OFF_LINK_DNS_SERVER,
+                ROUTER_LINK_LOCAL /* targetIp */,
+                true /* expectNeighborLost */);
+        verify(mIpMemoryStore, never()).storeNetworkEvent(any(), anyLong(), anyLong(),
+                eq(IIpMemoryStore.NETWORK_EVENT_NUD_FAILURE_ORGANIC), any());
+    }
+
+    @Test
+    @Flag(name = IP_REACHABILITY_IGNORE_NUD_FAILURE_VERSION, enabled = true)
+    @SignatureRequiredTest(reason = "need to delete cluster from real db in tearDown")
+    public void testIgnoreNudFailuresIfTooManyInPastWeek_stopWritingEvent_notUpToThreshold()
+            throws Exception {
+        long when = (long) (System.currentTimeMillis() - SIX_HOURS_IN_MS * 0.9);
+        long expiry = when + ONE_WEEK_IN_MS;
+        storeNudFailureEvents(when, expiry, 9, IIpMemoryStore.NETWORK_EVENT_NUD_FAILURE_ORGANIC);
+
+        runIpReachabilityMonitorAddressResolutionTest(IPV6_OFF_LINK_DNS_SERVER,
+                ROUTER_LINK_LOCAL /* targetIp */,
+                true /* expectNeighborLost */);
+        assertNotifyNeighborLost(ROUTER_LINK_LOCAL /* targetIp */,
+                NudEventType.NUD_ORGANIC_FAILED_CRITICAL);
+        verify(mIpMemoryStore).storeNetworkEvent(any(), anyLong(), anyLong(),
+                eq(IIpMemoryStore.NETWORK_EVENT_NUD_FAILURE_ORGANIC), any());
+    }
 }
diff --git a/tests/integration/root/android/net/ip/IpClientRootTest.kt b/tests/integration/root/android/net/ip/IpClientRootTest.kt
index 2c7ce17..3a56139 100644
--- a/tests/integration/root/android/net/ip/IpClientRootTest.kt
+++ b/tests/integration/root/android/net/ip/IpClientRootTest.kt
@@ -25,6 +25,7 @@
 import android.net.NetworkStackIpMemoryStore
 import android.net.ipmemorystore.NetworkAttributes
 import android.net.ipmemorystore.OnNetworkAttributesRetrievedListener
+import android.net.ipmemorystore.OnNetworkEventCountRetrievedListener
 import android.net.ipmemorystore.Status
 import android.net.networkstack.TestNetworkStackServiceClient
 import android.os.Process
@@ -39,6 +40,7 @@
 import java.util.concurrent.CountDownLatch
 import java.util.concurrent.TimeUnit
 import java.util.concurrent.TimeoutException
+import kotlin.test.assertFalse
 import kotlin.test.assertNotNull
 import kotlin.test.assertNull
 import kotlin.test.assertTrue
@@ -250,6 +252,23 @@
         }
     }
 
+    private class TestNetworkEventCountRetrievedListener : OnNetworkEventCountRetrievedListener {
+        private val future = CompletableFuture<IntArray>()
+        override fun onNetworkEventCountRetrieved(
+            status: Status,
+            counts: IntArray
+        ) {
+            if (status.resultCode != Status.SUCCESS) {
+                fail("retrieved the network event count " + " status: " + status.resultCode)
+            }
+            future.complete(counts)
+        }
+
+        fun getBlockingNetworkEventCount(timeout: Long): IntArray {
+            return future.get(timeout, TimeUnit.MILLISECONDS)
+        }
+    }
+
     override fun getStoredNetworkAttributes(l2Key: String, timeout: Long): NetworkAttributes {
         val listener = TestAttributesRetrievedListener()
         mStore.retrieveNetworkAttributes(l2Key, listener)
@@ -268,6 +287,23 @@
         mStore.storeNetworkAttributes(l2Key, na, null /* listener */)
     }
 
+    override fun storeNetworkEvent(cluster: String, now: Long, expiry: Long, eventType: Int) {
+        mStore.storeNetworkEvent(cluster, now, expiry, eventType, null /* listener */)
+    }
+
+    override fun getStoredNetworkEventCount(
+            cluster: String,
+            sinceTimes: LongArray,
+            eventType: IntArray,
+            timeout: Long
+    ): IntArray {
+        val listener = TestNetworkEventCountRetrievedListener()
+        mStore.retrieveNetworkEventCount(cluster, sinceTimes, eventType, listener)
+        val counts = listener.getBlockingNetworkEventCount(timeout)
+        assertFalse(counts.size == 0)
+        return counts
+    }
+
     private fun readNudSolicitNumFromResource(name: String): Int {
         val packageName = nsClient.getNetworkStackPackageName()
         val resource = mContext.createPackageContext(packageName, 0).getResources()
diff --git a/tests/integration/signature/android/net/ip/IpClientSignatureTest.kt b/tests/integration/signature/android/net/ip/IpClientSignatureTest.kt
index f401073..c3b189a 100644
--- a/tests/integration/signature/android/net/ip/IpClientSignatureTest.kt
+++ b/tests/integration/signature/android/net/ip/IpClientSignatureTest.kt
@@ -21,6 +21,7 @@
 import android.net.ipmemorystore.Status
 import android.net.ipmemorystore.Status.SUCCESS
 import android.util.ArrayMap
+import android.util.Pair
 import org.mockito.ArgumentCaptor
 import org.mockito.Mockito.any
 import org.mockito.Mockito.doAnswer
@@ -63,6 +64,7 @@
     override fun getDeviceConfigProperty(name: String): String? {
         return mDeviceConfigProperties.get(name)
     }
+
     override fun getStoredNetworkAttributes(l2Key: String, timeout: Long): NetworkAttributes {
         val networkAttributesCaptor = ArgumentCaptor.forClass(NetworkAttributes::class.java)
 
@@ -71,6 +73,30 @@
         return networkAttributesCaptor.value
     }
 
+    override fun getStoredNetworkEventCount(
+            cluster: String,
+            sinceTimes: LongArray,
+            eventType: IntArray,
+            timeout: Long
+    ): IntArray {
+        val counts = IntArray(sinceTimes.size)
+        val eventTypesSet = eventType.toSet() // Convert eventType to Set for faster contains check
+
+        sinceTimes.forEachIndexed { index, sinceTime ->
+            var count = 0
+            mNetworkEvents.forEach { event ->
+                val key = event.first
+                val value = event.second
+                if (key == cluster && eventTypesSet.contains(value.second) &&
+                        sinceTime <= value.first) {
+                    count++
+                }
+            }
+            counts[index] = count
+        }
+        return counts
+    }
+
     override fun assertIpMemoryNeverStoreNetworkAttributes(l2Key: String, timeout: Long) {
         verify(mIpMemoryStore, never()).storeNetworkAttributes(eq(l2Key), any(), any())
     }
@@ -83,6 +109,11 @@
         }.`when`(mIpMemoryStore).retrieveNetworkAttributes(eq(l2Key), any())
     }
 
+    override fun storeNetworkEvent(cluster: String, now: Long, expiry: Long, eventType: Int) {
+        val event = Pair(cluster, Pair(now, eventType))
+        mNetworkEvents.add(event)
+    }
+
     override fun readNudSolicitNumInSteadyStateFromResource(): Int {
         return DEFAULT_NUD_SOLICIT_NUM_STEADY_STATE
     }
diff --git a/tests/unit/src/android/net/ip/ConnectivityPacketTrackerTest.kt b/tests/unit/src/android/net/ip/ConnectivityPacketTrackerTest.kt
index 1ee0ca5..51a871d 100644
--- a/tests/unit/src/android/net/ip/ConnectivityPacketTrackerTest.kt
+++ b/tests/unit/src/android/net/ip/ConnectivityPacketTrackerTest.kt
@@ -102,7 +102,7 @@
             010203040506000102030405080600010800060400015c857e3c74e1c0a8012200000000000000000000
         """.replace("\\s+".toRegex(), "").trim().uppercase()
         val arpPktByteArray = HexDump.hexStringToByteArray(arpPkt)
-        assertEquals(0, getTotalCapturePacketCount(packetTracker))
+        assertEquals(0, getCapturePacketTypeCount(packetTracker))
         assertEquals(0, getMatchedPacketCount(packetTracker, arpPkt))
 
         // start capture packet
@@ -113,12 +113,12 @@
             Thread.sleep(SLEEP_TIMEOUT_MS)
         }
 
-        assertEquals(1, getTotalCapturePacketCount(packetTracker))
+        assertEquals(1, getCapturePacketTypeCount(packetTracker))
         assertEquals(5, getMatchedPacketCount(packetTracker, arpPkt))
 
         // stop capture packet
         setCapture(packetTracker, false)
-        assertEquals(0, getTotalCapturePacketCount(packetTracker))
+        assertEquals(0, getCapturePacketTypeCount(packetTracker))
         assertEquals(0, getMatchedPacketCount(packetTracker, arpPkt))
     }
 
@@ -157,7 +157,7 @@
             assertEquals(1, getMatchedPacketCount(packetTracker, it))
         }
 
-        assertEquals(mDependencies.maxCapturePktSize, getTotalCapturePacketCount(packetTracker))
+        assertEquals(mDependencies.maxCapturePktSize, getCapturePacketTypeCount(packetTracker))
     }
 
     @Throws(InterruptedIOException::class, ErrnoException::class)
@@ -215,13 +215,13 @@
         return result.get(TIMEOUT_MS, TimeUnit.MILLISECONDS)
     }
 
-    private fun getTotalCapturePacketCount(
+    private fun getCapturePacketTypeCount(
         packetTracker: ConnectivityPacketTracker
     ): Int {
         val result = CompletableFuture<Int>()
         handler.post {
             try {
-                val totalCnt = packetTracker.totalCapturePacketCount
+                val totalCnt = packetTracker.capturePacketTypeCount
                 result.complete(totalCnt)
             } catch (e: Exception) {
                 result.completeExceptionally(e)
diff --git a/tests/unit/src/com/android/networkstack/ipmemorystore/IpMemoryStoreServiceTest.java b/tests/unit/src/com/android/networkstack/ipmemorystore/IpMemoryStoreServiceTest.java
index 7d11f80..ee6c48b 100644
--- a/tests/unit/src/com/android/networkstack/ipmemorystore/IpMemoryStoreServiceTest.java
+++ b/tests/unit/src/com/android/networkstack/ipmemorystore/IpMemoryStoreServiceTest.java
@@ -16,6 +16,14 @@
 
 package com.android.networkstack.ipmemorystore;
 
+import static android.net.ip.IpClient.NETWORK_EVENT_NUD_FAILURE_TYPES;
+import static android.net.ip.IpClient.ONE_DAY_IN_MS;
+import static android.net.ip.IpClient.ONE_WEEK_IN_MS;
+import static android.net.IIpMemoryStore.NETWORK_EVENT_NUD_FAILURE_ROAM;
+import static android.net.IIpMemoryStore.NETWORK_EVENT_NUD_FAILURE_CONFIRM;
+import static android.net.IIpMemoryStore.NETWORK_EVENT_NUD_FAILURE_ORGANIC;
+import static android.net.IIpMemoryStore.NETWORK_EVENT_NUD_FAILURE_MAC_ADDRESS_CHANGED;
+
 import static com.android.networkstack.ipmemorystore.IpMemoryStoreDatabase.DbHelper.SCHEMA_VERSION;
 import static com.android.networkstack.ipmemorystore.RegularMaintenanceJobService.InterruptMaintenance;
 
@@ -36,6 +44,7 @@
 import android.net.ipmemorystore.IOnBlobRetrievedListener;
 import android.net.ipmemorystore.IOnL2KeyResponseListener;
 import android.net.ipmemorystore.IOnNetworkAttributesRetrievedListener;
+import android.net.ipmemorystore.IOnNetworkEventCountRetrievedListener;
 import android.net.ipmemorystore.IOnSameL3NetworkResponseListener;
 import android.net.ipmemorystore.IOnStatusAndCountListener;
 import android.net.ipmemorystore.IOnStatusListener;
@@ -92,6 +101,13 @@
     private static final String TEST_CLIENT_ID = "testClientId";
     private static final String TEST_DATA_NAME = "testData";
     private static final String TEST_DATABASE_NAME = "test.db";
+    private static final String TEST_CLUSTER = "testCluster12345";
+    private static final String TEST_CLUSTER_1 = "testCluster01234";
+
+    private static final File FILES_DIR = InstrumentationRegistry.getContext().getFilesDir();
+    private static final String OLD_DB_NAME = "IpMemoryStore.db";
+    private static final File OLD_DB = new File(FILES_DIR, OLD_DB_NAME);
+    private static final File TEST_DB = new File(FILES_DIR, TEST_DATABASE_NAME);
 
     private static final int TEST_DATABASE_SIZE_THRESHOLD = 100 * 1024; //100KB
     private static final int DEFAULT_TIMEOUT_MS = 5000;
@@ -104,7 +120,6 @@
     private static final String[] FAKE_KEYS;
     private static final byte[] TEST_BLOB_DATA = new byte[]{-3, 6, 8, -9, 12,
             -128, 0, 89, 112, 91, -34};
-
     static {
         FAKE_KEYS = new String[FAKE_KEY_COUNT];
         for (int i = 0; i < FAKE_KEYS.length; ++i) {
@@ -120,16 +135,14 @@
 
     private IpMemoryStoreService mService;
 
-    @Before
-    public void setUp() {
-        MockitoAnnotations.initMocks(this);
-        final Context context = InstrumentationRegistry.getContext();
-        final File dir = context.getFilesDir();
-        mDbFile = new File(dir, TEST_DATABASE_NAME);
+    private IpMemoryStoreService createService() {
+        mDbFile = TEST_DB;
         doReturn(mDbFile).when(mMockContext).getDatabasePath(anyString());
+        doReturn(OLD_DB).when(mMockContext).getDatabasePath(OLD_DB_NAME);
+
         doReturn(mMockJobScheduler).when(mMockContext)
                 .getSystemService(Context.JOB_SCHEDULER_SERVICE);
-        mService = new IpMemoryStoreService(mMockContext) {
+        final IpMemoryStoreService service = new IpMemoryStoreService(mMockContext) {
             @Override
             protected int getDbSizeThreshold() {
                 return TEST_DATABASE_SIZE_THRESHOLD;
@@ -143,12 +156,20 @@
                 return super.isDbSizeOverThreshold();
             }
         };
+        return service;
+    }
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+        mService = createService();
     }
 
     @After
     public void tearDown() {
         mService.shutdown();
         mDbFile.delete();
+        if (OLD_DB.exists()) OLD_DB.delete();
     }
 
     private void copyTestData(final File file) throws Exception {
@@ -362,6 +383,37 @@
         };
     }
 
+    /** Helper method to make an IOnNetworkEventCountRetrievedListener */
+    private interface OnNetworkEventCountRetrievedListener {
+        void onNetworkEventCountRetrieved(Status status, int[] counts);
+    }
+
+    private IOnNetworkEventCountRetrievedListener onNetworkEventCountRetrieved(
+            final OnNetworkEventCountRetrievedListener functor) {
+        return new IOnNetworkEventCountRetrievedListener() {
+            @Override
+            public void onNetworkEventCountRetrieved(final StatusParcelable status,
+                    final int[] counts) throws RemoteException {
+                functor.onNetworkEventCountRetrieved(new Status(status), counts);
+            }
+
+            @Override
+            public IBinder asBinder() {
+                return null;
+            }
+
+            @Override
+            public int getInterfaceVersion() {
+                return this.VERSION;
+            }
+
+            @Override
+            public String getInterfaceHash() {
+                return this.HASH;
+            }
+        };
+    }
+
     // Helper method to factorize some boilerplate
     private void doLatched(final String timeoutMessage, final Consumer<CountDownLatch> functor) {
         doLatched(timeoutMessage, functor, DEFAULT_TIMEOUT_MS);
@@ -411,6 +463,23 @@
                 })));
     }
 
+    // Helper method to store network events (NUD failure) to database.
+    private void storeNetworkEventOrFail(final String cluster, final long now,
+            final long expiry, final int eventType) {
+        storeNetworkEventOrFail("Did not complete storing a network event", cluster, now,
+                expiry, eventType);
+    }
+
+    private void storeNetworkEventOrFail(final String timeoutMessage, final String cluster,
+            final long now, final long expiry, final int eventType) {
+        doLatched(timeoutMessage, latch -> mService.storeNetworkEvent(cluster, now, expiry,
+                eventType,
+                onStatus(status -> {
+                    assertTrue("Store not successful : " + status.resultCode, status.isSuccess());
+                    latch.countDown();
+                })));
+    }
+
     /**
      * This method is used to generate test.db file.
      *
@@ -452,6 +521,22 @@
         }
     }
 
+    private void generateFakeNetworkEvents() {
+        final int fakeEventCount = 1000;
+        final int expiredRecordsCount = 500;
+        final long now = System.currentTimeMillis();
+        for (int i = 0; i < fakeEventCount; i++) {
+            final long timestamp =
+                    i < expiredRecordsCount ? now - ONE_WEEK_IN_MS - i : now + i;
+            final long expiry = timestamp + ONE_WEEK_IN_MS;
+            storeNetworkEventOrFail(
+                    TEST_CLUSTER,
+                    timestamp,
+                    expiry,
+                    NETWORK_EVENT_NUD_FAILURE_TYPES[i % 4]);
+        }
+    }
+
     /** Wait for assigned time. */
     private void waitForMs(long ms) {
         try {
@@ -559,22 +644,26 @@
                         })));
     }
 
+    private void assertPrivateDataPresent(IpMemoryStoreService service, String l2Key) {
+        doLatched("Did not complete retrieving private data", latch ->
+                service.retrieveBlob(l2Key, TEST_CLIENT_ID, TEST_DATA_NAME, onBlobRetrieved(
+                        (status, key, name, data) -> {
+                            assertTrue("Retrieve blob status not successful : " + status.resultCode,
+                                    status.isSuccess());
+                            assertEquals(l2Key, key);
+                            assertEquals(name, TEST_DATA_NAME);
+                            assertTrue(Arrays.equals(TEST_BLOB_DATA, data));
+                            latch.countDown();
+                        })));
+    }
+
     @Test
     public void testPrivateData() {
         final String l2Key = FAKE_KEYS[0];
         final Blob b = new Blob();
         storeBlobOrFail(l2Key, b, TEST_BLOB_DATA);
 
-        doLatched("Did not complete retrieving private data", latch ->
-                mService.retrieveBlob(l2Key, TEST_CLIENT_ID, TEST_DATA_NAME, onBlobRetrieved(
-                        (status, key, name, data) -> {
-                            assertTrue("Retrieve blob status not successful : " + status.resultCode,
-                                    status.isSuccess());
-                            assertEquals(l2Key, key);
-                            assertEquals(name, TEST_DATA_NAME);
-                            assertTrue(Arrays.equals(b.data, data));
-                            latch.countDown();
-                        })));
+        assertPrivateDataPresent(mService, l2Key);
 
         // Most puzzling error message ever
         doLatched("Did not complete retrieving nothing", latch ->
@@ -821,6 +910,25 @@
     }
 
     @Test
+    public void testFullMaintenance_networkEvents() throws Exception {
+        generateFakeNetworkEvents();
+        // After inserting test data, the size of the DB should be larger than the threshold.
+        assertTrue(mService.isDbSizeOverThreshold());
+
+        final InterruptMaintenance im = new InterruptMaintenance(0/* Fake JobId */);
+        // Do full maintenance and then the db should go down in size and be under the threshold.
+        doLatched("Maintenance unexpectedly completed successfully", latch ->
+                mService.fullMaintenance(onStatus((status) -> {
+                    assertTrue("Execute full maintenance failed: "
+                            + status.resultCode, status.isSuccess());
+                    latch.countDown();
+                }), im), LONG_TIMEOUT_MS);
+
+        // If maintenance is successful, the db size shall meet the threshold.
+        assertFalse(mService.isDbSizeOverThreshold());
+    }
+
+    @Test
     public void testInterruptMaintenance() throws Exception {
         copyTestData(mDbFile);
         // After inserting test data, the size of the DB should be larger than the threshold.
@@ -986,6 +1094,424 @@
         assertFalse(ipMemoryStoreService.isDbSizeOverThreshold());
     }
 
+    /**
+     * Setup the NetworkEvents table with multiple NUD failure events before running each testcase.
+     *    times             eventType                               cluster           timestamp
+     *     10    NETWORK_EVENT_NUD_FAILURE_ROAM                  TEST_CLUSTER       1.5 weeks ago
+     *     10    NETWORK_EVENT_NUD_FAILURE_ORGANIC               TEST_CLUSTER_1     1   weeks ago
+     *     10    NETWORK_EVENT_NUD_FAILURE_MAC_ADDRESS_CHANGED   TEST_CLUSTER       0.8 weeks ago
+     *     10    NETWORK_EVENT_NUD_FAILURE_CONFIRM               TEST_CLUSTER       0.6 weeks ago
+     *     10    NETWORK_EVENT_NUD_FAILURE_ROAM                  TEST_CLUSTER_1     0.5 weeks ago
+     *     10    NETWORK_EVENT_NUD_FAILURE_ORGANIC               TEST_CLUSTER       6   hours ago
+     */
+    private void storeNetworkEventsForNudFailures(final long now) {
+        // Insert 10 NUD failure events post roam happened 1.5 weeks ago to TEST_CLUSTER.
+        long timestamp = (long) (now - ONE_WEEK_IN_MS * 1.5);
+        long expiry = timestamp + ONE_WEEK_IN_MS;
+        for (int i = 0; i < 10; i++) {
+            storeNetworkEventOrFail(TEST_CLUSTER, timestamp, expiry,
+                    NETWORK_EVENT_NUD_FAILURE_ROAM);
+        }
+
+        // Insert 10 NUD failure events due to organic check happened 1 weeks ago to
+        // TEST_CLUSTER_1.
+        timestamp = now - ONE_WEEK_IN_MS;
+        expiry = timestamp + ONE_WEEK_IN_MS;
+        for (int i = 0; i < 10; i++) {
+            storeNetworkEventOrFail(TEST_CLUSTER_1, timestamp, expiry,
+                    NETWORK_EVENT_NUD_FAILURE_ORGANIC);
+        }
+
+        // Insert 10 NUD failure events due to mac address change happened 0.8 weeks ago to
+        // TEST_CLUSTER.
+        timestamp = (long) (now - ONE_WEEK_IN_MS * 0.8);
+        expiry = timestamp + ONE_WEEK_IN_MS;
+        for (int i = 0; i < 10; i++) {
+            storeNetworkEventOrFail(TEST_CLUSTER, timestamp, expiry,
+                    NETWORK_EVENT_NUD_FAILURE_MAC_ADDRESS_CHANGED);
+        }
+
+        // Insert 10 NUD failure events from confirm happened 0.6 weeks ago to TEST_CLUSTER.
+        timestamp = (long) (now - ONE_WEEK_IN_MS * 0.6);
+        expiry = timestamp + ONE_WEEK_IN_MS;
+        for (int i = 0; i < 10; i++) {
+            storeNetworkEventOrFail(TEST_CLUSTER, timestamp, expiry,
+                    NETWORK_EVENT_NUD_FAILURE_CONFIRM);
+        }
+
+        // Insert 10 NUD failure events from confirm happened 0.5 weeks ago to TEST_CLUSTER_1.
+        timestamp = (long) (now - ONE_WEEK_IN_MS * 0.5);
+        expiry = timestamp + ONE_WEEK_IN_MS;
+        for (int i = 0; i < 10; i++) {
+            storeNetworkEventOrFail(TEST_CLUSTER_1, timestamp, expiry,
+                    NETWORK_EVENT_NUD_FAILURE_ROAM);
+        }
+
+        // Insert 10 NUD failure events from organic check 6 hours ago to TEST_CLUSTER.
+        timestamp = now - ONE_DAY_IN_MS / 4;
+        expiry = timestamp + ONE_WEEK_IN_MS;
+        for (int i = 0; i < 10; i++) {
+            storeNetworkEventOrFail(TEST_CLUSTER, timestamp, expiry,
+                    NETWORK_EVENT_NUD_FAILURE_ORGANIC);
+        }
+    }
+
+    @Test
+    public void testNetworkEventsQuery() {
+        final long now = System.currentTimeMillis();
+        storeNetworkEventsForNudFailures(now);
+
+        // Query network event counts for NUD failures within TEST_CLUSTER.
+        final long[] sinceTimes = new long[2];
+        sinceTimes[0] = now - ONE_WEEK_IN_MS;
+        sinceTimes[1] = now - ONE_DAY_IN_MS;
+        doLatched("Did not complete retrieving network event count", latch ->
+                mService.retrieveNetworkEventCount(TEST_CLUSTER,
+                        sinceTimes,
+                        NETWORK_EVENT_NUD_FAILURE_TYPES,
+                        onNetworkEventCountRetrieved(
+                            (status, counts) -> {
+                                assertTrue("Retrieve network event counts not successful : "
+                                        + status.resultCode, status.isSuccess());
+                                assertTrue(counts.length == 2);
+                                assertEquals(30, counts[0]);
+                                assertEquals(10, counts[1]);
+                                latch.countDown();
+                            })));
+
+        doLatched("Did not complete retrieving network event count", latch ->
+                mService.retrieveNetworkEventCount(TEST_CLUSTER_1,
+                        sinceTimes,
+                        NETWORK_EVENT_NUD_FAILURE_TYPES,
+                        onNetworkEventCountRetrieved(
+                            (status, counts) -> {
+                                assertTrue("Retrieve network event counts not successful : "
+                                        + status.resultCode, status.isSuccess());
+                                assertTrue(counts.length == 2);
+                                assertEquals(20, counts[0]);
+                                assertEquals(0, counts[1]);
+                                latch.countDown();
+                            })));
+    }
+
+    private int[] eventTypes(final int... eventTypes) {
+        return eventTypes;
+    }
+
+    @Test
+    public void testNetworkEventsQuery_differentEventTypes() {
+        final long now = System.currentTimeMillis();
+        storeNetworkEventsForNudFailures(now);
+
+        final long[] sinceTimes = new long[2];
+        sinceTimes[0] = now - ONE_WEEK_IN_MS;
+        sinceTimes[1] = now - ONE_DAY_IN_MS;
+        doLatched("Did not complete retrieving network event count", latch ->
+                mService.retrieveNetworkEventCount(TEST_CLUSTER,
+                        sinceTimes,
+                        eventTypes(NETWORK_EVENT_NUD_FAILURE_ROAM,
+                                NETWORK_EVENT_NUD_FAILURE_CONFIRM),
+                        onNetworkEventCountRetrieved(
+                            (status, counts) -> {
+                                assertTrue("Retrieve network event counts not successful : "
+                                        + status.resultCode, status.isSuccess());
+                                assertTrue(counts.length == 2);
+                                assertEquals(10, counts[0]);
+                                assertEquals(0, counts[1]);
+                                latch.countDown();
+                            })));
+
+        doLatched("Did not complete retrieving network event count", latch ->
+                mService.retrieveNetworkEventCount(TEST_CLUSTER,
+                        sinceTimes,
+                        eventTypes(NETWORK_EVENT_NUD_FAILURE_ORGANIC,
+                                NETWORK_EVENT_NUD_FAILURE_MAC_ADDRESS_CHANGED),
+                        onNetworkEventCountRetrieved(
+                            (status, counts) -> {
+                                assertTrue("Retrieve network event counts not successful : "
+                                        + status.resultCode, status.isSuccess());
+                                assertTrue(counts.length == 2);
+                                assertEquals(20, counts[0]);
+                                assertEquals(10, counts[1]);
+                                latch.countDown();
+                            })));
+
+        doLatched("Did not complete retrieving network event count", latch ->
+                mService.retrieveNetworkEventCount(TEST_CLUSTER_1,
+                        sinceTimes,
+                        eventTypes(NETWORK_EVENT_NUD_FAILURE_ORGANIC,
+                                NETWORK_EVENT_NUD_FAILURE_MAC_ADDRESS_CHANGED),
+                        onNetworkEventCountRetrieved(
+                            (status, counts) -> {
+                                assertTrue("Retrieve network event counts not successful : "
+                                        + status.resultCode, status.isSuccess());
+                                assertTrue(counts.length == 2);
+                                assertEquals(10, counts[0]);
+                                assertEquals(0, counts[1]);
+                                latch.countDown();
+                            })));
+
+    }
+
+    @Test
+    public void testNetworkEventsQuery_querySinceLastOneWeek() {
+        final long now = System.currentTimeMillis();
+        storeNetworkEventsForNudFailures(now);
+
+        final long[] sinceTimes = new long[] { now - ONE_WEEK_IN_MS };
+        doLatched("Did not complete retrieving network event count", latch ->
+                mService.retrieveNetworkEventCount(TEST_CLUSTER,
+                        sinceTimes,
+                        NETWORK_EVENT_NUD_FAILURE_TYPES,
+                        onNetworkEventCountRetrieved(
+                            (status, counts) -> {
+                                assertTrue("Retrieve network event counts not successful : "
+                                        + status.resultCode, status.isSuccess());
+                                assertTrue(counts.length == 1);
+                                assertEquals(30, counts[0]);
+                                latch.countDown();
+                            })));
+
+        doLatched("Did not complete retrieving network event count", latch ->
+                mService.retrieveNetworkEventCount(TEST_CLUSTER_1,
+                        sinceTimes,
+                        NETWORK_EVENT_NUD_FAILURE_TYPES,
+                        onNetworkEventCountRetrieved(
+                            (status, counts) -> {
+                                assertTrue("Retrieve network event counts not successful : "
+                                        + status.resultCode, status.isSuccess());
+                                assertTrue(counts.length == 1);
+                                assertEquals(20, counts[0]);
+                                latch.countDown();
+                            })));
+    }
+
+    @Test
+    public void testNetworkEventsQuery_querySinceLastOneDay() {
+        final long now = System.currentTimeMillis();
+        storeNetworkEventsForNudFailures(now);
+
+        // Query network event count for NUD failures in past day within the same cluster.
+        final long[] sinceTimes = new long[] { now - ONE_DAY_IN_MS };
+        doLatched("Did not complete retrieving network event count", latch ->
+                mService.retrieveNetworkEventCount(TEST_CLUSTER,
+                        sinceTimes,
+                        NETWORK_EVENT_NUD_FAILURE_TYPES,
+                        onNetworkEventCountRetrieved(
+                            (status, counts) -> {
+                                assertTrue("Retrieve network event counts not successful : "
+                                        + status.resultCode, status.isSuccess());
+                                assertTrue(counts.length == 1);
+                                assertEquals(10, counts[0]);
+                                latch.countDown();
+                            })));
+
+        doLatched("Did not complete retrieving network event count", latch ->
+                mService.retrieveNetworkEventCount(TEST_CLUSTER_1,
+                        sinceTimes,
+                        NETWORK_EVENT_NUD_FAILURE_TYPES,
+                        onNetworkEventCountRetrieved(
+                            (status, counts) -> {
+                                assertTrue("Retrieve network event counts not successful : "
+                                        + status.resultCode, status.isSuccess());
+                                assertTrue(counts.length == 1);
+                                assertEquals(0, counts[0]);
+                                latch.countDown();
+                            })));
+    }
+
+    @Test
+    public void testNetworkEventsQuery_wrongCluster() {
+        final long now = System.currentTimeMillis();
+        storeNetworkEventsForNudFailures(now);
+
+        // Query network event count for NUD failures within the same cluster.
+        final long[] sinceTimes = new long[2];
+        sinceTimes[0] = now - ONE_WEEK_IN_MS;
+        sinceTimes[1] = now - ONE_DAY_IN_MS;
+        final int[] eventTypes = new int[] { NETWORK_EVENT_NUD_FAILURE_ROAM };
+        doLatched("Did not complete retrieving network event count", latch ->
+                mService.retrieveNetworkEventCount("wrong_cluster_to_query",
+                        sinceTimes,
+                        eventTypes,
+                        onNetworkEventCountRetrieved(
+                            (status, counts) -> {
+                                assertTrue("Retrieve network event counts not successful : "
+                                        + status.resultCode, status.isSuccess());
+                                assertTrue(counts.length == 2);
+                                assertEquals(0, counts[0]);
+                                assertEquals(0, counts[1]);
+                                latch.countDown();
+                            })));
+    }
+
+    @Test
+    public void testNetworkEventsQuery_nullCluster() {
+        final long now = System.currentTimeMillis();
+        storeNetworkEventsForNudFailures(now);
+
+        // Query network event count for NUD failures within the same cluster.
+        final long[] sinceTimes = new long[2];
+        sinceTimes[0] = now - ONE_WEEK_IN_MS;
+        sinceTimes[1] = now - ONE_DAY_IN_MS;
+        final int[] eventTypes = new int[] { NETWORK_EVENT_NUD_FAILURE_ROAM };
+        doLatched("Did not complete retrieving network event count", latch ->
+                mService.retrieveNetworkEventCount(null /* cluster */,
+                        sinceTimes,
+                        eventTypes,
+                        onNetworkEventCountRetrieved(
+                            (status, counts) -> {
+                                assertFalse("Success retrieving network event count",
+                                        status.isSuccess());
+                                assertEquals(Status.ERROR_ILLEGAL_ARGUMENT, status.resultCode);
+                                assertTrue(counts.length == 0);
+                                latch.countDown();
+                            })));
+    }
+
+    @Test
+    public void testNetworkEventsQuery_emptyQueryEventType() {
+        final long now = System.currentTimeMillis();
+        storeNetworkEventsForNudFailures(now);
+
+        // Query network event count for NUD failure within the same cluster but event type to
+        // be queried is empty, an empty counts should be returned.
+        final long[] sinceTimes = new long[2];
+        sinceTimes[0] = now - ONE_WEEK_IN_MS;
+        sinceTimes[1] = now - ONE_DAY_IN_MS;
+        final int[] eventTypes = new int[0];
+        doLatched("Did not complete retrieving network event count", latch ->
+                mService.retrieveNetworkEventCount(TEST_CLUSTER,
+                        sinceTimes,
+                        eventTypes,
+                        onNetworkEventCountRetrieved(
+                            (status, counts) -> {
+                                assertTrue("Retrieve network event counts not successful : "
+                                        + status.resultCode, status.isSuccess());
+                                assertTrue(counts.length == 2);
+                                assertEquals(0, counts[0]);
+                                assertEquals(0, counts[1]);
+                                latch.countDown();
+                            })));
+    }
+
+    @Test
+    public void testNetworkEventsQuery_emptySinceTimes() {
+        final long now = System.currentTimeMillis();
+        storeNetworkEventsForNudFailures(now);
+
+        // Query network event count for NUD failure within the same cluster but sinceTimes is
+        // empty, en empty count array will be returned and ERROR_ILLEGAL_ARGUMENT status.
+        final long[] sinceTimes = new long[0];
+        doLatched("Did not complete retrieving network event count", latch ->
+                mService.retrieveNetworkEventCount(TEST_CLUSTER,
+                        sinceTimes,
+                        NETWORK_EVENT_NUD_FAILURE_TYPES,
+                        onNetworkEventCountRetrieved(
+                            (status, counts) -> {
+                                assertFalse("Success retrieving network event count",
+                                        status.isSuccess());
+                                assertEquals(Status.ERROR_ILLEGAL_ARGUMENT, status.resultCode);
+                                assertTrue(counts.length == 0);
+                                latch.countDown();
+                            })));
+    }
+
+    @Test
+    public void testNetworkEventsQuery_wrongEventType() {
+        final long now = System.currentTimeMillis();
+        final long expiry = now + ONE_WEEK_IN_MS;
+        storeNetworkEventOrFail(TEST_CLUSTER, now, expiry, -1 /* nonexistent event type */);
+
+        // Query network event count for NUD failure within the same cluster but event type doesn't
+        // match.
+        final long[] sinceTimes = new long[2];
+        sinceTimes[0] = now - ONE_WEEK_IN_MS;
+        sinceTimes[1] = now - ONE_DAY_IN_MS;
+        doLatched("Did not complete retrieving network event count", latch ->
+                mService.retrieveNetworkEventCount(TEST_CLUSTER,
+                        sinceTimes,
+                        NETWORK_EVENT_NUD_FAILURE_TYPES,
+                        onNetworkEventCountRetrieved(
+                            (status, counts) -> {
+                                assertTrue("Retrieve network event counts not successful : "
+                                        + status.resultCode, status.isSuccess());
+                                assertTrue(counts.length == 2);
+                                assertEquals(0, counts[0]);
+                                assertEquals(0, counts[1]);
+                                latch.countDown();
+                            })));
+    }
+
+    @Test
+    public void testStoreNetworkEvent_nullCluster() {
+        final long now = System.currentTimeMillis();
+        final long expiry = now + ONE_WEEK_IN_MS;
+        doLatched("Did not complete storing a network event", latch ->
+                mService.storeNetworkEvent(null /* cluster */, now, expiry,
+                        NETWORK_EVENT_NUD_FAILURE_ROAM,
+                        onStatus(status -> {
+                            assertFalse("Success storing a network event with null cluster",
+                                    status.isSuccess());
+                            assertEquals(Status.ERROR_ILLEGAL_ARGUMENT, status.resultCode);
+                            latch.countDown();
+                        })));
+    }
+
+    @Test
+    public void testRenameDb_noExistingDb_newDbCreated() throws Exception {
+        mService.shutdown();
+        TEST_DB.delete();
+        assertFalse(TEST_DB.exists());
+
+        assertFalse(OLD_DB.exists());
+        assertFalse(TEST_DB.exists());
+
+        final IpMemoryStoreService service = createService();
+        service.shutdown();
+        assertFalse(OLD_DB.exists());
+        assertTrue(TEST_DB.exists());
+    }
+
+    @Test
+    public void testRenameDb_existingDb_becomesNewDb() throws Exception {
+        mService.shutdown();
+        TEST_DB.delete();
+        assertFalse(TEST_DB.exists());
+
+        assertFalse(OLD_DB.exists());
+        copyTestData(OLD_DB);
+        assertTrue(OLD_DB.exists());
+
+        final IpMemoryStoreService service = createService();
+        assertPrivateDataPresent(service, FAKE_KEYS[0]);
+        assertFalse(OLD_DB.exists());
+        assertTrue(TEST_DB.exists());
+
+        service.shutdown();
+    }
+
+    @Test
+    public void testRenameDb_existingDb_overwritesNewDb() throws Exception {
+        mService.shutdown();
+        // Replace the new DB with garbage. This lets us check that the data survives the rename.
+        try (FileOutputStream out = new FileOutputStream(TEST_DB, false /* append */)) {
+            out.write(new byte[]{'g', 'a', 'r', 'b', 'a', 'g', 'e'});
+        }
+        assertTrue(TEST_DB.exists());
+
+        assertFalse(OLD_DB.exists());
+        copyTestData(OLD_DB);
+        assertTrue(OLD_DB.exists());
+
+        final IpMemoryStoreService service = createService();
+        assertPrivateDataPresent(service, FAKE_KEYS[0]);
+        assertFalse(OLD_DB.exists());
+        assertTrue(TEST_DB.exists());
+
+        service.shutdown();
+    }
+
     private void doTestDowngradeAndUpgrade(int downgradeVersion) {
         SQLiteOpenHelper dbHelper = new IpMemoryStoreDatabase.DbHelper(
                 mMockContext, downgradeVersion);
@@ -1000,7 +1526,7 @@
     }
 
     @Test
-    public void testDowngradeClearsTablesAndriggers() {
+    public void testDowngradeClearsTablesAndTriggers() {
         final String l2Key = FAKE_KEYS[0];
         final Blob b = new Blob();
         storeBlobOrFail(l2Key, b, TEST_BLOB_DATA);
diff --git a/tests/unit/src/com/android/server/connectivity/FakeDns.java b/tests/unit/src/com/android/server/connectivity/FakeDns.java
new file mode 100644
index 0000000..e689f49
--- /dev/null
+++ b/tests/unit/src/com/android/server/connectivity/FakeDns.java
@@ -0,0 +1,279 @@
+/*
+ * 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.connectivity;
+
+import static android.net.DnsResolver.TYPE_A;
+import static android.net.DnsResolver.TYPE_AAAA;
+import static android.net.InetAddresses.parseNumericAddress;
+
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyInt;
+import static org.mockito.Mockito.doAnswer;
+
+import android.net.DnsResolver;
+import android.net.Network;
+import android.os.Handler;
+import android.os.Looper;
+
+import androidx.annotation.NonNull;
+
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * Fakes DNS responses.
+ *
+ * Allows test methods to configure the IP addresses that will be resolved by
+ * Network#getAllByName and by various DnsResolver query methods.
+ */
+public class FakeDns {
+    private static final int HANDLER_TIMEOUT_MS = 1000;
+
+    @NonNull
+    private final Network mNetwork;
+    @NonNull
+    private final DnsResolver mDnsResolver;
+    private final ArrayList<DnsEntry> mAnswers = new ArrayList<>();
+    private boolean mNonBypassPrivateDnsWorking = true;
+
+    public FakeDns(@NonNull Network network, @NonNull DnsResolver dnsResolver) {
+
+        mNetwork = Objects.requireNonNull(network);
+        mDnsResolver = Objects.requireNonNull(dnsResolver);
+    }
+
+    /** Data class to record the Dns entry. */
+    private static class DnsEntry {
+        final String mHostname;
+        final int mType;
+        final AnswerSupplier mAnswerSupplier;
+        DnsEntry(String host, int type, AnswerSupplier answerSupplier) {
+            mHostname = host;
+            mType = type;
+            mAnswerSupplier = answerSupplier;
+        }
+        // Full match or partial match that target host contains the entry hostname to support
+        // random private dns probe hostname.
+        private boolean matches(String hostname, int type) {
+            return hostname.endsWith(mHostname) && type == mType;
+        }
+    }
+
+    public interface AnswerSupplier {
+        /** Supplies the answer to one DnsResolver query method call. */
+        List<String> get() throws DnsResolver.DnsException;
+    }
+
+    private static class InstantAnswerSupplier implements AnswerSupplier {
+        private final List<String> mAnswers;
+        InstantAnswerSupplier(List<String> answers) {
+            mAnswers = answers;
+        }
+        @Override
+        public List<String> get() {
+            return mAnswers;
+        }
+    }
+
+    /** Whether DNS queries on mNonBypassPrivateDnsWorking should succeed. */
+    public void setNonBypassPrivateDnsWorking(boolean working) {
+        mNonBypassPrivateDnsWorking = working;
+    }
+
+    /** Clears all DNS entries. */
+    public void clearAll() {
+        synchronized (mAnswers) {
+            mAnswers.clear();
+        }
+    }
+
+    /** Returns the answer for a given name and type on the given mock network. */
+    private CompletableFuture<List<String>> getAnswer(Network mockNetwork, String hostname,
+            int type) {
+        if (mNetwork.equals(mockNetwork) && !mNonBypassPrivateDnsWorking) {
+            return CompletableFuture.completedFuture(null);
+        }
+
+        final AnswerSupplier answerSupplier;
+
+        synchronized (mAnswers) {
+            answerSupplier = mAnswers.stream()
+                    .filter(e -> e.matches(hostname, type))
+                    .map(answer -> answer.mAnswerSupplier).findFirst().orElse(null);
+        }
+        if (answerSupplier == null) {
+            return CompletableFuture.completedFuture(null);
+        }
+
+        if (answerSupplier instanceof InstantAnswerSupplier) {
+            // Save latency waiting for a query thread if the answer is hardcoded.
+            return CompletableFuture.completedFuture(
+                    ((InstantAnswerSupplier) answerSupplier).get());
+        }
+        final CompletableFuture<List<String>> answerFuture = new CompletableFuture<>();
+        new Thread(() -> {
+            try {
+                answerFuture.complete(answerSupplier.get());
+            } catch (DnsResolver.DnsException e) {
+                answerFuture.completeExceptionally(e);
+            }
+        }).start();
+        return answerFuture;
+    }
+
+    /** Sets the answer for a given name and type. */
+    public void setAnswer(String hostname, String[] answer, int type) {
+        setAnswer(hostname, new InstantAnswerSupplier(
+                (answer == null) ? null : Arrays.asList(answer)), type);
+    }
+
+    /** Sets the answer for a given name and type. */
+    public void setAnswer(String hostname, AnswerSupplier answerSupplier, int type) {
+        DnsEntry record = new DnsEntry(hostname, type, answerSupplier);
+        synchronized (mAnswers) {
+            // Remove the existing one.
+            mAnswers.removeIf(entry -> entry.matches(hostname, type));
+            // Add or replace a new record.
+            mAnswers.add(record);
+        }
+    }
+
+    /** Simulates a getAllByName call for the specified name on the specified mock network. */
+    private InetAddress[] getAllByName(Network mockNetwork, String hostname)
+            throws UnknownHostException {
+        final List<InetAddress> answer;
+        try {
+            answer = stringsToInetAddresses(queryAllTypes(mockNetwork, hostname).get(
+                    HANDLER_TIMEOUT_MS, TimeUnit.MILLISECONDS));
+        } catch (ExecutionException | InterruptedException | TimeoutException e) {
+            throw new AssertionError("No mock DNS reply within timeout", e);
+        }
+        if (answer == null || answer.size() == 0) {
+            throw new UnknownHostException(hostname);
+        }
+        return answer.toArray(new InetAddress[0]);
+    }
+
+    // Regardless of the type, depends on what the responses contained in the network.
+    @SuppressWarnings("FutureReturnValueIgnored")
+    private CompletableFuture<List<String>> queryAllTypes(
+            Network mockNetwork, String hostname) {
+        if (mNetwork.equals(mockNetwork) && !mNonBypassPrivateDnsWorking) {
+            return CompletableFuture.completedFuture(null);
+        }
+
+        final CompletableFuture<List<String>> aFuture =
+                getAnswer(mockNetwork, hostname, TYPE_A)
+                        .exceptionally(e -> Collections.emptyList());
+        final CompletableFuture<List<String>> aaaaFuture =
+                getAnswer(mockNetwork, hostname, TYPE_AAAA)
+                        .exceptionally(e -> Collections.emptyList());
+
+        final CompletableFuture<List<String>> combinedFuture = new CompletableFuture<>();
+        aFuture.thenAcceptBoth(aaaaFuture, (res1, res2) -> {
+            final List<String> answer = new ArrayList<>();
+            if (res1 != null) answer.addAll(res1);
+            if (res2 != null) answer.addAll(res2);
+            combinedFuture.complete(answer);
+        });
+        return combinedFuture;
+    }
+
+    /** Starts mocking DNS queries. */
+    public void startMocking() throws UnknownHostException {
+        // Queries on mNetwork using getAllByName.
+        doAnswer(invocation -> {
+            return getAllByName((Network) invocation.getMock(), invocation.getArgument(0));
+        }).when(mNetwork).getAllByName(any());
+
+        // Queries on mCleartextDnsNetwork using DnsResolver#query.
+        doAnswer(invocation -> {
+            return mockQuery(invocation, 0 /* posNetwork */, 1 /* posHostname */,
+                    3 /* posExecutor */, 5 /* posCallback */, -1 /* posType */);
+        }).when(mDnsResolver).query(any(), any(), anyInt(), any(), any(), any());
+
+        // Queries on mCleartextDnsNetwork using DnsResolver#query with QueryType.
+        doAnswer(invocation -> {
+            return mockQuery(invocation, 0 /* posNetwork */, 1 /* posHostname */,
+                    4 /* posExecutor */, 6 /* posCallback */, 2 /* posType */);
+        }).when(mDnsResolver).query(any(), any(), anyInt(), anyInt(), any(), any(), any());
+    }
+
+    private List<InetAddress> stringsToInetAddresses(List<String> addrs) {
+        if (addrs == null) return null;
+        final List<InetAddress> out = new ArrayList<>();
+        for (String addr : addrs) {
+            out.add(parseNumericAddress(addr));
+        }
+        return out;
+    }
+
+    // Mocks all the DnsResolver query methods used in this test.
+    @SuppressWarnings("FutureReturnValueIgnored")
+    private Answer mockQuery(InvocationOnMock invocation, int posNetwork, int posHostname,
+            int posExecutor, int posCallback, int posType) {
+        String hostname = invocation.getArgument(posHostname);
+        Executor executor = invocation.getArgument(posExecutor);
+        Network network = invocation.getArgument(posNetwork);
+        DnsResolver.Callback callback = invocation.getArgument(posCallback);
+
+        final CompletableFuture<List<String>> answerFuture = (posType != -1)
+                ? getAnswer(network, hostname, invocation.getArgument(posType))
+                : queryAllTypes(network, hostname);
+
+        answerFuture.whenComplete((answer, exception) -> {
+            new Handler(Looper.getMainLooper()).post(() -> executor.execute(() -> {
+                if (exception != null) {
+                    if (!(exception instanceof DnsResolver.DnsException)) {
+                        throw new AssertionError("Test error building DNS response", exception);
+                    }
+                    callback.onError((DnsResolver.DnsException) exception);
+                    return;
+                }
+                if (answer != null && answer.size() > 0) {
+                    final int qtype = (posType != -1)
+                            ? invocation.getArgument(posType) : TYPE_AAAA;
+                    switch (qtype) {
+                        // Assume A and AAAA queries use the List<InetAddress> callback.
+                        case TYPE_A:
+                        case TYPE_AAAA:
+                            callback.onAnswer(stringsToInetAddresses(answer), 0);
+                            break;
+                        default:
+                            throw new UnsupportedOperationException(
+                                    "Unsupported qtype: " + qtype + ", update this fake");
+                    }
+                }
+            }));
+        });
+        // If the future does not complete or has no answer do nothing. The timeout should fire.
+        return null;
+    }
+}
diff --git a/tests/unit/src/com/android/server/connectivity/NetworkMonitorTest.java b/tests/unit/src/com/android/server/connectivity/NetworkMonitorTest.java
index 9a86d9f..64196dd 100644
--- a/tests/unit/src/com/android/server/connectivity/NetworkMonitorTest.java
+++ b/tests/unit/src/com/android/server/connectivity/NetworkMonitorTest.java
@@ -138,9 +138,7 @@
 import android.os.Build;
 import android.os.Bundle;
 import android.os.ConditionVariable;
-import android.os.Handler;
 import android.os.IBinder;
-import android.os.Looper;
 import android.os.Process;
 import android.os.RemoteException;
 import android.os.SystemClock;
@@ -200,8 +198,6 @@
 import org.mockito.Mockito;
 import org.mockito.MockitoAnnotations;
 import org.mockito.Spy;
-import org.mockito.invocation.InvocationOnMock;
-import org.mockito.stubbing.Answer;
 
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
@@ -223,13 +219,9 @@
 import java.util.Map;
 import java.util.Objects;
 import java.util.Random;
-import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.Executor;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
 import java.util.function.Predicate;
 
 import javax.net.ssl.SSLHandshakeException;
@@ -375,224 +367,6 @@
                 .addCapability(NET_CAPABILITY_OEM_PAID)
                 .removeCapability(NET_CAPABILITY_NOT_RESTRICTED);
 
-    /**
-     * Fakes DNS responses.
-     *
-     * Allows test methods to configure the IP addresses that will be resolved by
-     * Network#getAllByName and by DnsResolver#query.
-     */
-    class FakeDns {
-        /** Data class to record the Dns entry. */
-        class DnsEntry {
-            final String mHostname;
-            final int mType;
-            final AnswerSupplier mAnswerSupplier;
-            DnsEntry(String host, int type, AnswerSupplier answerSupplier) {
-                mHostname = host;
-                mType = type;
-                mAnswerSupplier = answerSupplier;
-            }
-            // Full match or partial match that target host contains the entry hostname to support
-            // random private dns probe hostname.
-            private boolean matches(String hostname, int type) {
-                return hostname.endsWith(mHostname) && type == mType;
-            }
-        }
-        interface AnswerSupplier {
-            List<String> get() throws DnsResolver.DnsException;
-        }
-
-        class InstantAnswerSupplier implements AnswerSupplier {
-            private final List<String> mAnswers;
-            InstantAnswerSupplier(List<String> answers) {
-                mAnswers = answers;
-            }
-            @Override
-            public List<String> get() {
-                return mAnswers;
-            }
-        }
-
-        private final ArrayList<DnsEntry> mAnswers = new ArrayList<>();
-        private boolean mNonBypassPrivateDnsWorking = true;
-
-        /** Whether DNS queries on mNonBypassPrivateDnsWorking should succeed. */
-        private void setNonBypassPrivateDnsWorking(boolean working) {
-            mNonBypassPrivateDnsWorking = working;
-        }
-
-        /** Clears all DNS entries. */
-        private void clearAll() {
-            synchronized (mAnswers) {
-                mAnswers.clear();
-            }
-        }
-
-        /** Returns the answer for a given name and type on the given mock network. */
-        private CompletableFuture<List<String>> getAnswer(Network mockNetwork, String hostname,
-                int type) {
-            if (mockNetwork == mNetwork && !mNonBypassPrivateDnsWorking) {
-                return CompletableFuture.completedFuture(null);
-            }
-
-            final AnswerSupplier answerSupplier;
-
-            synchronized (mAnswers) {
-                answerSupplier = mAnswers.stream()
-                        .filter(e -> e.matches(hostname, type))
-                        .map(answer -> answer.mAnswerSupplier).findFirst().orElse(null);
-            }
-            if (answerSupplier == null) {
-                return CompletableFuture.completedFuture(null);
-            }
-
-            if (answerSupplier instanceof InstantAnswerSupplier) {
-                // Save latency waiting for a query thread if the answer is hardcoded.
-                return CompletableFuture.completedFuture(
-                        ((InstantAnswerSupplier) answerSupplier).get());
-            }
-            final CompletableFuture<List<String>> answerFuture = new CompletableFuture<>();
-            new Thread(() -> {
-                try {
-                    answerFuture.complete(answerSupplier.get());
-                } catch (DnsResolver.DnsException e) {
-                    answerFuture.completeExceptionally(e);
-                }
-            }).start();
-            return answerFuture;
-        }
-
-        /** Sets the answer for a given name and type. */
-        private void setAnswer(String hostname, String[] answer, int type) {
-            setAnswer(hostname, new InstantAnswerSupplier(
-                    (answer == null) ? null : Arrays.asList(answer)), type);
-        }
-
-        private void setAnswer(String hostname, AnswerSupplier answerSupplier, int type) {
-            DnsEntry record = new DnsEntry(hostname, type, answerSupplier);
-            synchronized (mAnswers) {
-                // Remove the existing one.
-                mAnswers.removeIf(entry -> entry.matches(hostname, type));
-                // Add or replace a new record.
-                mAnswers.add(record);
-            }
-        }
-
-        private byte[] makeSvcbResponse(String hostname, List<String> answer) {
-            throw new UnsupportedOperationException("Not supported, update this fake");
-        }
-
-        /** Simulates a getAllByName call for the specified name on the specified mock network. */
-        private InetAddress[] getAllByName(Network mockNetwork, String hostname)
-                throws UnknownHostException {
-            final List<InetAddress> answer;
-            try {
-                answer = stringsToInetAddresses(queryAllTypes(mockNetwork, hostname).get(
-                        HANDLER_TIMEOUT_MS, TimeUnit.MILLISECONDS));
-            } catch (ExecutionException | InterruptedException | TimeoutException e) {
-                throw new AssertionError("No mock DNS reply within timeout", e);
-            }
-            if (answer == null || answer.size() == 0) {
-                throw new UnknownHostException(hostname);
-            }
-            return answer.toArray(new InetAddress[0]);
-        }
-
-        // Regardless of the type, depends on what the responses contained in the network.
-        private CompletableFuture<List<String>> queryAllTypes(
-                Network mockNetwork, String hostname) {
-            if (mockNetwork == mNetwork && !mNonBypassPrivateDnsWorking) {
-                return CompletableFuture.completedFuture(null);
-            }
-
-            final CompletableFuture<List<String>> aFuture =
-                    getAnswer(mockNetwork, hostname, TYPE_A)
-                            .exceptionally(e -> Collections.emptyList());
-            final CompletableFuture<List<String>> aaaaFuture =
-                    getAnswer(mockNetwork, hostname, TYPE_AAAA)
-                            .exceptionally(e -> Collections.emptyList());
-
-            final CompletableFuture<List<String>> combinedFuture = new CompletableFuture<>();
-            aFuture.thenAcceptBoth(aaaaFuture, (res1, res2) -> {
-                final List<String> answer = new ArrayList<>();
-                if (res1 != null) answer.addAll(res1);
-                if (res2 != null) answer.addAll(res2);
-                combinedFuture.complete(answer);
-            });
-            return combinedFuture;
-        }
-
-        /** Starts mocking DNS queries. */
-        private void startMocking() throws UnknownHostException {
-            // Queries on mNetwork using getAllByName.
-            doAnswer(invocation -> {
-                return getAllByName((Network) invocation.getMock(), invocation.getArgument(0));
-            }).when(mNetwork).getAllByName(any());
-
-            // Queries on mCleartextDnsNetwork using DnsResolver#query.
-            doAnswer(invocation -> {
-                return mockQuery(invocation, 0 /* posNetwork */, 1 /* posHostname */,
-                        3 /* posExecutor */, 5 /* posCallback */, -1 /* posType */);
-            }).when(mDnsResolver).query(any(), any(), anyInt(), any(), any(), any());
-
-            // Queries on mCleartextDnsNetwork using DnsResolver#query with QueryType.
-            doAnswer(invocation -> {
-                return mockQuery(invocation, 0 /* posNetwork */, 1 /* posHostname */,
-                        4 /* posExecutor */, 6 /* posCallback */, 2 /* posType */);
-            }).when(mDnsResolver).query(any(), any(), anyInt(), anyInt(), any(), any(), any());
-        }
-
-        private List<InetAddress> stringsToInetAddresses(List<String> addrs) {
-            if (addrs == null) return null;
-            final List<InetAddress> out = new ArrayList<>();
-            for (String addr : addrs) {
-                out.add(parseNumericAddress(addr));
-            }
-            return out;
-        }
-
-        // Mocking queries on DnsResolver#query.
-        private Answer mockQuery(InvocationOnMock invocation, int posNetwork, int posHostname,
-                int posExecutor, int posCallback, int posType) {
-            String hostname = (String) invocation.getArgument(posHostname);
-            Executor executor = (Executor) invocation.getArgument(posExecutor);
-            Network network = invocation.getArgument(posNetwork);
-            DnsResolver.Callback callback = invocation.getArgument(posCallback);
-
-            final CompletableFuture<List<String>> answerFuture = (posType != -1)
-                    ? getAnswer(network, hostname, invocation.getArgument(posType))
-                    : queryAllTypes(network, hostname);
-
-            answerFuture.whenComplete((answer, exception) -> {
-                new Handler(Looper.getMainLooper()).post(() -> executor.execute(() -> {
-                    if (exception != null) {
-                        if (!(exception instanceof DnsResolver.DnsException)) {
-                            throw new AssertionError("Test error building DNS response", exception);
-                        }
-                        callback.onError((DnsResolver.DnsException) exception);
-                        return;
-                    }
-                    if (answer != null && answer.size() > 0) {
-                        final int qtype = (posType != -1)
-                                ? invocation.getArgument(posType) : TYPE_AAAA;
-                        switch (qtype) {
-                            // Assume A and AAAA queries use the List<InetAddress> callback.
-                            case TYPE_A:
-                            case TYPE_AAAA:
-                                callback.onAnswer(stringsToInetAddresses(answer), 0);
-                                break;
-                            default:
-                                throw new UnsupportedOperationException(
-                                        "Unsupported qtype: " + qtype + ", update this fake");
-                        }
-                    }
-                }));
-            });
-            // If the future does not complete or has no answer do nothing. The timeout should fire.
-            return null;
-        }
-    }
-
     private FakeDns mFakeDns;
 
     @GuardedBy("mThreadsToBeCleared")
@@ -694,7 +468,7 @@
         initHttpConnection(mFallbackConnection);
         initHttpConnection(mOtherFallbackConnection);
 
-        mFakeDns = new FakeDns();
+        mFakeDns = new FakeDns(mNetwork, mDnsResolver);
         mFakeDns.startMocking();
         // Set private dns suffix answer. sendPrivateDnsProbe() in NetworkMonitor send probe with
         // one time hostname. The hostname will be [random generated UUID] + HOST_SUFFIX differently