Merge "Migrate to netd_aidl_interface-V14" into main
diff --git a/Tethering/Android.bp b/Tethering/Android.bp
index dd60be7..414e50a 100644
--- a/Tethering/Android.bp
+++ b/Tethering/Android.bp
@@ -81,7 +81,10 @@
         "framework-tethering.impl",
     ],
     manifest: "AndroidManifestBase.xml",
-    lint: { strict_updatability_linting: true },
+    lint: {
+        strict_updatability_linting: true,
+        error_checks: ["NewApi"],
+    },
 }
 
 // build tethering static library, used to compile both variants of the tethering.
@@ -215,7 +218,10 @@
     use_embedded_native_libs: true,
     privapp_allowlist: ":privapp_allowlist_com.android.tethering",
     apex_available: ["com.android.tethering"],
-    lint: { strict_updatability_linting: true },
+    lint: {
+        strict_updatability_linting: true,
+        error_checks: ["NewApi"],
+    },
 }
 
 sdk {
diff --git a/Tethering/src/android/net/ip/IpServer.java b/Tethering/src/android/net/ip/IpServer.java
index 79d9a23..c065cd6 100644
--- a/Tethering/src/android/net/ip/IpServer.java
+++ b/Tethering/src/android/net/ip/IpServer.java
@@ -838,7 +838,7 @@
 
     private void addInterfaceToNetwork(final int netId, @NonNull final String ifaceName) {
         try {
-            if (null != mRoutingCoordinator.value) {
+            if (SdkLevel.isAtLeastS() && null != mRoutingCoordinator.value) {
                 // TODO : remove this call in favor of using the LocalNetworkConfiguration
                 // correctly, which will let ConnectivityService do it automatically.
                 mRoutingCoordinator.value.addInterfaceToNetwork(netId, ifaceName);
@@ -852,7 +852,7 @@
 
     private void addInterfaceForward(@NonNull final String fromIface,
             @NonNull final String toIface) throws ServiceSpecificException, RemoteException {
-        if (null != mRoutingCoordinator.value) {
+        if (SdkLevel.isAtLeastS() && null != mRoutingCoordinator.value) {
             mRoutingCoordinator.value.addInterfaceForward(fromIface, toIface);
         } else {
             mNetd.tetherAddForward(fromIface, toIface);
@@ -862,7 +862,7 @@
 
     private void removeInterfaceForward(@NonNull final String fromIface,
             @NonNull final String toIface) {
-        if (null != mRoutingCoordinator.value) {
+        if (SdkLevel.isAtLeastS() && null != mRoutingCoordinator.value) {
             try {
                 mRoutingCoordinator.value.removeInterfaceForward(fromIface, toIface);
             } catch (ServiceSpecificException e) {
diff --git a/Tethering/src/com/android/networkstack/tethering/OffloadHalAidlImpl.java b/Tethering/src/com/android/networkstack/tethering/OffloadHalAidlImpl.java
index e7dc757..9ef0f45 100644
--- a/Tethering/src/com/android/networkstack/tethering/OffloadHalAidlImpl.java
+++ b/Tethering/src/com/android/networkstack/tethering/OffloadHalAidlImpl.java
@@ -19,18 +19,21 @@
 import static com.android.networkstack.tethering.OffloadHardwareInterface.OFFLOAD_HAL_VERSION_AIDL;
 
 import android.annotation.NonNull;
+import android.annotation.TargetApi;
 import android.hardware.tetheroffload.ForwardedStats;
 import android.hardware.tetheroffload.IOffload;
 import android.hardware.tetheroffload.ITetheringOffloadCallback;
 import android.hardware.tetheroffload.NatTimeoutUpdate;
 import android.hardware.tetheroffload.NetworkProtocol;
 import android.hardware.tetheroffload.OffloadCallbackEvent;
+import android.os.Build;
 import android.os.Handler;
 import android.os.NativeHandle;
 import android.os.ParcelFileDescriptor;
 import android.os.ServiceManager;
 import android.system.OsConstants;
 
+import com.android.internal.annotations.VisibleForTesting;
 import com.android.modules.utils.build.SdkLevel;
 import com.android.net.module.util.SharedLog;
 import com.android.networkstack.tethering.OffloadHardwareInterface.OffloadHalCallback;
@@ -40,6 +43,7 @@
 /**
  * The implementation of IOffloadHal which based on Stable AIDL interface
  */
+@TargetApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
 public class OffloadHalAidlImpl implements IOffloadHal {
     private static final String TAG = OffloadHalAidlImpl.class.getSimpleName();
     private static final String HAL_INSTANCE_NAME = IOffload.DESCRIPTOR + "/default";
@@ -52,6 +56,7 @@
 
     private TetheringOffloadCallback mTetheringOffloadCallback;
 
+    @VisibleForTesting
     public OffloadHalAidlImpl(int version, @NonNull IOffload offload, @NonNull Handler handler,
             @NonNull SharedLog log) {
         mOffloadVersion = version;
diff --git a/Tethering/src/com/android/networkstack/tethering/OffloadHalHidlImpl.java b/Tethering/src/com/android/networkstack/tethering/OffloadHalHidlImpl.java
index e0a9878..71922f9 100644
--- a/Tethering/src/com/android/networkstack/tethering/OffloadHalHidlImpl.java
+++ b/Tethering/src/com/android/networkstack/tethering/OffloadHalHidlImpl.java
@@ -74,10 +74,7 @@
      */
     public boolean initOffload(@NonNull NativeHandle handle1, @NonNull NativeHandle handle2,
             @NonNull OffloadHalCallback callback) {
-        final String logmsg = String.format("initOffload(%d, %d, %s)",
-                handle1.getFileDescriptor().getInt$(), handle2.getFileDescriptor().getInt$(),
-                (callback == null) ? "null"
-                : "0x" + Integer.toHexString(System.identityHashCode(callback)));
+        final String logmsg = "initOffload()";
 
         mOffloadHalCallback = callback;
         mTetheringOffloadCallback = new TetheringOffloadCallback(
diff --git a/framework-t/api/system-current.txt b/framework-t/api/system-current.txt
index b285d85..05cf9e8 100644
--- a/framework-t/api/system-current.txt
+++ b/framework-t/api/system-current.txt
@@ -500,8 +500,8 @@
     method @RequiresPermission(allOf={android.Manifest.permission.ACCESS_NETWORK_STATE, "android.permission.THREAD_NETWORK_PRIVILEGED"}) public void registerOperationalDatasetCallback(@NonNull java.util.concurrent.Executor, @NonNull android.net.thread.ThreadNetworkController.OperationalDatasetCallback);
     method @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) public void registerStateCallback(@NonNull java.util.concurrent.Executor, @NonNull android.net.thread.ThreadNetworkController.StateCallback);
     method @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED") public void scheduleMigration(@NonNull android.net.thread.PendingOperationalDataset, @NonNull java.util.concurrent.Executor, @NonNull android.os.OutcomeReceiver<java.lang.Void,android.net.thread.ThreadNetworkException>);
-    method public void unregisterOperationalDatasetCallback(@NonNull android.net.thread.ThreadNetworkController.OperationalDatasetCallback);
-    method public void unregisterStateCallback(@NonNull android.net.thread.ThreadNetworkController.StateCallback);
+    method @RequiresPermission(allOf={android.Manifest.permission.ACCESS_NETWORK_STATE, "android.permission.THREAD_NETWORK_PRIVILEGED"}) public void unregisterOperationalDatasetCallback(@NonNull android.net.thread.ThreadNetworkController.OperationalDatasetCallback);
+    method @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) public void unregisterStateCallback(@NonNull android.net.thread.ThreadNetworkController.StateCallback);
     field public static final int DEVICE_ROLE_CHILD = 2; // 0x2
     field public static final int DEVICE_ROLE_DETACHED = 1; // 0x1
     field public static final int DEVICE_ROLE_LEADER = 4; // 0x4
diff --git a/framework/src/android/net/QosSession.java b/framework/src/android/net/QosSession.java
index 25f3965..d1edae9 100644
--- a/framework/src/android/net/QosSession.java
+++ b/framework/src/android/net/QosSession.java
@@ -22,6 +22,9 @@
 import android.os.Parcel;
 import android.os.Parcelable;
 
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
 /**
  * Provides identifying information of a QoS session.  Sent to an application through
  * {@link QosCallback}.
@@ -107,6 +110,7 @@
             TYPE_EPS_BEARER,
             TYPE_NR_BEARER,
     })
+    @Retention(RetentionPolicy.SOURCE)
     @interface QosSessionType {}
 
     private QosSession(final Parcel in) {
diff --git a/nearby/framework/java/android/nearby/BroadcastRequest.java b/nearby/framework/java/android/nearby/BroadcastRequest.java
index 90f4d0f..6d6357d 100644
--- a/nearby/framework/java/android/nearby/BroadcastRequest.java
+++ b/nearby/framework/java/android/nearby/BroadcastRequest.java
@@ -88,6 +88,7 @@
      * @hide
      */
     @IntDef({MEDIUM_BLE})
+    @Retention(RetentionPolicy.SOURCE)
     public @interface Medium {}
 
     /**
diff --git a/nearby/framework/java/android/nearby/NearbyDevice.java b/nearby/framework/java/android/nearby/NearbyDevice.java
index e8fcc28..e7db0c5 100644
--- a/nearby/framework/java/android/nearby/NearbyDevice.java
+++ b/nearby/framework/java/android/nearby/NearbyDevice.java
@@ -25,6 +25,8 @@
 
 import com.android.internal.util.Preconditions;
 
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
 import java.util.List;
 import java.util.Objects;
 import java.util.Set;
@@ -149,6 +151,7 @@
      * @hide
      */
     @IntDef({Medium.BLE, Medium.BLUETOOTH})
+    @Retention(RetentionPolicy.SOURCE)
     public @interface Medium {
         int BLE = 1;
         int BLUETOOTH = 2;
diff --git a/nearby/framework/java/android/nearby/NearbyManager.java b/nearby/framework/java/android/nearby/NearbyManager.java
index a70b303..070a2b6 100644
--- a/nearby/framework/java/android/nearby/NearbyManager.java
+++ b/nearby/framework/java/android/nearby/NearbyManager.java
@@ -34,6 +34,8 @@
 import com.android.internal.annotations.GuardedBy;
 import com.android.internal.util.Preconditions;
 
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
 import java.lang.ref.WeakReference;
 import java.util.Objects;
 import java.util.WeakHashMap;
@@ -63,6 +65,7 @@
             ScanStatus.SUCCESS,
             ScanStatus.ERROR,
     })
+    @Retention(RetentionPolicy.SOURCE)
     public @interface ScanStatus {
         // The undetermined status, some modules may be initializing. Retry is suggested.
         int UNKNOWN = 0;
diff --git a/service-t/src/com/android/server/NsdService.java b/service-t/src/com/android/server/NsdService.java
index 43357e4..ee5f25b 100644
--- a/service-t/src/com/android/server/NsdService.java
+++ b/service-t/src/com/android/server/NsdService.java
@@ -1645,8 +1645,8 @@
                         mContext, MdnsFeatureFlags.NSD_FORCE_DISABLE_MDNS_OFFLOAD))
                 .setIncludeInetAddressRecordsInProbing(mDeps.isFeatureEnabled(
                         mContext, MdnsFeatureFlags.INCLUDE_INET_ADDRESS_RECORDS_IN_PROBING))
-                .setIsExpiredServicesRemovalEnabled(mDeps.isTrunkStableFeatureEnabled(
-                        MdnsFeatureFlags.NSD_EXPIRED_SERVICES_REMOVAL))
+                .setIsExpiredServicesRemovalEnabled(mDeps.isFeatureEnabled(
+                        mContext, MdnsFeatureFlags.NSD_EXPIRED_SERVICES_REMOVAL))
                 .setIsLabelCountLimitEnabled(mDeps.isTetheringFeatureNotChickenedOut(
                         mContext, MdnsFeatureFlags.NSD_LIMIT_LABEL_COUNT))
                 .build();
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java b/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java
index 738c151..0a6d8c1 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java
@@ -86,7 +86,7 @@
         public Builder() {
             mIsMdnsOffloadFeatureEnabled = false;
             mIncludeInetAddressRecordsInProbing = false;
-            mIsExpiredServicesRemovalEnabled = true; // Default enabled.
+            mIsExpiredServicesRemovalEnabled = false;
             mIsLabelCountLimitEnabled = true; // Default enabled.
         }
 
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsResponse.java b/service-t/src/com/android/server/connectivity/mdns/MdnsResponse.java
index e2288c1..05ad1be 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsResponse.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsResponse.java
@@ -33,6 +33,7 @@
 
 /** An mDNS response. */
 public class MdnsResponse {
+    public static final long EXPIRATION_NEVER = Long.MAX_VALUE;
     private final List<MdnsRecord> records;
     private final List<MdnsPointerRecord> pointerRecords;
     private MdnsServiceRecord serviceRecord;
@@ -349,6 +350,21 @@
         return serviceName;
     }
 
+    /** Get the min remaining ttl time from received records */
+    public long getMinRemainingTtl(long now) {
+        long minRemainingTtl = EXPIRATION_NEVER;
+        // TODO: Check other records(A, AAAA, TXT) ttl time.
+        if (!hasServiceRecord()) {
+            return EXPIRATION_NEVER;
+        }
+        // Check ttl time.
+        long remainingTtl = serviceRecord.getRemainingTTL(now);
+        if (remainingTtl < minRemainingTtl) {
+            minRemainingTtl = remainingTtl;
+        }
+        return minRemainingTtl;
+    }
+
     /**
      * Tests if this response is a goodbye message. This will be true if a service record is present
      * and any of the records have a TTL of 0.
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceCache.java b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceCache.java
index d3493c7..e9a41d1 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceCache.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceCache.java
@@ -16,16 +16,22 @@
 
 package com.android.server.connectivity.mdns;
 
+import static com.android.server.connectivity.mdns.MdnsResponse.EXPIRATION_NEVER;
 import static com.android.server.connectivity.mdns.util.MdnsUtils.ensureRunningOnHandlerThread;
 import static com.android.server.connectivity.mdns.util.MdnsUtils.equalsIgnoreDnsCase;
 import static com.android.server.connectivity.mdns.util.MdnsUtils.toDnsLowerCase;
 
+import static java.lang.Math.min;
+
 import android.annotation.NonNull;
 import android.annotation.Nullable;
 import android.os.Handler;
 import android.os.Looper;
 import android.util.ArrayMap;
 
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.server.connectivity.mdns.util.MdnsUtils;
+
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Iterator;
@@ -67,8 +73,11 @@
         }
     }
     /**
-     * A map of cached services. Key is composed of service name, type and socket. Value is the
-     * service which use the service type to discover from each socket.
+     * A map of cached services. Key is composed of service type and socket. Value is the list of
+     * services which are discovered from the given CacheKey.
+     * When the MdnsFeatureFlags#NSD_EXPIRED_SERVICES_REMOVAL flag is enabled, the lists are sorted
+     * by expiration time, with the earliest entries appearing first. This sorting allows the
+     * removal process to progress through the expiration check efficiently.
      */
     @NonNull
     private final ArrayMap<CacheKey, List<MdnsResponse>> mCachedServices = new ArrayMap<>();
@@ -82,10 +91,20 @@
     private final Handler mHandler;
     @NonNull
     private final MdnsFeatureFlags mMdnsFeatureFlags;
+    @NonNull
+    private final MdnsUtils.Clock mClock;
+    private long mNextExpirationTime = EXPIRATION_NEVER;
 
     public MdnsServiceCache(@NonNull Looper looper, @NonNull MdnsFeatureFlags mdnsFeatureFlags) {
+        this(looper, mdnsFeatureFlags, new MdnsUtils.Clock());
+    }
+
+    @VisibleForTesting
+    MdnsServiceCache(@NonNull Looper looper, @NonNull MdnsFeatureFlags mdnsFeatureFlags,
+            @NonNull MdnsUtils.Clock clock) {
         mHandler = new Handler(looper);
         mMdnsFeatureFlags = mdnsFeatureFlags;
+        mClock = clock;
     }
 
     /**
@@ -97,6 +116,9 @@
     @NonNull
     public List<MdnsResponse> getCachedServices(@NonNull CacheKey cacheKey) {
         ensureRunningOnHandlerThread(mHandler);
+        if (mMdnsFeatureFlags.mIsExpiredServicesRemovalEnabled) {
+            maybeRemoveExpiredServices(cacheKey, mClock.elapsedRealtime());
+        }
         return mCachedServices.containsKey(cacheKey)
                 ? Collections.unmodifiableList(new ArrayList<>(mCachedServices.get(cacheKey)))
                 : Collections.emptyList();
@@ -129,6 +151,9 @@
     @Nullable
     public MdnsResponse getCachedService(@NonNull String serviceName, @NonNull CacheKey cacheKey) {
         ensureRunningOnHandlerThread(mHandler);
+        if (mMdnsFeatureFlags.mIsExpiredServicesRemovalEnabled) {
+            maybeRemoveExpiredServices(cacheKey, mClock.elapsedRealtime());
+        }
         final List<MdnsResponse> responses = mCachedServices.get(cacheKey);
         if (responses == null) {
             return null;
@@ -137,6 +162,16 @@
         return response != null ? new MdnsResponse(response) : null;
     }
 
+    static void insertResponseAndSortList(
+            List<MdnsResponse> responses, MdnsResponse response, long now) {
+        // binarySearch returns "the index of the search key, if it is contained in the list;
+        // otherwise, (-(insertion point) - 1)"
+        final int searchRes = Collections.binarySearch(responses, response,
+                // Sort the list by ttl.
+                (o1, o2) -> Long.compare(o1.getMinRemainingTtl(now), o2.getMinRemainingTtl(now)));
+        responses.add(searchRes >= 0 ? searchRes : (-searchRes - 1), response);
+    }
+
     /**
      * Add or update a service.
      *
@@ -151,7 +186,15 @@
         final MdnsResponse existing =
                 findMatchedResponse(responses, response.getServiceInstanceName());
         responses.remove(existing);
-        responses.add(response);
+        if (mMdnsFeatureFlags.mIsExpiredServicesRemovalEnabled) {
+            final long now = mClock.elapsedRealtime();
+            // Insert and sort service
+            insertResponseAndSortList(responses, response, now);
+            // Update the next expiration check time when a new service is added.
+            mNextExpirationTime = getNextExpirationTime(now);
+        } else {
+            responses.add(response);
+        }
     }
 
     /**
@@ -168,14 +211,25 @@
             return null;
         }
         final Iterator<MdnsResponse> iterator = responses.iterator();
+        MdnsResponse removedResponse = null;
         while (iterator.hasNext()) {
             final MdnsResponse response = iterator.next();
             if (equalsIgnoreDnsCase(serviceName, response.getServiceInstanceName())) {
                 iterator.remove();
-                return response;
+                removedResponse = response;
+                break;
             }
         }
-        return null;
+
+        if (mMdnsFeatureFlags.mIsExpiredServicesRemovalEnabled) {
+            // Remove the serviceType if no response.
+            if (responses.isEmpty()) {
+                mCachedServices.remove(cacheKey);
+            }
+            // Update the next expiration check time when a service is removed.
+            mNextExpirationTime = getNextExpirationTime(mClock.elapsedRealtime());
+        }
+        return removedResponse;
     }
 
     /**
@@ -203,6 +257,87 @@
         mCallbacks.remove(cacheKey);
     }
 
+    private void notifyServiceExpired(@NonNull CacheKey cacheKey,
+            @NonNull MdnsResponse previousResponse, @Nullable MdnsResponse newResponse) {
+        final ServiceExpiredCallback callback = mCallbacks.get(cacheKey);
+        if (callback == null) {
+            // The cached service is no listener.
+            return;
+        }
+        mHandler.post(()-> callback.onServiceRecordExpired(previousResponse, newResponse));
+    }
+
+    static List<MdnsResponse> removeExpiredServices(@NonNull List<MdnsResponse> responses,
+            long now) {
+        final List<MdnsResponse> removedResponses = new ArrayList<>();
+        final Iterator<MdnsResponse> iterator = responses.iterator();
+        while (iterator.hasNext()) {
+            final MdnsResponse response = iterator.next();
+            // TODO: Check other records (A, AAAA, TXT) ttl time and remove the record if it's
+            //  expired. Then send service update notification.
+            if (!response.hasServiceRecord() || response.getMinRemainingTtl(now) > 0) {
+                // The responses are sorted by the service record ttl time. Break out of loop
+                // early if service is not expired or no service record.
+                break;
+            }
+            // Remove the ttl expired service.
+            iterator.remove();
+            removedResponses.add(response);
+        }
+        return removedResponses;
+    }
+
+    private long getNextExpirationTime(long now) {
+        if (mCachedServices.isEmpty()) {
+            return EXPIRATION_NEVER;
+        }
+
+        long minRemainingTtl = EXPIRATION_NEVER;
+        for (int i = 0; i < mCachedServices.size(); i++) {
+            minRemainingTtl = min(minRemainingTtl,
+                    // The empty lists are not kept in the map, so there's always at least one
+                    // element in the list. Therefore, it's fine to get the first element without a
+                    // null check.
+                    mCachedServices.valueAt(i).get(0).getMinRemainingTtl(now));
+        }
+        return minRemainingTtl == EXPIRATION_NEVER ? EXPIRATION_NEVER : now + minRemainingTtl;
+    }
+
+    /**
+     * Check whether the ttl time is expired on each service and notify to the listeners
+     */
+    private void maybeRemoveExpiredServices(CacheKey cacheKey, long now) {
+        ensureRunningOnHandlerThread(mHandler);
+        if (now < mNextExpirationTime) {
+            // Skip the check if ttl time is not expired.
+            return;
+        }
+
+        final List<MdnsResponse> responses = mCachedServices.get(cacheKey);
+        if (responses == null) {
+            // No such services.
+            return;
+        }
+
+        final List<MdnsResponse> removedResponses = removeExpiredServices(responses, now);
+        if (removedResponses.isEmpty()) {
+            // No expired services.
+            return;
+        }
+
+        for (MdnsResponse previousResponse : removedResponses) {
+            notifyServiceExpired(cacheKey, previousResponse, null /* newResponse */);
+        }
+
+        // Remove the serviceType if no response.
+        if (responses.isEmpty()) {
+            mCachedServices.remove(cacheKey);
+        }
+
+        // Update next expiration time.
+        mNextExpirationTime = getNextExpirationTime(now);
+    }
+
     /*** Callbacks for listening service expiration */
     public interface ServiceExpiredCallback {
         /*** Notify the service is expired */
@@ -210,5 +345,5 @@
                 @Nullable MdnsResponse newResponse);
     }
 
-    // TODO: check ttl expiration for each service and notify to the clients.
+    // TODO: Schedule a job to check ttl expiration for all services and notify to the clients.
 }
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
index 0a03186..32f604e 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
@@ -312,8 +312,7 @@
         this.searchOptions = searchOptions;
         boolean hadReply = false;
         if (listeners.put(listener, searchOptions) == null) {
-            for (MdnsResponse existingResponse :
-                    serviceCache.getCachedServices(cacheKey)) {
+            for (MdnsResponse existingResponse : serviceCache.getCachedServices(cacheKey)) {
                 if (!responseMatchesOptions(existingResponse, searchOptions)) continue;
                 final MdnsServiceInfo info =
                         buildMdnsServiceInfoFromResponse(existingResponse, serviceTypeLabels);
diff --git a/service/src/com/android/server/ConnectivityService.java b/service/src/com/android/server/ConnectivityService.java
index 7d1644e..f904cd1 100755
--- a/service/src/com/android/server/ConnectivityService.java
+++ b/service/src/com/android/server/ConnectivityService.java
@@ -969,6 +969,9 @@
     // Flag to optimize closing frozen app sockets by waiting for the cellular modem to wake up.
     private final boolean mDelayDestroyFrozenSockets;
 
+    // Flag to allow SysUI to receive connectivity reports for wifi picker UI.
+    private final boolean mAllowSysUiConnectivityReports;
+
     // Uids that ConnectivityService is pending to close sockets of.
     private final Set<Integer> mPendingFrozenUids = new ArraySet<>();
 
@@ -1469,6 +1472,13 @@
         }
 
         /**
+         * @see DeviceConfigUtils#isTetheringFeatureNotChickenedOut
+         */
+        public boolean isFeatureNotChickenedOut(Context context, String name) {
+            return DeviceConfigUtils.isTetheringFeatureNotChickenedOut(context, name);
+        }
+
+        /**
          * Get the BpfNetMaps implementation to use in ConnectivityService.
          * @param netd a netd binder
          * @return BpfNetMaps implementation.
@@ -1760,7 +1770,12 @@
         mUserAllContext.registerReceiver(mPackageIntentReceiver, packageIntentFilter,
                 null /* broadcastPermission */, mHandler);
 
-        mNetworkActivityTracker = new LegacyNetworkActivityTracker(mContext, mNetd, mHandler);
+        // TrackMultiNetworkActivities feature should be enabled by trunk stable flag.
+        // But reading the trunk stable flags from mainline modules is not supported yet.
+        // So enabling this feature on V+ release.
+        mTrackMultiNetworkActivities = mDeps.isAtLeastV();
+        mNetworkActivityTracker = new LegacyNetworkActivityTracker(mContext, mNetd, mHandler,
+                mTrackMultiNetworkActivities);
 
         final NetdCallback netdCallback = new NetdCallback();
         try {
@@ -1835,6 +1850,8 @@
                 && mDeps.isFeatureEnabled(context, KEY_DESTROY_FROZEN_SOCKETS_VERSION);
         mDelayDestroyFrozenSockets = mDeps.isAtLeastU()
                 && mDeps.isFeatureEnabled(context, DELAY_DESTROY_FROZEN_SOCKETS_VERSION);
+        mAllowSysUiConnectivityReports = mDeps.isFeatureNotChickenedOut(
+                mContext, ALLOW_SYSUI_CONNECTIVITY_REPORTS);
         if (mDestroyFrozenSockets) {
             final UidFrozenStateChangedCallback frozenStateChangedCallback =
                     new UidFrozenStateChangedCallback() {
@@ -3234,9 +3251,20 @@
     private void handleReportNetworkActivity(final NetworkActivityParams params) {
         mNetworkActivityTracker.handleReportNetworkActivity(params);
 
+        final boolean isCellNetworkActivity;
+        if (mTrackMultiNetworkActivities) {
+            final NetworkAgentInfo nai = getNetworkAgentInfoForNetId(params.label);
+            // nai could be null if netd receives a netlink message and calls the network
+            // activity change callback after the network is unregistered from ConnectivityService.
+            isCellNetworkActivity = nai != null
+                    && nai.networkCapabilities.hasTransport(TRANSPORT_CELLULAR);
+        } else {
+            isCellNetworkActivity = params.label == TRANSPORT_CELLULAR;
+        }
+
         if (mDelayDestroyFrozenSockets
                 && params.isActive
-                && params.label == TRANSPORT_CELLULAR
+                && isCellNetworkActivity
                 && !mPendingFrozenUids.isEmpty()) {
             closePendingFrozenSockets();
         }
@@ -3297,6 +3325,10 @@
     static final String DELAY_DESTROY_FROZEN_SOCKETS_VERSION =
             "delay_destroy_frozen_sockets_version";
 
+    @VisibleForTesting
+    public static final String ALLOW_SYSUI_CONNECTIVITY_REPORTS =
+            "allow_sysui_connectivity_reports";
+
     private void enforceInternetPermission() {
         mContext.enforceCallingOrSelfPermission(
                 android.Manifest.permission.INTERNET,
@@ -3460,6 +3492,11 @@
                 NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK);
     }
 
+    private boolean checkSystemBarServicePermission(int pid, int uid) {
+        return checkAnyPermissionOf(mContext, pid, uid,
+                android.Manifest.permission.STATUS_BAR_SERVICE);
+    }
+
     private boolean checkNetworkSignalStrengthWakeupPermission(int pid, int uid) {
         return checkAnyPermissionOf(mContext, pid, uid,
                 android.Manifest.permission.NETWORK_SIGNAL_STRENGTH_WAKEUP,
@@ -4944,6 +4981,11 @@
         if (wasDefault) {
             mDefaultInetConditionPublished = 0;
         }
+        if (mTrackMultiNetworkActivities) {
+            // If trackMultiNetworkActivities is disabled, ActivityTracker removes idleTimer when
+            // the network becomes no longer the default network.
+            mNetworkActivityTracker.removeDataActivityTracking(nai);
+        }
         notifyIfacesChangedForNetworkStats();
         // If this was a local network forwarded to some upstream, or if some local network was
         // forwarded to this nai, then disable forwarding rules now.
@@ -4997,12 +5039,7 @@
                 }
 
                 if (mDefaultRequest == nri) {
-                    // TODO : make battery stats aware that since 2013 multiple interfaces may be
-                    //  active at the same time. For now keep calling this with the default
-                    //  network, because while incorrect this is the closest to the old (also
-                    //  incorrect) behavior.
-                    mNetworkActivityTracker.updateDataActivityTracking(
-                            null /* newNetwork */, nai);
+                    mNetworkActivityTracker.updateDefaultNetwork(null /* newNetwork */, nai);
                     maybeClosePendingFrozenSockets(null /* newNetwork */, nai);
                     ensureNetworkTransitionWakelock(nai.toShortString());
                 }
@@ -9623,7 +9660,7 @@
         if (oldDefaultNetwork != null) {
             mLingerMonitor.noteLingerDefaultNetwork(oldDefaultNetwork, newDefaultNetwork);
         }
-        mNetworkActivityTracker.updateDataActivityTracking(newDefaultNetwork, oldDefaultNetwork);
+        mNetworkActivityTracker.updateDefaultNetwork(newDefaultNetwork, oldDefaultNetwork);
         maybeClosePendingFrozenSockets(newDefaultNetwork, oldDefaultNetwork);
         mProxyTracker.setDefaultProxy(null != newDefaultNetwork
                 ? newDefaultNetwork.linkProperties.getHttpProxy() : null);
@@ -10523,6 +10560,15 @@
                     SystemClock.elapsedRealtime(), mNascentDelayMs);
             networkAgent.setInactive();
 
+            if (mTrackMultiNetworkActivities) {
+                // Start tracking activity of this network.
+                // This must be called before rematchAllNetworksAndRequests since the network
+                // should be tracked when the network becomes the default network.
+                // This method does not trigger any callbacks or broadcasts. Callbacks or broadcasts
+                // can be triggered later if this network becomes the default network.
+                mNetworkActivityTracker.setupDataActivityTracking(networkAgent);
+            }
+
             // Consider network even though it is not yet validated.
             rematchAllNetworksAndRequests();
 
@@ -11581,6 +11627,10 @@
         if (checkNetworkStackPermission(callbackPid, callbackUid)) {
             return true;
         }
+        if (mAllowSysUiConnectivityReports
+                && checkSystemBarServicePermission(callbackPid, callbackUid)) {
+            return true;
+        }
 
         // Administrator UIDs also contains the Owner UID
         final int[] administratorUids = nai.networkCapabilities.getAdministratorUids();
@@ -11710,8 +11760,8 @@
      */
     private static final class NetworkActivityParams {
         public final boolean isActive;
-        // Label used for idle timer. Transport type is used as label.
-        // label is int since NMS was using the identifier as int, and it has not been changed
+        // If TrackMultiNetworkActivities is enabled, idleTimer label is netid.
+        // If TrackMultiNetworkActivities is disabled, idleTimer label is transport type.
         public final int label;
         public final long timestampNs;
         // Uid represents the uid that was responsible for waking the radio.
@@ -11753,13 +11803,15 @@
         }
     }
 
+    private final boolean mTrackMultiNetworkActivities;
     private final LegacyNetworkActivityTracker mNetworkActivityTracker;
 
     /**
      * Class used for updating network activity tracking with netd and notify network activity
      * changes.
      */
-    private static final class LegacyNetworkActivityTracker {
+    @VisibleForTesting
+    public static final class LegacyNetworkActivityTracker {
         private static final int NO_UID = -1;
         private final Context mContext;
         private final INetd mNetd;
@@ -11771,8 +11823,14 @@
         // If there is no default network, default network is considered active to keep the existing
         // behavior. Initial value is used until first connect to the default network.
         private volatile boolean mIsDefaultNetworkActive = true;
+        private Network mDefaultNetwork;
         // Key is netId. Value is configured idle timer information.
         private final SparseArray<IdleTimerParams> mActiveIdleTimers = new SparseArray<>();
+        private final boolean mTrackMultiNetworkActivities;
+        // Store netIds of Wi-Fi networks whose idletimers report that they are active
+        private final Set<Integer> mActiveWifiNetworks = new ArraySet<>();
+        // Store netIds of cellular networks whose idletimers report that they are active
+        private final Set<Integer> mActiveCellularNetworks = new ArraySet<>();
 
         private static class IdleTimerParams {
             public final int timeout;
@@ -11785,10 +11843,11 @@
         }
 
         LegacyNetworkActivityTracker(@NonNull Context context, @NonNull INetd netd,
-                @NonNull Handler handler) {
+                @NonNull Handler handler, boolean trackMultiNetworkActivities) {
             mContext = context;
             mNetd = netd;
             mHandler = handler;
+            mTrackMultiNetworkActivities = trackMultiNetworkActivities;
         }
 
         private void ensureRunningOnConnectivityServiceThread() {
@@ -11798,19 +11857,97 @@
             }
         }
 
-        public void handleReportNetworkActivity(NetworkActivityParams activityParams) {
-            ensureRunningOnConnectivityServiceThread();
+        /**
+         * Update network activity and call BatteryStats to update radio power state if the
+         * mobile or Wi-Fi activity is changed.
+         * LegacyNetworkActivityTracker considers the mobile network is active if at least one
+         * mobile network is active since BatteryStatsService only maintains a single power state
+         * for the mobile network.
+         * The Wi-Fi network is also the same.
+         *
+         * {@link #setupDataActivityTracking} and {@link #removeDataActivityTracking} use
+         * TRANSPORT_CELLULAR as the transportType argument if the network has both cell and Wi-Fi
+         * transports.
+         */
+        private void maybeUpdateRadioPowerState(final int netId, final int transportType,
+                final boolean isActive, final int uid) {
+            if (transportType != TRANSPORT_WIFI && transportType != TRANSPORT_CELLULAR) {
+                Log.e(TAG, "Unexpected transportType in maybeUpdateRadioPowerState: "
+                        + transportType);
+                return;
+            }
+            final Set<Integer> activeNetworks = transportType == TRANSPORT_WIFI
+                    ? mActiveWifiNetworks : mActiveCellularNetworks;
+
+            final boolean wasEmpty = activeNetworks.isEmpty();
+            if (isActive) {
+                activeNetworks.add(netId);
+            } else {
+                activeNetworks.remove(netId);
+            }
+
+            if (wasEmpty != activeNetworks.isEmpty()) {
+                updateRadioPowerState(isActive, transportType, uid);
+            }
+        }
+
+        private void handleDefaultNetworkActivity(final int transportType,
+                final boolean isActive, final long timestampNs) {
+            mIsDefaultNetworkActive = isActive;
+            sendDataActivityBroadcast(transportTypeToLegacyType(transportType),
+                    isActive, timestampNs);
+            if (isActive) {
+                reportNetworkActive();
+            }
+        }
+
+        private void handleReportNetworkActivityWithNetIdLabel(
+                NetworkActivityParams activityParams) {
+            final int netId = activityParams.label;
+            final IdleTimerParams idleTimerParams = mActiveIdleTimers.get(netId);
+            if (idleTimerParams == null) {
+                // This network activity change is not tracked anymore
+                // This can happen if netd callback post activity change event message but idle
+                // timer is removed before processing this message.
+                return;
+            }
+            // TODO: if a network changes transports, storing the transport type in the
+            // IdleTimerParams is not correct. Consider getting it from the network's
+            // NetworkCapabilities instead.
+            final int transportType = idleTimerParams.transportType;
+            maybeUpdateRadioPowerState(netId, transportType,
+                    activityParams.isActive, activityParams.uid);
+
+            if (mDefaultNetwork == null || mDefaultNetwork.netId != netId) {
+                // This activity change is not for the default network.
+                return;
+            }
+
+            handleDefaultNetworkActivity(transportType, activityParams.isActive,
+                    activityParams.timestampNs);
+        }
+
+        private void handleReportNetworkActivityWithTransportTypeLabel(
+                NetworkActivityParams activityParams) {
             if (mActiveIdleTimers.size() == 0) {
                 // This activity change is not for the current default network.
                 // This can happen if netd callback post activity change event message but
                 // the default network is lost before processing this message.
                 return;
             }
-            sendDataActivityBroadcast(transportTypeToLegacyType(activityParams.label),
-                    activityParams.isActive, activityParams.timestampNs);
-            mIsDefaultNetworkActive = activityParams.isActive;
-            if (mIsDefaultNetworkActive) {
-                reportNetworkActive();
+            handleDefaultNetworkActivity(activityParams.label, activityParams.isActive,
+                    activityParams.timestampNs);
+        }
+
+        /**
+         * Handle network activity change
+         */
+        public void handleReportNetworkActivity(NetworkActivityParams activityParams) {
+            ensureRunningOnConnectivityServiceThread();
+            if (mTrackMultiNetworkActivities) {
+                handleReportNetworkActivityWithNetIdLabel(activityParams);
+            } else {
+                handleReportNetworkActivityWithTransportTypeLabel(activityParams);
             }
         }
 
@@ -11867,6 +12004,30 @@
         }
 
         /**
+         * Get idle timer label
+         */
+        @VisibleForTesting
+        public static int getIdleTimerLabel(final boolean trackMultiNetworkActivities,
+                final int netId, final int transportType) {
+            return trackMultiNetworkActivities ? netId : transportType;
+        }
+
+        private boolean maybeCreateIdleTimer(
+                String iface, int netId, int timeout, int transportType) {
+            if (timeout <= 0 || iface == null) return false;
+            try {
+                final String label = Integer.toString(getIdleTimerLabel(
+                        mTrackMultiNetworkActivities, netId, transportType));
+                mNetd.idletimerAddInterface(iface, timeout, label);
+                mActiveIdleTimers.put(netId, new IdleTimerParams(timeout, transportType));
+                return true;
+            } catch (Exception e) {
+                loge("Exception in createIdleTimer", e);
+                return false;
+            }
+        }
+
+        /**
          * Setup data activity tracking for the given network.
          *
          * Every {@code setupDataActivityTracking} should be paired with a
@@ -11875,13 +12036,17 @@
          * @return true if the idleTimer is added to the network, false otherwise
          */
         private boolean setupDataActivityTracking(NetworkAgentInfo networkAgent) {
+            ensureRunningOnConnectivityServiceThread();
             final String iface = networkAgent.linkProperties.getInterfaceName();
             final int netId = networkAgent.network().netId;
 
             final int timeout;
             final int type;
 
-            if (networkAgent.networkCapabilities.hasTransport(
+            if (!networkAgent.networkCapabilities.hasCapability(NET_CAPABILITY_NOT_VPN)) {
+                // Do not track VPN network.
+                return false;
+            } else if (networkAgent.networkCapabilities.hasTransport(
                     NetworkCapabilities.TRANSPORT_CELLULAR)) {
                 timeout = Settings.Global.getInt(mContext.getContentResolver(),
                         ConnectivitySettingsManager.DATA_ACTIVITY_TIMEOUT_MOBILE,
@@ -11897,25 +12062,21 @@
                 return false; // do not track any other networks
             }
 
-            updateRadioPowerState(true /* isActive */, type);
-
-            if (timeout > 0 && iface != null) {
-                try {
-                    mActiveIdleTimers.put(netId, new IdleTimerParams(timeout, type));
-                    mNetd.idletimerAddInterface(iface, timeout, Integer.toString(type));
-                    return true;
-                } catch (Exception e) {
-                    // You shall not crash!
-                    loge("Exception in setupDataActivityTracking " + e);
-                }
+            final boolean hasIdleTimer = maybeCreateIdleTimer(iface, netId, timeout, type);
+            if (hasIdleTimer || !mTrackMultiNetworkActivities) {
+                // If trackMultiNetwork is disabled, NetworkActivityTracker updates radio power
+                // state in all cases. If trackMultiNetwork is enabled, it updates radio power
+                // state only about a network that has an idletimer.
+                maybeUpdateRadioPowerState(netId, type, true /* isActive */, NO_UID);
             }
-            return false;
+            return hasIdleTimer;
         }
 
         /**
          * Remove data activity tracking when network disconnects.
          */
-        private void removeDataActivityTracking(NetworkAgentInfo networkAgent) {
+        public void removeDataActivityTracking(NetworkAgentInfo networkAgent) {
+            ensureRunningOnConnectivityServiceThread();
             final String iface = networkAgent.linkProperties.getInterfaceName();
             final int netId = networkAgent.network().netId;
             final NetworkCapabilities caps = networkAgent.networkCapabilities;
@@ -11923,7 +12084,10 @@
             if (iface == null) return;
 
             final int type;
-            if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
+            if (!networkAgent.networkCapabilities.hasCapability(NET_CAPABILITY_NOT_VPN)) {
+                // Do not track VPN network.
+                return;
+            } else if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
                 type = NetworkCapabilities.TRANSPORT_CELLULAR;
             } else if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
                 type = NetworkCapabilities.TRANSPORT_WIFI;
@@ -11932,16 +12096,17 @@
             }
 
             try {
-                updateRadioPowerState(false /* isActive */, type);
+                maybeUpdateRadioPowerState(netId, type, false /* isActive */, NO_UID);
                 final IdleTimerParams params = mActiveIdleTimers.get(netId);
                 if (params == null) {
                     // IdleTimer is not added if the configured timeout is 0 or negative value
                     return;
                 }
                 mActiveIdleTimers.remove(netId);
-                // The call fails silently if no idle timer setup for this interface
-                mNetd.idletimerRemoveInterface(iface, params.timeout,
-                        Integer.toString(params.transportType));
+                final String label = Integer.toString(getIdleTimerLabel(
+                        mTrackMultiNetworkActivities, netId, params.transportType));
+                        // The call fails silently if no idle timer setup for this interface
+                mNetd.idletimerRemoveInterface(iface, params.timeout, label);
             } catch (Exception e) {
                 // You shall not crash!
                 loge("Exception in removeDataActivityTracking " + e);
@@ -11951,12 +12116,15 @@
         private void updateDefaultNetworkActivity(NetworkAgentInfo defaultNetwork,
                 boolean hasIdleTimer) {
             if (defaultNetwork != null) {
+                mDefaultNetwork = defaultNetwork.network();
                 mIsDefaultNetworkActive = true;
-                // Callbacks are called only when the network has the idle timer.
-                if (hasIdleTimer) {
+                // If only the default network is tracked, callbacks are called only when the
+                // network has the idle timer.
+                if (mTrackMultiNetworkActivities || hasIdleTimer) {
                     reportNetworkActive();
                 }
             } else {
+                mDefaultNetwork = null;
                 // If there is no default network, default network is considered active to keep the
                 // existing behavior.
                 mIsDefaultNetworkActive = true;
@@ -11964,29 +12132,34 @@
         }
 
         /**
-         * Update data activity tracking when network state is updated.
+         * Update the default network this class tracks the activity of.
          */
-        public void updateDataActivityTracking(NetworkAgentInfo newNetwork,
+        public void updateDefaultNetwork(NetworkAgentInfo newNetwork,
                 NetworkAgentInfo oldNetwork) {
             ensureRunningOnConnectivityServiceThread();
+            // If TrackMultiNetworkActivities is enabled, devices add idleTimer when the network is
+            // first connected and remove when the network is disconnected.
+            // If TrackMultiNetworkActivities is disabled, devices add idleTimer when the network
+            // becomes the default network and remove when the network becomes no longer the default
+            // network.
             boolean hasIdleTimer = false;
-            if (newNetwork != null) {
+            if (!mTrackMultiNetworkActivities && newNetwork != null) {
                 hasIdleTimer = setupDataActivityTracking(newNetwork);
             }
             updateDefaultNetworkActivity(newNetwork, hasIdleTimer);
-            if (oldNetwork != null) {
+            if (!mTrackMultiNetworkActivities && oldNetwork != null) {
                 removeDataActivityTracking(oldNetwork);
             }
         }
 
-        private void updateRadioPowerState(boolean isActive, int transportType) {
+        private void updateRadioPowerState(boolean isActive, int transportType, int uid) {
             final BatteryStatsManager bs = mContext.getSystemService(BatteryStatsManager.class);
             switch (transportType) {
                 case NetworkCapabilities.TRANSPORT_CELLULAR:
-                    bs.reportMobileRadioPowerState(isActive, NO_UID);
+                    bs.reportMobileRadioPowerState(isActive, uid);
                     break;
                 case NetworkCapabilities.TRANSPORT_WIFI:
-                    bs.reportWifiRadioPowerState(isActive, NO_UID);
+                    bs.reportWifiRadioPowerState(isActive, uid);
                     break;
                 default:
                     logw("Untracked transport type:" + transportType);
@@ -12006,7 +12179,9 @@
         }
 
         public void dump(IndentingPrintWriter pw) {
+            pw.print("mTrackMultiNetworkActivities="); pw.println(mTrackMultiNetworkActivities);
             pw.print("mIsDefaultNetworkActive="); pw.println(mIsDefaultNetworkActive);
+            pw.print("mDefaultNetwork="); pw.println(mDefaultNetwork);
             pw.println("Idle timers:");
             try {
                 for (int i = 0; i < mActiveIdleTimers.size(); i++) {
@@ -12015,11 +12190,13 @@
                     pw.print("    timeout="); pw.print(params.timeout);
                     pw.print(" type="); pw.println(params.transportType);
                 }
+                pw.println("WiFi active networks: " + mActiveWifiNetworks);
+                pw.println("Cellular active networks: " + mActiveCellularNetworks);
             } catch (Exception e) {
-                // mActiveIdleTimers should only be accessed from handler thread, except dump().
-                // As dump() is never called in normal usage, it would be needlessly expensive
-                // to lock the collection only for its benefit.
-                // Also, mActiveIdleTimers is not expected to be updated frequently.
+                // mActiveIdleTimers, mActiveWifiNetworks, and mActiveCellularNetworks should only
+                // be accessed from handler thread, except dump(). As dump() is never called in
+                // normal usage, it would be needlessly expensive to lock the collection only for
+                // its benefit. Also, they are not expected to be updated frequently.
                 // So catching the exception and logging.
                 pw.println("Failed to dump NetworkActivityTracker: " + e);
             }
diff --git a/staticlibs/framework/com/android/net/module/util/DnsSvcbPacket.java b/staticlibs/framework/com/android/net/module/util/DnsSvcbPacket.java
index c7ed3e6..d298599 100644
--- a/staticlibs/framework/com/android/net/module/util/DnsSvcbPacket.java
+++ b/staticlibs/framework/com/android/net/module/util/DnsSvcbPacket.java
@@ -21,7 +21,6 @@
 
 import android.annotation.NonNull;
 import android.annotation.Nullable;
-import android.text.TextUtils;
 import android.util.Log;
 
 import java.net.InetAddress;
@@ -29,7 +28,6 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
-import java.util.StringJoiner;
 
 /**
  * A class for a DNS SVCB response packet.
@@ -159,16 +157,6 @@
         return out;
     }
 
-    @Override
-    public String toString() {
-        final StringJoiner out = new StringJoiner(" ");
-        out.add("QUERY: [" + TextUtils.join(", ", mRecords[QDSECTION]) + "]");
-        out.add("ANSWER: [" + TextUtils.join(", ", mRecords[ANSECTION]) + "]");
-        out.add("AUTHORITY: [" + TextUtils.join(", ", mRecords[NSSECTION]) + "]");
-        out.add("ADDITIONAL: [" + TextUtils.join(", ", mRecords[ARSECTION]) + "]");
-        return out.toString();
-    }
-
     /**
      * Creates a DnsSvcbPacket object from the given wire-format DNS answer.
      */
diff --git a/staticlibs/framework/com/android/net/module/util/DnsSvcbRecord.java b/staticlibs/framework/com/android/net/module/util/DnsSvcbRecord.java
index 669725c..935cdf6 100644
--- a/staticlibs/framework/com/android/net/module/util/DnsSvcbRecord.java
+++ b/staticlibs/framework/com/android/net/module/util/DnsSvcbRecord.java
@@ -230,7 +230,7 @@
     /**
      * The base class for all SvcParam.
      */
-    private abstract static class SvcParam {
+    private abstract static class SvcParam<T> {
         private final int mKey;
 
         SvcParam(int key) {
@@ -240,9 +240,11 @@
         int getKey() {
             return mKey;
         }
+
+        abstract T getValue();
     }
 
-    private static class SvcParamMandatory extends SvcParam {
+    private static class SvcParamMandatory extends SvcParam<short[]> {
         private final short[] mValue;
 
         private SvcParamMandatory(@NonNull ByteBuffer buf) throws BufferUnderflowException,
@@ -258,6 +260,12 @@
         }
 
         @Override
+        short[] getValue() {
+            /* Not yet implemented */
+            return null;
+        }
+
+        @Override
         public String toString() {
             final StringJoiner valueJoiner = new StringJoiner(",");
             for (short key : mValue) {
@@ -267,7 +275,7 @@
         }
     }
 
-    private static class SvcParamAlpn extends SvcParam {
+    private static class SvcParamAlpn extends SvcParam<List<String>> {
         private final List<String> mValue;
 
         SvcParamAlpn(@NonNull ByteBuffer buf) throws BufferUnderflowException, ParseException {
@@ -281,6 +289,7 @@
             }
         }
 
+        @Override
         List<String> getValue() {
             return Collections.unmodifiableList(mValue);
         }
@@ -291,7 +300,7 @@
         }
     }
 
-    private static class SvcParamNoDefaultAlpn extends SvcParam {
+    private static class SvcParamNoDefaultAlpn extends SvcParam<Void> {
         SvcParamNoDefaultAlpn(@NonNull ByteBuffer buf) throws BufferUnderflowException,
                 ParseException {
             super(KEY_NO_DEFAULT_ALPN);
@@ -303,12 +312,17 @@
         }
 
         @Override
+        Void getValue() {
+            return null;
+        }
+
+        @Override
         public String toString() {
             return toKeyName(getKey());
         }
     }
 
-    private static class SvcParamPort extends SvcParam {
+    private static class SvcParamPort extends SvcParam<Integer> {
         private final int mValue;
 
         SvcParamPort(@NonNull ByteBuffer buf) throws BufferUnderflowException, ParseException {
@@ -321,7 +335,8 @@
             mValue = Short.toUnsignedInt(buf.getShort());
         }
 
-        int getValue() {
+        @Override
+        Integer getValue() {
             return mValue;
         }
 
@@ -331,7 +346,7 @@
         }
     }
 
-    private static class SvcParamIpHint extends SvcParam {
+    private static class SvcParamIpHint extends SvcParam<List<InetAddress>> {
         private final List<InetAddress> mValue;
 
         private SvcParamIpHint(int key, @NonNull ByteBuffer buf, int addrLen) throws
@@ -346,6 +361,7 @@
             }
         }
 
+        @Override
         List<InetAddress> getValue() {
             return Collections.unmodifiableList(mValue);
         }
@@ -378,7 +394,7 @@
         }
     }
 
-    private static class SvcParamDohPath extends SvcParam {
+    private static class SvcParamDohPath extends SvcParam<String> {
         private final String mValue;
 
         SvcParamDohPath(@NonNull ByteBuffer buf) throws BufferUnderflowException, ParseException {
@@ -390,6 +406,7 @@
             mValue = new String(value, StandardCharsets.UTF_8);
         }
 
+        @Override
         String getValue() {
             return mValue;
         }
@@ -401,7 +418,7 @@
     }
 
     // For other unrecognized and unimplemented SvcParams, they are stored as SvcParamGeneric.
-    private static class SvcParamGeneric extends SvcParam {
+    private static class SvcParamGeneric extends SvcParam<byte[]> {
         private final byte[] mValue;
 
         SvcParamGeneric(int key, @NonNull ByteBuffer buf) throws BufferUnderflowException,
@@ -414,6 +431,12 @@
         }
 
         @Override
+        byte[] getValue() {
+            /* Not yet implemented */
+            return null;
+        }
+
+        @Override
         public String toString() {
             final StringBuilder out = new StringBuilder();
             out.append(toKeyName(getKey()));
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/DnsSvcbPacketTest.java b/staticlibs/tests/unit/src/com/android/net/module/util/DnsSvcbPacketTest.java
index 6778f8a..d59795f 100644
--- a/staticlibs/tests/unit/src/com/android/net/module/util/DnsSvcbPacketTest.java
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/DnsSvcbPacketTest.java
@@ -207,7 +207,7 @@
                 os.write(shortToByteArray((short) mRdataLen));
             } else {
                 final byte[] targetNameLabels =
-                                DnsPacketUtils.DnsRecordParser.domainNameToLabels(mTargetName);
+                        DnsPacketUtils.DnsRecordParser.domainNameToLabels(mTargetName);
                 mRdataLen += (Short.BYTES + targetNameLabels.length);
                 os.write(shortToByteArray((short) mRdataLen));
                 os.write(shortToByteArray(mSvcPriority));
@@ -251,7 +251,9 @@
         // Check the content returned from toString() for now because the getter function for
         // this SvcParam hasn't been implemented.
         // TODO(b/240259333): Consider adding DnsSvcbRecord.isMandatory(String alpn) when needed.
-        assertTrue(record.toString().contains("mandatory=ipv4hint,alpn,key333"));
+        assertTrue(record.toString().contains("ipv4hint"));
+        assertTrue(record.toString().contains("alpn"));
+        assertTrue(record.toString().contains("key333"));
     }
 
     @Test
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/DevSdkIgnoreRunner.kt b/staticlibs/testutils/devicetests/com/android/testutils/DevSdkIgnoreRunner.kt
index 2d281fd..1ba83ca 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/DevSdkIgnoreRunner.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/DevSdkIgnoreRunner.kt
@@ -19,6 +19,7 @@
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import com.android.testutils.DevSdkIgnoreRule.IgnoreAfter
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
+import java.lang.IllegalStateException
 import java.lang.reflect.Modifier
 import org.junit.runner.Description
 import org.junit.runner.Runner
@@ -27,6 +28,7 @@
 import org.junit.runner.manipulation.NoTestsRemainException
 import org.junit.runner.manipulation.Sortable
 import org.junit.runner.manipulation.Sorter
+import org.junit.runner.notification.Failure
 import org.junit.runner.notification.RunNotifier
 import org.junit.runners.Parameterized
 
@@ -52,6 +54,9 @@
  *     class MyTestClass { ... }
  */
 class DevSdkIgnoreRunner(private val klass: Class<*>) : Runner(), Filterable, Sortable {
+    private val leakMonitorDesc = Description.createTestDescription(klass, "ThreadLeakMonitor")
+    private val shouldThreadLeakFailTest = klass.isAnnotationPresent(MonitorThreadLeak::class.java)
+
     // Inference correctly infers Runner & Filterable & Sortable for |baseRunner|, but the
     // Java bytecode doesn't have a way to express this. Give this type a name by wrapping it.
     private class RunnerWrapper<T>(private val wrapped: T) :
@@ -61,6 +66,10 @@
         override fun run(notifier: RunNotifier?) = wrapped.run(notifier)
     }
 
+    // Annotation for test classes to indicate the test runner should monitor thread leak.
+    // TODO(b/307693729): Remove this annotation and monitor thread leak by default.
+    annotation class MonitorThreadLeak
+
     private val baseRunner: RunnerWrapper<*>? = klass.let {
         val ignoreAfter = it.getAnnotation(IgnoreAfter::class.java)
         val ignoreUpTo = it.getAnnotation(IgnoreUpTo::class.java)
@@ -81,20 +90,52 @@
                 it.isAnnotationPresent(Parameterized.Parameters::class.java) }
 
     override fun run(notifier: RunNotifier) {
-        if (baseRunner != null) {
+        if (baseRunner == null) {
+            // Report a single, skipped placeholder test for this class, as the class is expected to
+            // report results when run. In practice runners that apply the Filterable implementation
+            // would see a NoTestsRemainException and not call the run method.
+            notifier.fireTestIgnored(
+                    Description.createTestDescription(klass, "skippedClassForDevSdkMismatch"))
+            return
+        }
+        if (!shouldThreadLeakFailTest) {
             baseRunner.run(notifier)
             return
         }
 
-        // Report a single, skipped placeholder test for this class, as the class is expected to
-        // report results when run. In practice runners that apply the Filterable implementation
-        // would see a NoTestsRemainException and not call the run method.
-        notifier.fireTestIgnored(
-                Description.createTestDescription(klass, "skippedClassForDevSdkMismatch"))
+        // Dump threads as a baseline to monitor thread leaks.
+        val threadCountsBeforeTest = getAllThreadNameCounts()
+
+        baseRunner.run(notifier)
+
+        notifier.fireTestStarted(leakMonitorDesc)
+        val threadCountsAfterTest = getAllThreadNameCounts()
+        if (threadCountsBeforeTest != threadCountsAfterTest) {
+            notifier.fireTestFailure(Failure(leakMonitorDesc,
+                    IllegalStateException("Expected threads: $threadCountsBeforeTest " +
+                            "but got: $threadCountsAfterTest")))
+        }
+        notifier.fireTestFinished(leakMonitorDesc)
+    }
+
+    private fun getAllThreadNameCounts(): Map<String, Int> {
+        // Get the counts of threads in the group per name.
+        // Filter system thread groups.
+        return Thread.getAllStackTraces().keys
+                .filter { it.threadGroup?.name != "system" }
+                .groupingBy { it.name }.eachCount()
     }
 
     override fun getDescription(): Description {
-        return baseRunner?.description ?: Description.createSuiteDescription(klass)
+        if (baseRunner == null) {
+            return Description.createSuiteDescription(klass)
+        }
+
+        return baseRunner.description.also {
+            if (shouldThreadLeakFailTest) {
+                it.addChild(leakMonitorDesc)
+            }
+        }
     }
 
     /**
@@ -102,7 +143,9 @@
      */
     override fun testCount(): Int {
         // When ignoring the tests, a skipped placeholder test is reported, so test count is 1.
-        return baseRunner?.testCount() ?: 1
+        if (baseRunner == null) return 1
+
+        return baseRunner.testCount() + if (shouldThreadLeakFailTest) 1 else 0
     }
 
     @Throws(NoTestsRemainException::class)
diff --git a/tests/cts/hostside/app/src/com/android/cts/net/hostside/AbstractRestrictBackgroundNetworkTestCase.java b/tests/cts/hostside/app/src/com/android/cts/net/hostside/AbstractRestrictBackgroundNetworkTestCase.java
index bb32052..198b009 100644
--- a/tests/cts/hostside/app/src/com/android/cts/net/hostside/AbstractRestrictBackgroundNetworkTestCase.java
+++ b/tests/cts/hostside/app/src/com/android/cts/net/hostside/AbstractRestrictBackgroundNetworkTestCase.java
@@ -62,6 +62,8 @@
 import android.util.Log;
 import android.util.Pair;
 
+import androidx.annotation.Nullable;
+
 import com.android.compatibility.common.util.AmUtils;
 import com.android.compatibility.common.util.BatteryUtils;
 import com.android.compatibility.common.util.DeviceConfigStateHelper;
@@ -283,8 +285,30 @@
     }
 
     protected void assertBackgroundNetworkAccess(boolean expectAllowed) throws Exception {
+        assertBackgroundNetworkAccess(expectAllowed, null);
+    }
+
+    /**
+     * Asserts whether the active network is available or not for the background app. If the network
+     * is unavailable, also checks whether it is blocked by the expected error.
+     *
+     * @param expectAllowed expect background network access to be allowed or not.
+     * @param expectedUnavailableError the expected error when {@code expectAllowed} is false. It's
+     *                                 meaningful only when the {@code expectAllowed} is 'false'.
+     *                                 Throws an IllegalArgumentException when {@code expectAllowed}
+     *                                 is true and this parameter is not null. When the
+     *                                 {@code expectAllowed} is 'false' and this parameter is null,
+     *                                 this function does not compare error type of the networking
+     *                                 access failure.
+     */
+    protected void assertBackgroundNetworkAccess(boolean expectAllowed,
+            @Nullable final String expectedUnavailableError) throws Exception {
         assertBackgroundState();
-        assertNetworkAccess(expectAllowed /* expectAvailable */, false /* needScreenOn */);
+        if (expectAllowed && expectedUnavailableError != null) {
+            throw new IllegalArgumentException("expectedUnavailableError is not null");
+        }
+        assertNetworkAccess(expectAllowed /* expectAvailable */, false /* needScreenOn */,
+                expectedUnavailableError);
     }
 
     protected void assertForegroundNetworkAccess() throws Exception {
@@ -407,12 +431,17 @@
      */
     private void assertNetworkAccess(boolean expectAvailable, boolean needScreenOn)
             throws Exception {
+        assertNetworkAccess(expectAvailable, needScreenOn, null);
+    }
+
+    private void assertNetworkAccess(boolean expectAvailable, boolean needScreenOn,
+            @Nullable final String expectedUnavailableError) throws Exception {
         final int maxTries = 5;
         String error = null;
         int timeoutMs = 500;
 
         for (int i = 1; i <= maxTries; i++) {
-            error = checkNetworkAccess(expectAvailable);
+            error = checkNetworkAccess(expectAvailable, expectedUnavailableError);
 
             if (error == null) return;
 
@@ -479,12 +508,15 @@
      *
      * @return error message with the mismatch (or empty if assertion passed).
      */
-    private String checkNetworkAccess(boolean expectAvailable) throws Exception {
+    private String checkNetworkAccess(boolean expectAvailable,
+            @Nullable final String expectedUnavailableError) throws Exception {
         final String resultData = mServiceClient.checkNetworkStatus();
-        return checkForAvailabilityInResultData(resultData, expectAvailable);
+        return checkForAvailabilityInResultData(resultData, expectAvailable,
+                expectedUnavailableError);
     }
 
-    private String checkForAvailabilityInResultData(String resultData, boolean expectAvailable) {
+    private String checkForAvailabilityInResultData(String resultData, boolean expectAvailable,
+            @Nullable final String expectedUnavailableError) {
         if (resultData == null) {
             assertNotNull("Network status from app2 is null", resultData);
         }
@@ -516,6 +548,10 @@
         if (expectedState != state || expectedDetailedState != detailedState) {
             errors.append(String.format("Connection state mismatch: expected %s/%s, got %s/%s\n",
                     expectedState, expectedDetailedState, state, detailedState));
+        } else if (!expectAvailable && (expectedUnavailableError != null)
+                 && !connectionCheckDetails.contains(expectedUnavailableError)) {
+            errors.append("Connection unavailable reason mismatch: expected "
+                     + expectedUnavailableError + "\n");
         }
 
         if (errors.length() > 0) {
@@ -914,7 +950,7 @@
                 final String resultData = result.get(0).second;
                 if (resultCode == INetworkStateObserver.RESULT_SUCCESS_NETWORK_STATE_CHECKED) {
                     final String error = checkForAvailabilityInResultData(
-                            resultData, expectAvailable);
+                            resultData, expectAvailable, null /* expectedUnavailableError */);
                     if (error != null) {
                         fail("Network is not available for activity in app2 (" + mUid + "): "
                                 + error);
@@ -949,7 +985,7 @@
                 final String resultData = result.get(0).second;
                 if (resultCode == INetworkStateObserver.RESULT_SUCCESS_NETWORK_STATE_CHECKED) {
                     final String error = checkForAvailabilityInResultData(
-                            resultData, expectAvailable);
+                            resultData, expectAvailable, null /* expectedUnavailableError */);
                     if (error != null) {
                         Log.d(TAG, "Network state is unexpected, checking again. " + error);
                         // Right now we could end up in an unexpected state if expedited job
diff --git a/tests/cts/hostside/app/src/com/android/cts/net/hostside/NetworkCallbackTest.java b/tests/cts/hostside/app/src/com/android/cts/net/hostside/NetworkCallbackTest.java
index ab3cf14..82f4a65 100644
--- a/tests/cts/hostside/app/src/com/android/cts/net/hostside/NetworkCallbackTest.java
+++ b/tests/cts/hostside/app/src/com/android/cts/net/hostside/NetworkCallbackTest.java
@@ -32,8 +32,11 @@
 import android.net.Network;
 import android.net.NetworkCapabilities;
 import android.net.NetworkRequest;
+import android.net.cts.util.CtsNetUtils;
 import android.util.Log;
 
+import com.android.modules.utils.build.SdkLevel;
+
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Rule;
@@ -46,6 +49,9 @@
 public class NetworkCallbackTest extends AbstractRestrictBackgroundNetworkTestCase {
     private Network mNetwork;
     private final TestNetworkCallback mTestNetworkCallback = new TestNetworkCallback();
+    private CtsNetUtils mCtsNetUtils;
+    private static final String GOOGLE_PRIVATE_DNS_SERVER = "dns.google";
+
     @Rule
     public final MeterednessConfigurationRule mMeterednessConfiguration
             = new MeterednessConfigurationRule();
@@ -218,6 +224,26 @@
         mTestNetworkCallback.expectCapabilitiesCallbackEventually(mNetwork,
                 false /* hasCapability */, NET_CAPABILITY_NOT_METERED);
         mTestNetworkCallback.expectBlockedStatusCallback(mNetwork, false);
+
+        // Before Android T, DNS queries over private DNS should be but are not restricted by Power
+        // Saver or Data Saver. The issue is fixed in mainline update and apps can no longer request
+        // DNS queries when its network is restricted by Power Saver. The fix takes effect backwards
+        // starting from Android T. But for Data Saver, the fix is not backward compatible since
+        // there are some platform changes involved. It is only available on devices that a specific
+        // trunk flag is enabled.
+        //
+        // This test can not only verify that the network traffic from apps is blocked at the right
+        // time, but also verify whether it is correctly blocked at the DNS stage, or at a later
+        // socket connection stage.
+        if (SdkLevel.isAtLeastT()) {
+            // Enable private DNS
+            mCtsNetUtils = new CtsNetUtils(mContext);
+            mCtsNetUtils.storePrivateDnsSetting();
+            mCtsNetUtils.setPrivateDnsStrictMode(GOOGLE_PRIVATE_DNS_SERVER);
+            mCtsNetUtils.awaitPrivateDnsSetting(
+                    "NetworkCallbackTest wait private DNS setting timeout", mNetwork,
+                    GOOGLE_PRIVATE_DNS_SERVER, true);
+        }
     }
 
     @After
@@ -227,6 +253,10 @@
         setRestrictBackground(false);
         setBatterySaverMode(false);
         unregisterNetworkCallback();
+
+        if (SdkLevel.isAtLeastT() && (mCtsNetUtils != null)) {
+            mCtsNetUtils.restorePrivateDnsSetting();
+        }
     }
 
     @RequiredProperties({DATA_SAVER_MODE})
@@ -235,6 +265,8 @@
         try {
             // Enable restrict background
             setRestrictBackground(true);
+            // TODO: Verify expectedUnavailableError when aconfig support mainline.
+            // (see go/aconfig-in-mainline-problems)
             assertBackgroundNetworkAccess(false);
             assertNetworkAccessBlockedByBpf(true, mUid, true /* metered */);
             mTestNetworkCallback.expectBlockedStatusCallbackEventually(mNetwork, true);
@@ -247,6 +279,7 @@
 
             // Remove from whitelist
             removeRestrictBackgroundWhitelist(mUid);
+            // TODO: Verify expectedUnavailableError when aconfig support mainline.
             assertBackgroundNetworkAccess(false);
             assertNetworkAccessBlockedByBpf(true, mUid, true /* metered */);
             mTestNetworkCallback.expectBlockedStatusCallbackEventually(mNetwork, true);
@@ -278,7 +311,11 @@
         try {
             // Enable Power Saver
             setBatterySaverMode(true);
-            assertBackgroundNetworkAccess(false);
+            if (SdkLevel.isAtLeastT()) {
+                assertBackgroundNetworkAccess(false, "java.net.UnknownHostException");
+            } else {
+                assertBackgroundNetworkAccess(false);
+            }
             mTestNetworkCallback.expectBlockedStatusCallbackEventually(mNetwork, true);
             assertNetworkAccessBlockedByBpf(true, mUid, true /* metered */);
 
@@ -298,7 +335,11 @@
         try {
             // Enable Power Saver
             setBatterySaverMode(true);
-            assertBackgroundNetworkAccess(false);
+            if (SdkLevel.isAtLeastT()) {
+                assertBackgroundNetworkAccess(false, "java.net.UnknownHostException");
+            } else {
+                assertBackgroundNetworkAccess(false);
+            }
             mTestNetworkCallback.expectBlockedStatusCallbackEventually(mNetwork, true);
             assertNetworkAccessBlockedByBpf(true, mUid, false /* metered */);
 
diff --git a/tests/unit/java/com/android/server/ConnectivityServiceTest.java b/tests/unit/java/com/android/server/ConnectivityServiceTest.java
index bafd450..b8cf08e 100755
--- a/tests/unit/java/com/android/server/ConnectivityServiceTest.java
+++ b/tests/unit/java/com/android/server/ConnectivityServiceTest.java
@@ -31,6 +31,7 @@
 import static android.Manifest.permission.NETWORK_STACK;
 import static android.Manifest.permission.PACKET_KEEPALIVE_OFFLOAD;
 import static android.Manifest.permission.READ_DEVICE_CONFIG;
+import static android.Manifest.permission.STATUS_BAR_SERVICE;
 import static android.app.ActivityManager.UidFrozenStateChangedCallback.UID_FROZEN_STATE_FROZEN;
 import static android.app.ActivityManager.UidFrozenStateChangedCallback.UID_FROZEN_STATE_UNFROZEN;
 import static android.app.PendingIntent.FLAG_IMMUTABLE;
@@ -154,9 +155,12 @@
 import static android.net.resolv.aidl.IDnsResolverUnsolicitedEventListener.VALIDATION_RESULT_SUCCESS;
 import static android.os.Process.INVALID_UID;
 import static android.system.OsConstants.IPPROTO_TCP;
+import static android.telephony.DataConnectionRealTimeInfo.DC_POWER_STATE_HIGH;
+import static android.telephony.DataConnectionRealTimeInfo.DC_POWER_STATE_LOW;
 
 import static com.android.server.ConnectivityService.DELAY_DESTROY_FROZEN_SOCKETS_VERSION;
 import static com.android.net.module.util.DeviceConfigUtils.TETHERING_MODULE_NAME;
+import static com.android.server.ConnectivityService.ALLOW_SYSUI_CONNECTIVITY_REPORTS;
 import static com.android.server.ConnectivityService.KEY_DESTROY_FROZEN_SOCKETS_VERSION;
 import static com.android.server.ConnectivityService.MAX_NETWORK_REQUESTS_PER_SYSTEM_UID;
 import static com.android.server.ConnectivityService.PREFERENCE_ORDER_MOBILE_DATA_PREFERERRED;
@@ -638,8 +642,8 @@
 
     // BatteryStatsManager is final and cannot be mocked with regular mockito, so just mock the
     // underlying binder calls.
-    final BatteryStatsManager mBatteryStatsManager =
-            new BatteryStatsManager(mock(IBatteryStats.class));
+    final IBatteryStats mIBatteryStats = mock(IBatteryStats.class);
+    final BatteryStatsManager mBatteryStatsManager = new BatteryStatsManager(mIBatteryStats);
 
     private ArgumentCaptor<ResolverParamsParcel> mResolverParamsParcelCaptor =
             ArgumentCaptor.forClass(ResolverParamsParcel.class);
@@ -2150,6 +2154,16 @@
             }
         }
 
+        @Override
+        public boolean isFeatureNotChickenedOut(Context context, String name) {
+            switch (name) {
+                case ALLOW_SYSUI_CONNECTIVITY_REPORTS:
+                    return true;
+                default:
+                    return super.isFeatureNotChickenedOut(context, name);
+            }
+        }
+
         public void setChangeIdEnabled(final boolean enabled, final long changeId, final int uid) {
             final Pair<Long, Integer> data = new Pair<>(changeId, uid);
             // mEnabledChangeIds is read on the handler thread and maybe the test thread, so
@@ -10795,6 +10809,11 @@
         expectNativeNetworkCreated(netId, permission, iface, null /* inOrder */);
     }
 
+    private int getIdleTimerLabel(int netId, int transportType) {
+        return ConnectivityService.LegacyNetworkActivityTracker.getIdleTimerLabel(
+                mDeps.isAtLeastV(), netId, transportType);
+    }
+
     @Test
     public void testStackedLinkProperties() throws Exception {
         final LinkAddress myIpv4 = new LinkAddress("1.2.3.4/24");
@@ -11036,7 +11055,7 @@
         networkCallback.expect(LOST, mCellAgent);
         networkCallback.assertNoCallback();
         verify(mMockNetd, times(1)).idletimerRemoveInterface(eq(MOBILE_IFNAME), anyInt(),
-                eq(Integer.toString(TRANSPORT_CELLULAR)));
+                eq(Integer.toString(getIdleTimerLabel(cellNetId, TRANSPORT_CELLULAR))));
         verify(mMockNetd).networkDestroy(cellNetId);
         if (mDeps.isAtLeastU()) {
             verify(mMockNetd).setNetworkAllowlist(any());
@@ -11095,7 +11114,7 @@
         }
 
         verify(mMockNetd).idletimerRemoveInterface(eq(MOBILE_IFNAME), anyInt(),
-                eq(Integer.toString(TRANSPORT_CELLULAR)));
+                eq(Integer.toString(getIdleTimerLabel(cellNetId, TRANSPORT_CELLULAR))));
         verify(mMockNetd).networkDestroy(cellNetId);
         if (mDeps.isAtLeastU()) {
             verify(mMockNetd).setNetworkAllowlist(any());
@@ -11350,8 +11369,21 @@
         final ConditionVariable onNetworkActiveCv = new ConditionVariable();
         final ConnectivityManager.OnNetworkActiveListener listener = onNetworkActiveCv::open;
 
+        TestNetworkCallback defaultCallback = new TestNetworkCallback();
+
         testAndCleanup(() -> {
+            mCm.registerDefaultNetworkCallback(defaultCallback);
             agent.connect(true);
+            defaultCallback.expectAvailableThenValidatedCallbacks(agent);
+            if (transportType == TRANSPORT_CELLULAR) {
+                verify(mIBatteryStats).noteMobileRadioPowerState(eq(DC_POWER_STATE_HIGH),
+                        anyLong() /* timestampNs */, eq(NETWORK_ACTIVITY_NO_UID));
+            } else if (transportType == TRANSPORT_WIFI) {
+                verify(mIBatteryStats).noteWifiRadioPowerState(eq(DC_POWER_STATE_HIGH),
+                        anyLong() /* timestampNs */, eq(NETWORK_ACTIVITY_NO_UID));
+            }
+            clearInvocations(mIBatteryStats);
+            final int idleTimerLabel = getIdleTimerLabel(agent.getNetwork().netId, transportType);
 
             // Network is considered active when the network becomes the default network.
             assertTrue(mCm.isDefaultNetworkActive());
@@ -11360,19 +11392,57 @@
 
             // Interface goes to inactive state
             netdUnsolicitedEventListener.onInterfaceClassActivityChanged(false /* isActive */,
-                    transportType, TIMESTAMP, NETWORK_ACTIVITY_NO_UID);
+                    idleTimerLabel, TIMESTAMP, NETWORK_ACTIVITY_NO_UID);
             mServiceContext.expectDataActivityBroadcast(legacyType, false /* isActive */,
                     TIMESTAMP);
             assertFalse(onNetworkActiveCv.block(TEST_CALLBACK_TIMEOUT_MS));
             assertFalse(mCm.isDefaultNetworkActive());
+            if (mDeps.isAtLeastV()) {
+                if (transportType == TRANSPORT_CELLULAR) {
+                    verify(mIBatteryStats).noteMobileRadioPowerState(eq(DC_POWER_STATE_LOW),
+                            anyLong() /* timestampNs */, eq(NETWORK_ACTIVITY_NO_UID));
+                } else if (transportType == TRANSPORT_WIFI) {
+                    verify(mIBatteryStats).noteWifiRadioPowerState(eq(DC_POWER_STATE_LOW),
+                            anyLong() /* timestampNs */, eq(NETWORK_ACTIVITY_NO_UID));
+                }
+            } else {
+                // If TrackMultiNetworks is disabled, LegacyNetworkActivityTracker does not call
+                // BatteryStats API by the netd activity change callback since BatteryStatsService
+                // listen to netd callback via NetworkManagementService and update battery stats by
+                // itself.
+                verify(mIBatteryStats, never())
+                        .noteMobileRadioPowerState(anyInt(), anyLong(), anyInt());
+                verify(mIBatteryStats, never())
+                        .noteWifiRadioPowerState(anyInt(), anyLong(), anyInt());
+            }
 
             // Interface goes to active state
             netdUnsolicitedEventListener.onInterfaceClassActivityChanged(true /* isActive */,
-                    transportType, TIMESTAMP, TEST_PACKAGE_UID);
+                    idleTimerLabel, TIMESTAMP, TEST_PACKAGE_UID);
             mServiceContext.expectDataActivityBroadcast(legacyType, true /* isActive */, TIMESTAMP);
             assertTrue(onNetworkActiveCv.block(TEST_CALLBACK_TIMEOUT_MS));
             assertTrue(mCm.isDefaultNetworkActive());
+            if (mDeps.isAtLeastV()) {
+                if (transportType == TRANSPORT_CELLULAR) {
+                    verify(mIBatteryStats).noteMobileRadioPowerState(eq(DC_POWER_STATE_HIGH),
+                            anyLong() /* timestampNs */, eq(TEST_PACKAGE_UID));
+                } else if (transportType == TRANSPORT_WIFI) {
+                    verify(mIBatteryStats).noteWifiRadioPowerState(eq(DC_POWER_STATE_HIGH),
+                            anyLong() /* timestampNs */, eq(TEST_PACKAGE_UID));
+                }
+            } else {
+                // If TrackMultiNetworks is disabled, LegacyNetworkActivityTracker does not call
+                // BatteryStats API by the netd activity change callback since BatteryStatsService
+                // listen to netd callback via NetworkManagementService and update battery stats by
+                // itself.
+                verify(mIBatteryStats, never())
+                        .noteMobileRadioPowerState(anyInt(), anyLong(), anyInt());
+                verify(mIBatteryStats, never())
+                        .noteWifiRadioPowerState(anyInt(), anyLong(), anyInt());
+            }
         }, () -> { // Cleanup
+                mCm.unregisterNetworkCallback(defaultCallback);
+            }, () -> { // Cleanup
                 mCm.removeDefaultNetworkActiveListener(listener);
             }, () -> { // Cleanup
                 agent.disconnect();
@@ -11420,12 +11490,13 @@
     }
 
     @Test
-    public void testOnNetworkActive_NewEthernetConnects_CallbackNotCalled() throws Exception {
-        // LegacyNetworkActivityTracker calls onNetworkActive callback only for networks that
-        // tracker adds the idle timer to. And the tracker does not set the idle timer for the
-        // ethernet network.
+    public void testOnNetworkActive_NewEthernetConnects_Callback() throws Exception {
+        // On pre-V devices, LegacyNetworkActivityTracker calls onNetworkActive callback only for
+        // networks that tracker adds the idle timer to. And the tracker does not set the idle timer
+        // for the ethernet network.
         // So onNetworkActive is not called when the ethernet becomes the default network
-        doTestOnNetworkActive_NewNetworkConnects(TRANSPORT_ETHERNET, false /* expectCallback */);
+        final boolean expectCallback = mDeps.isAtLeastV();
+        doTestOnNetworkActive_NewNetworkConnects(TRANSPORT_ETHERNET, expectCallback);
     }
 
     @Test
@@ -11455,15 +11526,19 @@
         mCm.registerNetworkCallback(networkRequest, networkCallback);
 
         mCellAgent = new TestNetworkAgentWrapper(TRANSPORT_CELLULAR);
+        final String cellIdleTimerLabel = Integer.toString(getIdleTimerLabel(
+                mCellAgent.getNetwork().netId, TRANSPORT_CELLULAR));
         final LinkProperties cellLp = new LinkProperties();
         cellLp.setInterfaceName(MOBILE_IFNAME);
         mCellAgent.sendLinkProperties(cellLp);
         mCellAgent.connect(true);
         networkCallback.expectAvailableThenValidatedCallbacks(mCellAgent);
         verify(mMockNetd, times(1)).idletimerAddInterface(eq(MOBILE_IFNAME), anyInt(),
-                eq(Integer.toString(TRANSPORT_CELLULAR)));
+                eq(cellIdleTimerLabel));
 
         mWiFiAgent = new TestNetworkAgentWrapper(TRANSPORT_WIFI);
+        String wifiIdleTimerLabel = Integer.toString(getIdleTimerLabel(
+                mWiFiAgent.getNetwork().netId, TRANSPORT_WIFI));
         final LinkProperties wifiLp = new LinkProperties();
         wifiLp.setInterfaceName(WIFI_IFNAME);
         mWiFiAgent.sendLinkProperties(wifiLp);
@@ -11474,9 +11549,18 @@
         networkCallback.expectLosing(mCellAgent);
         networkCallback.expectCaps(mWiFiAgent, c -> c.hasCapability(NET_CAPABILITY_VALIDATED));
         verify(mMockNetd, times(1)).idletimerAddInterface(eq(WIFI_IFNAME), anyInt(),
-                eq(Integer.toString(TRANSPORT_WIFI)));
-        verify(mMockNetd, times(1)).idletimerRemoveInterface(eq(MOBILE_IFNAME), anyInt(),
-                eq(Integer.toString(TRANSPORT_CELLULAR)));
+                eq(wifiIdleTimerLabel));
+        if (mDeps.isAtLeastV()) {
+            // V+ devices add idleTimer when the network is first connected and remove when the
+            // network is disconnected.
+            verify(mMockNetd, never()).idletimerRemoveInterface(eq(MOBILE_IFNAME), anyInt(),
+                    eq(Integer.toString(mCellAgent.getNetwork().netId)));
+        } else {
+            // pre V devices add idleTimer when the network becomes the default network and remove
+            // when the network becomes no longer the default network.
+            verify(mMockNetd, times(1)).idletimerRemoveInterface(eq(MOBILE_IFNAME), anyInt(),
+                    eq(Integer.toString(TRANSPORT_CELLULAR)));
+        }
 
         // Disconnect wifi and switch back to cell
         reset(mMockNetd);
@@ -11484,13 +11568,20 @@
         networkCallback.expect(LOST, mWiFiAgent);
         assertNoCallbacks(networkCallback);
         verify(mMockNetd, times(1)).idletimerRemoveInterface(eq(WIFI_IFNAME), anyInt(),
-                eq(Integer.toString(TRANSPORT_WIFI)));
-        verify(mMockNetd, times(1)).idletimerAddInterface(eq(MOBILE_IFNAME), anyInt(),
-                eq(Integer.toString(TRANSPORT_CELLULAR)));
+                eq(wifiIdleTimerLabel));
+        if (mDeps.isAtLeastV()) {
+            verify(mMockNetd, never()).idletimerAddInterface(eq(MOBILE_IFNAME), anyInt(),
+                    eq(Integer.toString(mCellAgent.getNetwork().netId)));
+        } else {
+            verify(mMockNetd, times(1)).idletimerAddInterface(eq(MOBILE_IFNAME), anyInt(),
+                    eq(Integer.toString(TRANSPORT_CELLULAR)));
+        }
 
         // reconnect wifi
         reset(mMockNetd);
         mWiFiAgent = new TestNetworkAgentWrapper(TRANSPORT_WIFI);
+        wifiIdleTimerLabel = Integer.toString(getIdleTimerLabel(
+                mWiFiAgent.getNetwork().netId, TRANSPORT_WIFI));
         wifiLp.setInterfaceName(WIFI_IFNAME);
         mWiFiAgent.sendLinkProperties(wifiLp);
         mWiFiAgent.connect(true);
@@ -11498,20 +11589,30 @@
         networkCallback.expectLosing(mCellAgent);
         networkCallback.expectCaps(mWiFiAgent, c -> c.hasCapability(NET_CAPABILITY_VALIDATED));
         verify(mMockNetd, times(1)).idletimerAddInterface(eq(WIFI_IFNAME), anyInt(),
-                eq(Integer.toString(TRANSPORT_WIFI)));
-        verify(mMockNetd, times(1)).idletimerRemoveInterface(eq(MOBILE_IFNAME), anyInt(),
-                eq(Integer.toString(TRANSPORT_CELLULAR)));
+                eq(wifiIdleTimerLabel));
+        if (mDeps.isAtLeastV()) {
+            verify(mMockNetd, never()).idletimerRemoveInterface(eq(MOBILE_IFNAME), anyInt(),
+                    eq(Integer.toString(mCellAgent.getNetwork().netId)));
+        } else {
+            verify(mMockNetd, times(1)).idletimerRemoveInterface(eq(MOBILE_IFNAME), anyInt(),
+                    eq(Integer.toString(TRANSPORT_CELLULAR)));
+        }
 
         // Disconnect cell
         reset(mMockNetd);
         mCellAgent.disconnect();
         networkCallback.expect(LOST, mCellAgent);
-        // LOST callback is triggered earlier than removing idle timer. Broadcast should also be
-        // sent as network being switched. Ensure rule removal for cell will not be triggered
-        // unexpectedly before network being removed.
         waitForIdle();
-        verify(mMockNetd, times(0)).idletimerRemoveInterface(eq(MOBILE_IFNAME), anyInt(),
-                eq(Integer.toString(TRANSPORT_CELLULAR)));
+        if (mDeps.isAtLeastV()) {
+            verify(mMockNetd, times(1)).idletimerRemoveInterface(eq(MOBILE_IFNAME), anyInt(),
+                    eq(Integer.toString(mCellAgent.getNetwork().netId)));
+        }  else {
+            // LOST callback is triggered earlier than removing idle timer. Broadcast should also be
+            // sent as network being switched. Ensure rule removal for cell will not be triggered
+            // unexpectedly before network being removed.
+            verify(mMockNetd, times(0)).idletimerRemoveInterface(eq(MOBILE_IFNAME), anyInt(),
+                    eq(Integer.toString(TRANSPORT_CELLULAR)));
+        }
         verify(mMockNetd, times(1)).networkDestroy(eq(mCellAgent.getNetwork().netId));
         verify(mMockDnsResolver, times(1)).destroyNetworkCache(eq(mCellAgent.getNetwork().netId));
 
@@ -11520,12 +11621,27 @@
         mWiFiAgent.disconnect();
         b.expectBroadcast();
         verify(mMockNetd, times(1)).idletimerRemoveInterface(eq(WIFI_IFNAME), anyInt(),
-                eq(Integer.toString(TRANSPORT_WIFI)));
+                eq(wifiIdleTimerLabel));
 
         // Clean up
         mCm.unregisterNetworkCallback(networkCallback);
     }
 
+    @Test
+    public void testDataActivityTracking_VpnNetwork() throws Exception {
+        mWiFiAgent = new TestNetworkAgentWrapper(TRANSPORT_WIFI);
+        mWiFiAgent.connect(true /* validated */);
+        mMockVpn.setUnderlyingNetworks(new Network[] { mWiFiAgent.getNetwork() });
+
+        final LinkProperties lp = new LinkProperties();
+        lp.setInterfaceName(VPN_IFNAME);
+        mMockVpn.establishForMyUid(lp);
+
+        // NetworkActivityTracker should not track the VPN network since VPN can change the
+        // underlying network without disconnect.
+        verify(mMockNetd, never()).idletimerAddInterface(eq(VPN_IFNAME), anyInt(), any());
+    }
+
     private void verifyTcpBufferSizeChange(String tcpBufferSizes) throws Exception {
         String[] values = tcpBufferSizes.split(",");
         String rmemValues = String.join(" ", values[0], values[1], values[2]);
@@ -12810,6 +12926,18 @@
     }
 
     @Test
+    public void testCheckConnectivityDiagnosticsPermissionsSysUi() throws Exception {
+        final NetworkAgentInfo naiWithoutUid = fakeMobileNai(new NetworkCapabilities());
+
+        mServiceContext.setPermission(STATUS_BAR_SERVICE, PERMISSION_GRANTED);
+        assertTrue(
+                "SysUi permission (STATUS_BAR_SERVICE) not applied",
+                mService.checkConnectivityDiagnosticsPermissions(
+                        Process.myPid(), Process.myUid(), naiWithoutUid,
+                        mContext.getOpPackageName()));
+    }
+
+    @Test
     public void testCheckConnectivityDiagnosticsPermissionsWrongUidPackageName() throws Exception {
         final int wrongUid = Process.myUid() + 1;
 
@@ -18704,6 +18832,7 @@
         final LinkProperties lp = new LinkProperties();
         lp.setInterfaceName(transportToTestIfaceName(transportType));
         final TestNetworkAgentWrapper agent = new TestNetworkAgentWrapper(transportType, lp);
+        final int idleTimerLabel = getIdleTimerLabel(agent.getNetwork().netId, transportType);
         testAndCleanup(() -> {
             final UidFrozenStateChangedCallback uidFrozenStateChangedCallback =
                     getUidFrozenStateChangedCallback().get();
@@ -18716,7 +18845,7 @@
             if (freezeWithNetworkInactive) {
                 // Make network inactive
                 netdUnsolicitedEventListener.onInterfaceClassActivityChanged(false /* isActive */,
-                        transportType, TIMESTAMP, NETWORK_ACTIVITY_NO_UID);
+                        idleTimerLabel, TIMESTAMP, NETWORK_ACTIVITY_NO_UID);
             }
 
             // Freeze TEST_FROZEN_UID and TEST_UNFROZEN_UID
@@ -18740,7 +18869,7 @@
 
             // Make network active
             netdUnsolicitedEventListener.onInterfaceClassActivityChanged(true /* isActive */,
-                    transportType, TIMESTAMP, TEST_PACKAGE_UID);
+                    idleTimerLabel, TIMESTAMP, TEST_PACKAGE_UID);
             waitForIdle();
 
             if (expectDelay) {
@@ -18759,8 +18888,8 @@
     @Test
     @IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
     public void testDelayFrozenUidSocketDestroy_ActiveCellular() throws Exception {
-        doTestDelayFrozenUidSocketDestroy(TRANSPORT_CELLULAR,
-                false /* freezeWithNetworkInactive */, false /* expectDelay */);
+        doTestDelayFrozenUidSocketDestroy(TRANSPORT_CELLULAR, false /* freezeWithNetworkInactive */,
+                false /* expectDelay */);
     }
 
     @Test
@@ -18768,22 +18897,22 @@
     public void testDelayFrozenUidSocketDestroy_InactiveCellular() throws Exception {
         // When the default network is cellular and cellular network is inactive, closing socket
         // is delayed.
-        doTestDelayFrozenUidSocketDestroy(TRANSPORT_CELLULAR,
-                true /* freezeWithNetworkInactive */, true /* expectDelay */);
+        doTestDelayFrozenUidSocketDestroy(TRANSPORT_CELLULAR, true /* freezeWithNetworkInactive */,
+                true /* expectDelay */);
     }
 
     @Test
     @IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
     public void testDelayFrozenUidSocketDestroy_ActiveWifi() throws Exception {
-        doTestDelayFrozenUidSocketDestroy(TRANSPORT_WIFI,
-                false /* freezeWithNetworkInactive */, false /* expectDelay */);
+        doTestDelayFrozenUidSocketDestroy(TRANSPORT_WIFI, false /* freezeWithNetworkInactive */,
+                false /* expectDelay */);
     }
 
     @Test
     @IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
     public void testDelayFrozenUidSocketDestroy_InactiveWifi() throws Exception {
-        doTestDelayFrozenUidSocketDestroy(TRANSPORT_WIFI,
-                true /* freezeWithNetworkInactive */, false /* expectDelay */);
+        doTestDelayFrozenUidSocketDestroy(TRANSPORT_WIFI, true /* freezeWithNetworkInactive */,
+                false /* expectDelay */);
     }
 
     /**
@@ -18804,6 +18933,8 @@
         final LinkProperties cellLp = new LinkProperties();
         cellLp.setInterfaceName(MOBILE_IFNAME);
         mCellAgent = new TestNetworkAgentWrapper(TRANSPORT_CELLULAR, cellLp);
+        final int idleTimerLabel =
+                getIdleTimerLabel(mCellAgent.getNetwork().netId, TRANSPORT_CELLULAR);
 
         final TestNetworkCallback defaultCallback = new TestNetworkCallback();
         mCm.registerDefaultNetworkCallback(defaultCallback);
@@ -18813,7 +18944,7 @@
 
             // Make cell network inactive
             netdUnsolicitedEventListener.onInterfaceClassActivityChanged(false /* isActive */,
-                    TRANSPORT_CELLULAR, TIMESTAMP, NETWORK_ACTIVITY_NO_UID);
+                    idleTimerLabel, TIMESTAMP, NETWORK_ACTIVITY_NO_UID);
 
             // Freeze TEST_FROZEN_UID
             final int[] uids = {TEST_FROZEN_UID};
diff --git a/tests/unit/java/com/android/server/connectivity/VpnTest.java b/tests/unit/java/com/android/server/connectivity/VpnTest.java
index 48cfe77..ff801e5 100644
--- a/tests/unit/java/com/android/server/connectivity/VpnTest.java
+++ b/tests/unit/java/com/android/server/connectivity/VpnTest.java
@@ -56,11 +56,9 @@
 import static com.android.server.connectivity.Vpn.PREFERRED_IKE_PROTOCOL_IPV4_UDP;
 import static com.android.server.connectivity.Vpn.PREFERRED_IKE_PROTOCOL_IPV6_ESP;
 import static com.android.server.connectivity.Vpn.PREFERRED_IKE_PROTOCOL_IPV6_UDP;
-import static com.android.testutils.Cleanup.testAndCleanup;
 import static com.android.testutils.HandlerUtils.waitForIdleSerialExecutor;
 import static com.android.testutils.MiscAsserts.assertThrows;
 
-import static org.junit.Assert.assertArrayEquals;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
@@ -149,7 +147,6 @@
 import android.net.wifi.WifiInfo;
 import android.os.Build.VERSION_CODES;
 import android.os.Bundle;
-import android.os.ConditionVariable;
 import android.os.INetworkManagementService;
 import android.os.ParcelFileDescriptor;
 import android.os.PersistableBundle;
@@ -1983,22 +1980,6 @@
         // a subsequent CL.
     }
 
-    @Test
-    public void testStartLegacyVpnIpv6() throws Exception {
-        setMockedUsers(PRIMARY_USER);
-        final Vpn vpn = createVpn(PRIMARY_USER.id);
-        final LinkProperties lp = new LinkProperties();
-        lp.setInterfaceName(EGRESS_IFACE);
-        lp.addLinkAddress(new LinkAddress("2001:db8::1/64"));
-        final RouteInfo defaultRoute = new RouteInfo(
-                new IpPrefix(Inet6Address.ANY, 0), null, EGRESS_IFACE);
-        lp.addRoute(defaultRoute);
-
-        // IllegalStateException thrown since legacy VPN only supports IPv4.
-        assertThrows(IllegalStateException.class,
-                () -> vpn.startLegacyVpn(mVpnProfile, EGRESS_NETWORK, lp));
-    }
-
     private Vpn startLegacyVpn(final Vpn vpn, final VpnProfile vpnProfile) throws Exception {
         setMockedUsers(PRIMARY_USER);
 
@@ -3112,23 +3093,15 @@
     }
 
     @Test
-    public void testStartRacoonNumericAddress() throws Exception {
-        startRacoon("1.2.3.4", "1.2.3.4");
-    }
+    public void testStartLegacyVpnType() throws Exception {
+        setMockedUsers(PRIMARY_USER);
+        final Vpn vpn = createVpn(PRIMARY_USER.id);
+        final VpnProfile profile = new VpnProfile("testProfile" /* key */);
 
-    @Test
-    public void testStartRacoonHostname() throws Exception {
-        startRacoon("hostname", "5.6.7.8"); // address returned by deps.resolve
-    }
-
-    @Test
-    public void testStartPptp() throws Exception {
-        startPptp(true /* useMppe */);
-    }
-
-    @Test
-    public void testStartPptp_NoMppe() throws Exception {
-        startPptp(false /* useMppe */);
+        profile.type = VpnProfile.TYPE_PPTP;
+        assertThrows(UnsupportedOperationException.class, () -> startLegacyVpn(vpn, profile));
+        profile.type = VpnProfile.TYPE_L2TP_IPSEC_PSK;
+        assertThrows(UnsupportedOperationException.class, () -> startLegacyVpn(vpn, profile));
     }
 
     private void assertTransportInfoMatches(NetworkCapabilities nc, int type) {
@@ -3138,125 +3111,6 @@
         assertEquals(type, ti.getType());
     }
 
-    private void startPptp(boolean useMppe) throws Exception {
-        final VpnProfile profile = new VpnProfile("testProfile" /* key */);
-        profile.type = VpnProfile.TYPE_PPTP;
-        profile.name = "testProfileName";
-        profile.username = "userName";
-        profile.password = "thePassword";
-        profile.server = "192.0.2.123";
-        profile.mppe = useMppe;
-
-        doReturn(new Network[] { new Network(101) }).when(mConnectivityManager).getAllNetworks();
-        doReturn(new Network(102)).when(mConnectivityManager).registerNetworkAgent(
-                any(), // INetworkAgent
-                any(), // NetworkInfo
-                any(), // LinkProperties
-                any(), // NetworkCapabilities
-                any(), // LocalNetworkConfig
-                any(), // NetworkScore
-                any(), // NetworkAgentConfig
-                anyInt()); // provider ID
-
-        final Vpn vpn = startLegacyVpn(createVpn(PRIMARY_USER.id), profile);
-        final TestDeps deps = (TestDeps) vpn.mDeps;
-
-        testAndCleanup(() -> {
-            final String[] mtpdArgs = deps.mtpdArgs.get(10, TimeUnit.SECONDS);
-            final String[] argsPrefix = new String[]{
-                    EGRESS_IFACE, "pptp", profile.server, "1723", "name", profile.username,
-                    "password", profile.password, "linkname", "vpn", "refuse-eap", "nodefaultroute",
-                    "usepeerdns", "idle", "1800", "mtu", "1270", "mru", "1270"
-            };
-            assertArrayEquals(argsPrefix, Arrays.copyOf(mtpdArgs, argsPrefix.length));
-            if (useMppe) {
-                assertEquals(argsPrefix.length + 2, mtpdArgs.length);
-                assertEquals("+mppe", mtpdArgs[argsPrefix.length]);
-                assertEquals("-pap", mtpdArgs[argsPrefix.length + 1]);
-            } else {
-                assertEquals(argsPrefix.length + 1, mtpdArgs.length);
-                assertEquals("nomppe", mtpdArgs[argsPrefix.length]);
-            }
-
-            verify(mConnectivityManager, timeout(10_000)).registerNetworkAgent(
-                    any(), // INetworkAgent
-                    any(), // NetworkInfo
-                    any(), // LinkProperties
-                    any(), // NetworkCapabilities
-                    any(), // LocalNetworkConfig
-                    any(), // NetworkScore
-                    any(), // NetworkAgentConfig
-                    anyInt()); // provider ID
-        }, () -> { // Cleanup
-                vpn.mVpnRunner.exitVpnRunner();
-                deps.getStateFile().delete(); // set to delete on exit, but this deletes it earlier
-                vpn.mVpnRunner.join(10_000); // wait for up to 10s for the runner to die and cleanup
-            });
-    }
-
-    public void startRacoon(final String serverAddr, final String expectedAddr)
-            throws Exception {
-        final ConditionVariable legacyRunnerReady = new ConditionVariable();
-        final VpnProfile profile = new VpnProfile("testProfile" /* key */);
-        profile.type = VpnProfile.TYPE_L2TP_IPSEC_PSK;
-        profile.name = "testProfileName";
-        profile.username = "userName";
-        profile.password = "thePassword";
-        profile.server = serverAddr;
-        profile.ipsecIdentifier = "id";
-        profile.ipsecSecret = "secret";
-        profile.l2tpSecret = "l2tpsecret";
-
-        when(mConnectivityManager.getAllNetworks())
-            .thenReturn(new Network[] { new Network(101) });
-
-        when(mConnectivityManager.registerNetworkAgent(any(), any(), any(), any(),
-                any(), any(), any(), anyInt())).thenAnswer(invocation -> {
-                    // The runner has registered an agent and is now ready.
-                    legacyRunnerReady.open();
-                    return new Network(102);
-                });
-        final Vpn vpn = startLegacyVpn(createVpn(PRIMARY_USER.id), profile);
-        final TestDeps deps = (TestDeps) vpn.mDeps;
-        try {
-            // udppsk and 1701 are the values for TYPE_L2TP_IPSEC_PSK
-            assertArrayEquals(
-                    new String[] { EGRESS_IFACE, expectedAddr, "udppsk",
-                            profile.ipsecIdentifier, profile.ipsecSecret, "1701" },
-                    deps.racoonArgs.get(10, TimeUnit.SECONDS));
-            // literal values are hardcoded in Vpn.java for mtpd args
-            assertArrayEquals(
-                    new String[] { EGRESS_IFACE, "l2tp", expectedAddr, "1701", profile.l2tpSecret,
-                            "name", profile.username, "password", profile.password,
-                            "linkname", "vpn", "refuse-eap", "nodefaultroute", "usepeerdns",
-                            "idle", "1800", "mtu", "1270", "mru", "1270" },
-                    deps.mtpdArgs.get(10, TimeUnit.SECONDS));
-
-            // Now wait for the runner to be ready before testing for the route.
-            ArgumentCaptor<LinkProperties> lpCaptor = ArgumentCaptor.forClass(LinkProperties.class);
-            ArgumentCaptor<NetworkCapabilities> ncCaptor =
-                    ArgumentCaptor.forClass(NetworkCapabilities.class);
-            verify(mConnectivityManager, timeout(10_000)).registerNetworkAgent(any(), any(),
-                    lpCaptor.capture(), ncCaptor.capture(), any(), any(), any(), anyInt());
-
-            // In this test the expected address is always v4 so /32.
-            // Note that the interface needs to be specified because RouteInfo objects stored in
-            // LinkProperties objects always acquire the LinkProperties' interface.
-            final RouteInfo expectedRoute = new RouteInfo(new IpPrefix(expectedAddr + "/32"),
-                    null, EGRESS_IFACE, RouteInfo.RTN_THROW);
-            final List<RouteInfo> actualRoutes = lpCaptor.getValue().getRoutes();
-            assertTrue("Expected throw route (" + expectedRoute + ") not found in " + actualRoutes,
-                    actualRoutes.contains(expectedRoute));
-
-            assertTransportInfoMatches(ncCaptor.getValue(), VpnManager.TYPE_VPN_LEGACY);
-        } finally {
-            // Now interrupt the thread, unblock the runner and clean up.
-            vpn.mVpnRunner.exitVpnRunner();
-            deps.getStateFile().delete(); // set to delete on exit, but this deletes it earlier
-            vpn.mVpnRunner.join(10_000); // wait for up to 10s for the runner to die and cleanup
-        }
-    }
-
     // Make it public and un-final so as to spy it
     public class TestDeps extends Vpn.Dependencies {
         public final CompletableFuture<String[]> racoonArgs = new CompletableFuture();
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceCacheTest.kt b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceCacheTest.kt
index 2b3b834..3cea5cb 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceCacheTest.kt
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceCacheTest.kt
@@ -19,7 +19,10 @@
 import android.os.Build
 import android.os.Handler
 import android.os.HandlerThread
+import com.android.net.module.util.ArrayTrackRecord
 import com.android.server.connectivity.mdns.MdnsServiceCache.CacheKey
+import com.android.server.connectivity.mdns.MdnsServiceCacheTest.ExpiredRecord.ExpiredEvent.ServiceRecordExpired
+import com.android.server.connectivity.mdns.util.MdnsUtils
 import com.android.testutils.DevSdkIgnoreRule
 import com.android.testutils.DevSdkIgnoreRunner
 import java.util.concurrent.CompletableFuture
@@ -32,13 +35,19 @@
 import org.junit.Before
 import org.junit.Test
 import org.junit.runner.RunWith
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.mock
 
 private const val SERVICE_NAME_1 = "service-instance-1"
 private const val SERVICE_NAME_2 = "service-instance-2"
+private const val SERVICE_NAME_3 = "service-instance-3"
 private const val SERVICE_TYPE_1 = "_test1._tcp.local"
 private const val SERVICE_TYPE_2 = "_test2._tcp.local"
 private const val INTERFACE_INDEX = 999
 private const val DEFAULT_TIMEOUT_MS = 2000L
+private const val NO_CALLBACK_TIMEOUT_MS = 200L
+private const val TEST_ELAPSED_REALTIME_MS = 123L
+private const val DEFAULT_TTL_TIME_MS = 120000L
 
 @RunWith(DevSdkIgnoreRunner::class)
 @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.S_V2)
@@ -47,10 +56,46 @@
     private val cacheKey1 = CacheKey(SERVICE_TYPE_1, socketKey)
     private val cacheKey2 = CacheKey(SERVICE_TYPE_2, socketKey)
     private val thread = HandlerThread(MdnsServiceCacheTest::class.simpleName)
+    private val clock = mock(MdnsUtils.Clock::class.java)
     private val handler by lazy {
         Handler(thread.looper)
     }
 
+    private class ExpiredRecord : MdnsServiceCache.ServiceExpiredCallback {
+        val history = ArrayTrackRecord<ExpiredEvent>().newReadHead()
+
+        sealed class ExpiredEvent {
+            abstract val previousResponse: MdnsResponse
+            abstract val newResponse: MdnsResponse?
+            data class ServiceRecordExpired(
+                    override val previousResponse: MdnsResponse,
+                    override val newResponse: MdnsResponse?
+            ) : ExpiredEvent()
+        }
+
+        override fun onServiceRecordExpired(
+                previousResponse: MdnsResponse,
+                newResponse: MdnsResponse?
+        ) {
+            history.add(ServiceRecordExpired(previousResponse, newResponse))
+        }
+
+        fun expectedServiceRecordExpired(
+                serviceName: String,
+                timeoutMs: Long = DEFAULT_TIMEOUT_MS
+        ) {
+            val event = history.poll(timeoutMs)
+            assertNotNull(event)
+            assertTrue(event is ServiceRecordExpired)
+            assertEquals(serviceName, event.previousResponse.serviceInstanceName)
+        }
+
+        fun assertNoCallback() {
+            val cb = history.poll(NO_CALLBACK_TIMEOUT_MS)
+            assertNull("Expected no callback but got $cb", cb)
+        }
+    }
+
     @Before
     fun setUp() {
         thread.start()
@@ -89,19 +134,27 @@
     private fun getService(
             serviceCache: MdnsServiceCache,
             serviceName: String,
-            cacheKey: CacheKey,
+            cacheKey: CacheKey
     ): MdnsResponse? = runningOnHandlerAndReturn {
         serviceCache.getCachedService(serviceName, cacheKey)
     }
 
     private fun getServices(
             serviceCache: MdnsServiceCache,
-            cacheKey: CacheKey,
+            cacheKey: CacheKey
     ): List<MdnsResponse> = runningOnHandlerAndReturn { serviceCache.getCachedServices(cacheKey) }
 
+    private fun registerServiceExpiredCallback(
+            serviceCache: MdnsServiceCache,
+            cacheKey: CacheKey,
+            callback: MdnsServiceCache.ServiceExpiredCallback
+    ) = runningOnHandlerAndReturn {
+        serviceCache.registerServiceExpiredCallback(cacheKey, callback)
+    }
+
     @Test
     fun testAddAndRemoveService() {
-        val serviceCache = MdnsServiceCache(thread.looper, makeFlags())
+        val serviceCache = MdnsServiceCache(thread.looper, makeFlags(), clock)
         addOrUpdateService(serviceCache, cacheKey1, createResponse(SERVICE_NAME_1, SERVICE_TYPE_1))
         var response = getService(serviceCache, SERVICE_NAME_1, cacheKey1)
         assertNotNull(response)
@@ -113,7 +166,7 @@
 
     @Test
     fun testGetCachedServices_multipleServiceTypes() {
-        val serviceCache = MdnsServiceCache(thread.looper, makeFlags())
+        val serviceCache = MdnsServiceCache(thread.looper, makeFlags(), clock)
         addOrUpdateService(serviceCache, cacheKey1, createResponse(SERVICE_NAME_1, SERVICE_TYPE_1))
         addOrUpdateService(serviceCache, cacheKey1, createResponse(SERVICE_NAME_2, SERVICE_TYPE_1))
         addOrUpdateService(serviceCache, cacheKey2, createResponse(SERVICE_NAME_2, SERVICE_TYPE_2))
@@ -145,7 +198,127 @@
         })
     }
 
-    private fun createResponse(serviceInstanceName: String, serviceType: String) = MdnsResponse(
-            0 /* now */, "$serviceInstanceName.$serviceType".split(".").toTypedArray(),
-            socketKey.interfaceIndex, socketKey.network)
+    @Test
+    fun testServiceExpiredAndSendCallbacks() {
+        val serviceCache = MdnsServiceCache(
+                thread.looper, makeFlags(isExpiredServicesRemovalEnabled = true), clock)
+        // Register service expired callbacks
+        val callback1 = ExpiredRecord()
+        val callback2 = ExpiredRecord()
+        registerServiceExpiredCallback(serviceCache, cacheKey1, callback1)
+        registerServiceExpiredCallback(serviceCache, cacheKey2, callback2)
+
+        doReturn(TEST_ELAPSED_REALTIME_MS).`when`(clock).elapsedRealtime()
+
+        // Add multiple services with different ttl time.
+        addOrUpdateService(serviceCache, cacheKey1, createResponse(SERVICE_NAME_1, SERVICE_TYPE_1,
+                DEFAULT_TTL_TIME_MS))
+        addOrUpdateService(serviceCache, cacheKey1, createResponse(SERVICE_NAME_2, SERVICE_TYPE_1,
+                DEFAULT_TTL_TIME_MS + 20L))
+        addOrUpdateService(serviceCache, cacheKey2, createResponse(SERVICE_NAME_3, SERVICE_TYPE_2,
+                DEFAULT_TTL_TIME_MS + 10L))
+
+        // Check the service expiration immediately. Should be no callback.
+        assertEquals(2, getServices(serviceCache, cacheKey1).size)
+        assertEquals(1, getServices(serviceCache, cacheKey2).size)
+        callback1.assertNoCallback()
+        callback2.assertNoCallback()
+
+        // Simulate the case where the response is after TTL then check expired services.
+        // Expect SERVICE_NAME_1 expired.
+        doReturn(TEST_ELAPSED_REALTIME_MS + DEFAULT_TTL_TIME_MS).`when`(clock).elapsedRealtime()
+        assertEquals(1, getServices(serviceCache, cacheKey1).size)
+        assertEquals(1, getServices(serviceCache, cacheKey2).size)
+        callback1.expectedServiceRecordExpired(SERVICE_NAME_1)
+        callback2.assertNoCallback()
+
+        // Simulate the case where the response is after TTL then check expired services.
+        // Expect SERVICE_NAME_3 expired.
+        doReturn(TEST_ELAPSED_REALTIME_MS + DEFAULT_TTL_TIME_MS + 11L)
+                .`when`(clock).elapsedRealtime()
+        assertEquals(1, getServices(serviceCache, cacheKey1).size)
+        assertEquals(0, getServices(serviceCache, cacheKey2).size)
+        callback1.assertNoCallback()
+        callback2.expectedServiceRecordExpired(SERVICE_NAME_3)
+    }
+
+    @Test
+    fun testRemoveExpiredServiceWhenGetting() {
+        val serviceCache = MdnsServiceCache(
+                thread.looper, makeFlags(isExpiredServicesRemovalEnabled = true), clock)
+
+        doReturn(TEST_ELAPSED_REALTIME_MS).`when`(clock).elapsedRealtime()
+        addOrUpdateService(serviceCache, cacheKey1,
+                createResponse(SERVICE_NAME_1, SERVICE_TYPE_1, 1L /* ttlTime */))
+        doReturn(TEST_ELAPSED_REALTIME_MS + 2L).`when`(clock).elapsedRealtime()
+        assertNull(getService(serviceCache, SERVICE_NAME_1, cacheKey1))
+
+        addOrUpdateService(serviceCache, cacheKey2,
+                createResponse(SERVICE_NAME_2, SERVICE_TYPE_2, 3L /* ttlTime */))
+        doReturn(TEST_ELAPSED_REALTIME_MS + 4L).`when`(clock).elapsedRealtime()
+        assertEquals(0, getServices(serviceCache, cacheKey2).size)
+    }
+
+    @Test
+    fun testInsertResponseAndSortList() {
+        val responses = ArrayList<MdnsResponse>()
+        val response1 = createResponse(SERVICE_NAME_1, SERVICE_TYPE_1, 100L /* ttlTime */)
+        MdnsServiceCache.insertResponseAndSortList(responses, response1, TEST_ELAPSED_REALTIME_MS)
+        assertEquals(1, responses.size)
+        assertEquals(response1, responses[0])
+
+        val response2 = createResponse(SERVICE_NAME_2, SERVICE_TYPE_1, 50L /* ttlTime */)
+        MdnsServiceCache.insertResponseAndSortList(responses, response2, TEST_ELAPSED_REALTIME_MS)
+        assertEquals(2, responses.size)
+        assertEquals(response2, responses[0])
+        assertEquals(response1, responses[1])
+
+        val response3 = createResponse(SERVICE_NAME_3, SERVICE_TYPE_1, 75L /* ttlTime */)
+        MdnsServiceCache.insertResponseAndSortList(responses, response3, TEST_ELAPSED_REALTIME_MS)
+        assertEquals(3, responses.size)
+        assertEquals(response2, responses[0])
+        assertEquals(response3, responses[1])
+        assertEquals(response1, responses[2])
+
+        val response4 = createResponse("service-instance-4", SERVICE_TYPE_1, 125L /* ttlTime */)
+        MdnsServiceCache.insertResponseAndSortList(responses, response4, TEST_ELAPSED_REALTIME_MS)
+        assertEquals(4, responses.size)
+        assertEquals(response2, responses[0])
+        assertEquals(response3, responses[1])
+        assertEquals(response1, responses[2])
+        assertEquals(response4, responses[3])
+    }
+
+    private fun createResponse(
+            serviceInstanceName: String,
+            serviceType: String,
+            ttlTime: Long = 120000L
+    ): MdnsResponse {
+        val serviceName = "$serviceInstanceName.$serviceType".split(".").toTypedArray()
+        val response = MdnsResponse(
+                0 /* now */, "$serviceInstanceName.$serviceType".split(".").toTypedArray(),
+                socketKey.interfaceIndex, socketKey.network)
+
+        // Set PTR record
+        val pointerRecord = MdnsPointerRecord(
+                serviceType.split(".").toTypedArray(),
+                TEST_ELAPSED_REALTIME_MS /* receiptTimeMillis */,
+                false /* cacheFlush */,
+                ttlTime /* ttlMillis */,
+                serviceName)
+        response.addPointerRecord(pointerRecord)
+
+        // Set SRV record.
+        val serviceRecord = MdnsServiceRecord(
+                serviceName,
+                TEST_ELAPSED_REALTIME_MS /* receiptTimeMillis */,
+                false /* cacheFlush */,
+                ttlTime /* ttlMillis */,
+                0 /* servicePriority */,
+                0 /* serviceWeight */,
+                12345 /* port */,
+                arrayOf("hostname"))
+        response.serviceRecord = serviceRecord
+        return response
+    }
 }
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java
index ce154dd..26a3796 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java
@@ -194,7 +194,9 @@
         thread.start();
         handler = new Handler(thread.getLooper());
         serviceCache = new MdnsServiceCache(
-                thread.getLooper(), MdnsFeatureFlags.newBuilder().build());
+                thread.getLooper(),
+                MdnsFeatureFlags.newBuilder().setIsExpiredServicesRemovalEnabled(false).build(),
+                mockDecoderClock);
 
         doAnswer(inv -> {
             latestDelayMs = 0;
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSNetworkActivityTest.kt b/tests/unit/java/com/android/server/connectivityservice/CSNetworkActivityTest.kt
new file mode 100644
index 0000000..526ec9d
--- /dev/null
+++ b/tests/unit/java/com/android/server/connectivityservice/CSNetworkActivityTest.kt
@@ -0,0 +1,267 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server
+
+import android.net.ConnectivityManager
+import android.net.ConnectivityManager.ACTION_DATA_ACTIVITY_CHANGE
+import android.net.ConnectivityManager.EXTRA_DEVICE_TYPE
+import android.net.ConnectivityManager.EXTRA_IS_ACTIVE
+import android.net.ConnectivityManager.EXTRA_REALTIME_NS
+import android.net.LinkProperties
+import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.NET_CAPABILITY_IMS
+import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED
+import android.net.NetworkCapabilities.TRANSPORT_CELLULAR
+import android.net.NetworkCapabilities.TRANSPORT_WIFI
+import android.net.NetworkRequest
+import android.os.Build
+import android.os.ConditionVariable
+import android.telephony.DataConnectionRealTimeInfo.DC_POWER_STATE_HIGH
+import android.telephony.DataConnectionRealTimeInfo.DC_POWER_STATE_LOW
+import androidx.test.filters.SmallTest
+import com.android.net.module.util.BaseNetdUnsolicitedEventListener
+import com.android.server.CSTest.CSContext
+import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
+import com.android.testutils.DevSdkIgnoreRunner
+import com.android.testutils.RecorderCallback.CallbackEntry.Lost
+import com.android.testutils.TestableNetworkCallback
+import kotlin.test.assertNotNull
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentCaptor
+import org.mockito.ArgumentMatchers.eq
+import org.mockito.Mockito.anyInt
+import org.mockito.Mockito.anyLong
+import org.mockito.Mockito.inOrder
+import org.mockito.Mockito.never
+import org.mockito.Mockito.timeout
+import org.mockito.Mockito.verify
+
+private const val DATA_CELL_IFNAME = "rmnet_data"
+private const val IMS_CELL_IFNAME = "rmnet_ims"
+private const val WIFI_IFNAME = "wlan0"
+private const val TIMESTAMP = 1234L
+private const val NETWORK_ACTIVITY_NO_UID = -1
+private const val PACKAGE_UID = 123
+private const val TIMEOUT_MS = 250L
+
+@RunWith(DevSdkIgnoreRunner::class)
+@SmallTest
+@IgnoreUpTo(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+class CSNetworkActivityTest : CSTest() {
+
+    private fun getRegisteredNetdUnsolicitedEventListener(): BaseNetdUnsolicitedEventListener {
+        val captor = ArgumentCaptor.forClass(BaseNetdUnsolicitedEventListener::class.java)
+        verify(netd).registerUnsolicitedEventListener(captor.capture())
+        return captor.value
+    }
+
+    @Test
+    fun testInterfaceClassActivityChanged_NonDefaultNetwork() {
+        val netdUnsolicitedEventListener = getRegisteredNetdUnsolicitedEventListener()
+        val batteryStatsInorder = inOrder(batteryStats)
+
+        val cellNr = NetworkRequest.Builder()
+                .clearCapabilities()
+                .addTransportType(TRANSPORT_CELLULAR)
+                .addCapability(NET_CAPABILITY_INTERNET)
+                .build()
+        val cellCb = TestableNetworkCallback()
+        // Request cell network to keep cell network up
+        cm.requestNetwork(cellNr, cellCb)
+
+        val defaultCb = TestableNetworkCallback()
+        cm.registerDefaultNetworkCallback(defaultCb)
+
+        val cellNc = NetworkCapabilities.Builder()
+                .addTransportType(TRANSPORT_CELLULAR)
+                .addCapability(NET_CAPABILITY_INTERNET)
+                .addCapability(NET_CAPABILITY_NOT_SUSPENDED)
+                .addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+                .build()
+        val cellLp = LinkProperties().apply {
+            interfaceName = DATA_CELL_IFNAME
+        }
+        // Connect Cellular network
+        val cellAgent = Agent(nc = cellNc, lp = cellLp)
+        cellAgent.connect()
+        defaultCb.expectAvailableCallbacks(cellAgent.network, validated = false)
+
+        val wifiNc = NetworkCapabilities.Builder()
+                .addTransportType(TRANSPORT_WIFI)
+                .addCapability(NET_CAPABILITY_INTERNET)
+                .addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+                .build()
+        val wifiLp = LinkProperties().apply {
+            interfaceName = WIFI_IFNAME
+        }
+        // Connect Wi-Fi network, Wi-Fi network should be the default network.
+        val wifiAgent = Agent(nc = wifiNc, lp = wifiLp)
+        wifiAgent.connect()
+        defaultCb.expectAvailableCallbacks(wifiAgent.network, validated = false)
+        batteryStatsInorder.verify(batteryStats).noteWifiRadioPowerState(eq(DC_POWER_STATE_HIGH),
+                anyLong() /* timestampNs */, eq(NETWORK_ACTIVITY_NO_UID))
+
+        val onNetworkActiveCv = ConditionVariable()
+        val listener = ConnectivityManager.OnNetworkActiveListener { onNetworkActiveCv::open }
+        cm.addDefaultNetworkActiveListener(listener)
+
+        // Cellular network (non default network) goes to inactive state.
+        netdUnsolicitedEventListener.onInterfaceClassActivityChanged(false /* isActive */,
+                cellAgent.network.netId, TIMESTAMP, NETWORK_ACTIVITY_NO_UID)
+        // Non-default network activity change does not change default network activity
+        // But cellular radio power state is updated
+        assertFalse(onNetworkActiveCv.block(TIMEOUT_MS))
+        context.expectNoDataActivityBroadcast(0 /* timeoutMs */)
+        assertTrue(cm.isDefaultNetworkActive)
+        batteryStatsInorder.verify(batteryStats).noteMobileRadioPowerState(eq(DC_POWER_STATE_LOW),
+                anyLong() /* timestampNs */, eq(NETWORK_ACTIVITY_NO_UID))
+
+        // Cellular network (non default network) goes to active state.
+        netdUnsolicitedEventListener.onInterfaceClassActivityChanged(true /* isActive */,
+                cellAgent.network.netId, TIMESTAMP, PACKAGE_UID)
+        // Non-default network activity change does not change default network activity
+        // But cellular radio power state is updated
+        assertFalse(onNetworkActiveCv.block(TIMEOUT_MS))
+        context.expectNoDataActivityBroadcast(0 /* timeoutMs */)
+        assertTrue(cm.isDefaultNetworkActive)
+        batteryStatsInorder.verify(batteryStats).noteMobileRadioPowerState(eq(DC_POWER_STATE_HIGH),
+                anyLong() /* timestampNs */, eq(PACKAGE_UID))
+
+        cm.unregisterNetworkCallback(cellCb)
+        cm.unregisterNetworkCallback(defaultCb)
+        cm.removeDefaultNetworkActiveListener(listener)
+    }
+
+    @Test
+    fun testDataActivityTracking_MultiCellNetwork() {
+        val netdUnsolicitedEventListener = getRegisteredNetdUnsolicitedEventListener()
+        val batteryStatsInorder = inOrder(batteryStats)
+
+        val dataNetworkNc = NetworkCapabilities.Builder()
+                .addTransportType(TRANSPORT_CELLULAR)
+                .addCapability(NET_CAPABILITY_INTERNET)
+                .addCapability(NET_CAPABILITY_NOT_SUSPENDED)
+                .build()
+        val dataNetworkNr = NetworkRequest.Builder()
+                .clearCapabilities()
+                .addTransportType(TRANSPORT_CELLULAR)
+                .addCapability(NET_CAPABILITY_INTERNET)
+                .build()
+        val dataNetworkLp = LinkProperties().apply {
+            interfaceName = DATA_CELL_IFNAME
+        }
+        val dataNetworkCb = TestableNetworkCallback()
+        cm.requestNetwork(dataNetworkNr, dataNetworkCb)
+        val dataNetworkAgent = Agent(nc = dataNetworkNc, lp = dataNetworkLp)
+        val dataNetworkNetId = dataNetworkAgent.network.netId.toString()
+
+        val imsNetworkNc = NetworkCapabilities.Builder()
+                .addTransportType(TRANSPORT_CELLULAR)
+                .addCapability(NET_CAPABILITY_IMS)
+                .addCapability(NET_CAPABILITY_NOT_SUSPENDED)
+                .build()
+        val imsNetworkNr = NetworkRequest.Builder()
+                .clearCapabilities()
+                .addTransportType(TRANSPORT_CELLULAR)
+                .addCapability(NET_CAPABILITY_IMS)
+                .build()
+        val imsNetworkLp = LinkProperties().apply {
+            interfaceName = IMS_CELL_IFNAME
+        }
+        val imsNetworkCb = TestableNetworkCallback()
+        cm.requestNetwork(imsNetworkNr, imsNetworkCb)
+        val imsNetworkAgent = Agent(nc = imsNetworkNc, lp = imsNetworkLp)
+        val imsNetworkNetId = imsNetworkAgent.network.netId.toString()
+
+        dataNetworkAgent.connect()
+        dataNetworkCb.expectAvailableCallbacks(dataNetworkAgent.network, validated = false)
+
+        imsNetworkAgent.connect()
+        imsNetworkCb.expectAvailableCallbacks(imsNetworkAgent.network, validated = false)
+
+        // Both cell networks have idleTimers
+        verify(netd).idletimerAddInterface(eq(DATA_CELL_IFNAME), anyInt(), eq(dataNetworkNetId))
+        verify(netd).idletimerAddInterface(eq(IMS_CELL_IFNAME), anyInt(), eq(imsNetworkNetId))
+        verify(netd, never()).idletimerRemoveInterface(eq(DATA_CELL_IFNAME), anyInt(),
+                eq(dataNetworkNetId))
+        verify(netd, never()).idletimerRemoveInterface(eq(IMS_CELL_IFNAME), anyInt(),
+                eq(imsNetworkNetId))
+
+        // Both cell networks go to inactive state
+        netdUnsolicitedEventListener.onInterfaceClassActivityChanged(false /* isActive */,
+                imsNetworkAgent.network.netId, TIMESTAMP, NETWORK_ACTIVITY_NO_UID)
+        netdUnsolicitedEventListener.onInterfaceClassActivityChanged(false /* isActive */,
+                dataNetworkAgent.network.netId, TIMESTAMP, NETWORK_ACTIVITY_NO_UID)
+
+        // Data cell network goes to active state. This should update the cellular radio power state
+        netdUnsolicitedEventListener.onInterfaceClassActivityChanged(true /* isActive */,
+                dataNetworkAgent.network.netId, TIMESTAMP, PACKAGE_UID)
+        batteryStatsInorder.verify(batteryStats, timeout(TIMEOUT_MS)).noteMobileRadioPowerState(
+                eq(DC_POWER_STATE_HIGH), anyLong() /* timestampNs */, eq(PACKAGE_UID))
+        // Ims cell network goes to active state. But this should not update the cellular radio
+        // power state since cellular radio power state is already high
+        netdUnsolicitedEventListener.onInterfaceClassActivityChanged(true /* isActive */,
+                imsNetworkAgent.network.netId, TIMESTAMP, PACKAGE_UID)
+        waitForIdle()
+        batteryStatsInorder.verify(batteryStats, never()).noteMobileRadioPowerState(anyInt(),
+                anyLong() /* timestampNs */, anyInt())
+
+        // Data cell network goes to inactive state. But this should not update the cellular radio
+        // power state ims cell network is still active state
+        netdUnsolicitedEventListener.onInterfaceClassActivityChanged(false /* isActive */,
+                dataNetworkAgent.network.netId, TIMESTAMP, NETWORK_ACTIVITY_NO_UID)
+        waitForIdle()
+        batteryStatsInorder.verify(batteryStats, never()).noteMobileRadioPowerState(anyInt(),
+                anyLong() /* timestampNs */, anyInt())
+
+        // Ims cell network goes to inactive state.
+        // This should update the cellular radio power state
+        netdUnsolicitedEventListener.onInterfaceClassActivityChanged(false /* isActive */,
+                imsNetworkAgent.network.netId, TIMESTAMP, NETWORK_ACTIVITY_NO_UID)
+        batteryStatsInorder.verify(batteryStats, timeout(TIMEOUT_MS)).noteMobileRadioPowerState(
+                eq(DC_POWER_STATE_LOW), anyLong() /* timestampNs */, eq(NETWORK_ACTIVITY_NO_UID))
+
+        dataNetworkAgent.disconnect()
+        dataNetworkCb.expect<Lost>(dataNetworkAgent.network)
+        verify(netd).idletimerRemoveInterface(eq(DATA_CELL_IFNAME), anyInt(), eq(dataNetworkNetId))
+
+        imsNetworkAgent.disconnect()
+        imsNetworkCb.expect<Lost>(imsNetworkAgent.network)
+        verify(netd).idletimerRemoveInterface(eq(IMS_CELL_IFNAME), anyInt(), eq(imsNetworkNetId))
+
+        cm.unregisterNetworkCallback(dataNetworkCb)
+        cm.unregisterNetworkCallback(imsNetworkCb)
+    }
+}
+
+internal fun CSContext.expectDataActivityBroadcast(
+        deviceType: Int,
+        isActive: Boolean,
+        tsNanos: Long
+) {
+    assertNotNull(orderedBroadcastAsUserHistory.poll(BROADCAST_TIMEOUT_MS) {
+        intent -> intent.action.equals(ACTION_DATA_ACTIVITY_CHANGE) &&
+            intent.getIntExtra(EXTRA_DEVICE_TYPE, -1) == deviceType &&
+            intent.getBooleanExtra(EXTRA_IS_ACTIVE, !isActive) == isActive &&
+            intent.getLongExtra(EXTRA_REALTIME_NS, -1) == tsNanos
+    })
+}
diff --git a/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt b/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
index 0ccbfc3..f21a428 100644
--- a/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
@@ -43,6 +43,7 @@
 import android.net.PacProxyManager
 import android.net.networkstack.NetworkStackClientBase
 import android.os.BatteryStatsManager
+import android.os.Bundle
 import android.os.Handler
 import android.os.HandlerThread
 import android.os.UserHandle
@@ -54,6 +55,7 @@
 import com.android.internal.app.IBatteryStats
 import com.android.internal.util.test.BroadcastInterceptingContext
 import com.android.modules.utils.build.SdkLevel
+import com.android.net.module.util.ArrayTrackRecord
 import com.android.networkstack.apishim.common.UnsupportedApiLevelException
 import com.android.server.connectivity.AutomaticOnOffKeepaliveTracker
 import com.android.server.connectivity.CarrierPrivilegeAuthenticator
@@ -64,14 +66,16 @@
 import com.android.server.connectivity.ProxyTracker
 import com.android.testutils.visibleOnHandlerThread
 import com.android.testutils.waitForIdle
+import java.util.concurrent.Executors
+import kotlin.test.assertNull
+import kotlin.test.fail
 import org.mockito.AdditionalAnswers.delegatesTo
 import org.mockito.Mockito.doAnswer
 import org.mockito.Mockito.doReturn
 import org.mockito.Mockito.mock
-import java.util.concurrent.Executors
-import kotlin.test.fail
 
 internal const val HANDLER_TIMEOUT_MS = 2_000
+internal const val BROADCAST_TIMEOUT_MS = 3_000L
 internal const val TEST_PACKAGE_NAME = "com.android.test.package"
 internal const val WIFI_WOL_IFNAME = "test_wlan_wol"
 internal val LOCAL_IPV4_ADDRESS = InetAddresses.parseNumericAddress("192.0.2.1")
@@ -127,6 +131,7 @@
         it[ConnectivityFlags.NO_REMATCH_ALL_REQUESTS_ON_REGISTER] = true
         it[ConnectivityService.KEY_DESTROY_FROZEN_SOCKETS_VERSION] = true
         it[ConnectivityService.DELAY_DESTROY_FROZEN_SOCKETS_VERSION] = true
+        it[ConnectivityService.ALLOW_SYSUI_CONNECTIVITY_REPORTS] = true
     }
     fun enableFeature(f: String) = enabledFeatures.set(f, true)
     fun disableFeature(f: String) = enabledFeatures.set(f, false)
@@ -154,7 +159,8 @@
     val proxyTracker = ProxyTracker(context, mock<Handler>(), 16 /* EVENT_PROXY_HAS_CHANGED */)
     val alarmManager = makeMockAlarmManager()
     val systemConfigManager = makeMockSystemConfigManager()
-    val batteryManager = BatteryStatsManager(mock<IBatteryStats>())
+    val batteryStats = mock<IBatteryStats>()
+    val batteryManager = BatteryStatsManager(batteryStats)
     val telephonyManager = mock<TelephonyManager>().also {
         doReturn(true).`when`(it).isDataCapable()
     }
@@ -195,6 +201,8 @@
         // checking permissions.
         override fun isFeatureEnabled(context: Context?, name: String?) =
                 enabledFeatures[name] ?: fail("Unmocked feature $name, see CSTest.enabledFeatures")
+        override fun isFeatureNotChickenedOut(context: Context?, name: String?) =
+                enabledFeatures[name] ?: fail("Unmocked feature $name, see CSTest.enabledFeatures")
 
         // Mocked change IDs
         private val enabledChangeIds = ArraySet<Long>()
@@ -282,6 +290,26 @@
             Context.STATS_MANAGER -> null // Stats manager is final and can't be mocked
             else -> super.getSystemService(serviceName)
         }
+
+        internal val orderedBroadcastAsUserHistory = ArrayTrackRecord<Intent>().newReadHead()
+
+        fun expectNoDataActivityBroadcast(timeoutMs: Int) {
+            assertNull(orderedBroadcastAsUserHistory.poll(
+                    timeoutMs.toLong()) { intent -> true })
+        }
+
+        override fun sendOrderedBroadcastAsUser(
+                intent: Intent,
+                user: UserHandle,
+                receiverPermission: String?,
+                resultReceiver: BroadcastReceiver?,
+                scheduler: Handler?,
+                initialCode: Int,
+                initialData: String?,
+                initialExtras: Bundle?
+        ) {
+            orderedBroadcastAsUserHistory.add(intent)
+        }
     }
 
     // Utility methods for subclasses to use
diff --git a/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java b/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
index 92a5b64..7a4dfed 100644
--- a/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
+++ b/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
@@ -193,6 +193,7 @@
  * TODO: This test used to be really brittle because it used Easymock - it uses Mockito now, but
  * still uses the Easymock structure, which could be simplified.
  */
+@DevSdkIgnoreRunner.MonitorThreadLeak
 @RunWith(DevSdkIgnoreRunner.class)
 @SmallTest
 // NetworkStatsService is not updatable before T, so tests do not need to be backwards compatible
diff --git a/thread/framework/java/android/net/thread/PendingOperationalDataset.java b/thread/framework/java/android/net/thread/PendingOperationalDataset.java
index 9cfd0b8..c1351af 100644
--- a/thread/framework/java/android/net/thread/PendingOperationalDataset.java
+++ b/thread/framework/java/android/net/thread/PendingOperationalDataset.java
@@ -39,6 +39,7 @@
  * a given delay. This is typically used to deploy new network parameters (e.g. Network Key or
  * Channel) to all devices in the network.
  *
+ * @see ThreadNetworkController#scheduleMigration
  * @hide
  */
 @FlaggedApi(ThreadNetworkFlags.FLAG_THREAD_ENABLED)
@@ -76,7 +77,8 @@
      * @param pendingTimestamp the Pending Timestamp which represents the version of this Pending
      *     Dataset
      * @param delayTimer the delay after when {@code activeOpDataset} will be committed on this
-     *     device
+     *     device; use {@link Duration#ZERO} to tell the system to choose a reasonable value
+     *     automatically
      */
     public PendingOperationalDataset(
             @NonNull ActiveOperationalDataset activeOpDataset,
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkController.java b/thread/framework/java/android/net/thread/ThreadNetworkController.java
index ec39db4..5c5fda9 100644
--- a/thread/framework/java/android/net/thread/ThreadNetworkController.java
+++ b/thread/framework/java/android/net/thread/ThreadNetworkController.java
@@ -226,15 +226,17 @@
      * @param callback the callback which has been registered with {@link #registerStateCallback}
      * @throws IllegalArgumentException if {@code callback} hasn't been registered
      */
+    @RequiresPermission(permission.ACCESS_NETWORK_STATE)
     public void unregisterStateCallback(@NonNull StateCallback callback) {
         requireNonNull(callback, "callback cannot be null");
         synchronized (mStateCallbackMapLock) {
-            StateCallbackProxy callbackProxy = mStateCallbackMap.remove(callback);
+            StateCallbackProxy callbackProxy = mStateCallbackMap.get(callback);
             if (callbackProxy == null) {
                 throw new IllegalArgumentException("callback hasn't been registered");
             }
             try {
                 mControllerService.unregisterStateCallback(callbackProxy);
+                mStateCallbackMap.remove(callback);
             } catch (RemoteException e) {
                 e.rethrowFromSystemServer();
             }
@@ -334,15 +336,21 @@
      *     #registerOperationalDatasetCallback}
      * @throws IllegalArgumentException if {@code callback} hasn't been registered
      */
+    @RequiresPermission(
+            allOf = {
+                permission.ACCESS_NETWORK_STATE,
+                "android.permission.THREAD_NETWORK_PRIVILEGED"
+            })
     public void unregisterOperationalDatasetCallback(@NonNull OperationalDatasetCallback callback) {
         requireNonNull(callback, "callback cannot be null");
         synchronized (mOpDatasetCallbackMapLock) {
-            OperationalDatasetCallbackProxy callbackProxy = mOpDatasetCallbackMap.remove(callback);
+            OperationalDatasetCallbackProxy callbackProxy = mOpDatasetCallbackMap.get(callback);
             if (callbackProxy == null) {
                 throw new IllegalArgumentException("callback hasn't been registered");
             }
             try {
                 mControllerService.unregisterOperationalDatasetCallback(callbackProxy);
+                mOpDatasetCallbackMap.remove(callback);
             } catch (RemoteException e) {
                 e.rethrowFromSystemServer();
             }
diff --git a/thread/service/java/com/android/server/thread/InfraInterfaceController.java b/thread/service/java/com/android/server/thread/InfraInterfaceController.java
new file mode 100644
index 0000000..d7c49a0
--- /dev/null
+++ b/thread/service/java/com/android/server/thread/InfraInterfaceController.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.thread;
+
+import android.os.ParcelFileDescriptor;
+
+import java.io.IOException;
+
+/** Controller for the infrastructure network interface. */
+public class InfraInterfaceController {
+    private static final String TAG = "InfraIfController";
+
+    static {
+        System.loadLibrary("service-thread-jni");
+    }
+
+    /**
+     * Creates a socket on the infrastructure network interface for sending/receiving ICMPv6
+     * Neighbor Discovery messages.
+     *
+     * @param infraInterfaceName the infrastructure network interface name.
+     * @return an ICMPv6 socket file descriptor on the Infrastructure network interface.
+     * @throws IOException when fails to create the socket.
+     */
+    public static ParcelFileDescriptor createIcmp6Socket(String infraInterfaceName)
+            throws IOException {
+        return ParcelFileDescriptor.adoptFd(nativeCreateIcmp6Socket(infraInterfaceName));
+    }
+
+    private static native int nativeCreateIcmp6Socket(String interfaceName) throws IOException;
+}
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
index 6c9a775..33516aa 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
@@ -223,6 +223,7 @@
         return mOtDaemon;
     }
 
+    // TODO(b/309792480): restarts the OT daemon service
     private void onOtDaemonDied() {
         Log.w(TAG, "OT daemon became dead, clean up...");
         OperationReceiverWrapper.onOtDaemonDied();
@@ -418,12 +419,12 @@
     @Override
     public void registerStateCallback(IStateCallback stateCallback) throws RemoteException {
         enforceAllCallingPermissionsGranted(permission.ACCESS_NETWORK_STATE);
-
         mHandler.post(() -> mOtDaemonCallbackProxy.registerStateCallback(stateCallback));
     }
 
     @Override
     public void unregisterStateCallback(IStateCallback stateCallback) throws RemoteException {
+        enforceAllCallingPermissionsGranted(permission.ACCESS_NETWORK_STATE);
         mHandler.post(() -> mOtDaemonCallbackProxy.unregisterStateCallback(stateCallback));
     }
 
@@ -438,6 +439,8 @@
     @Override
     public void unregisterOperationalDatasetCallback(IOperationalDatasetCallback callback)
             throws RemoteException {
+        enforceAllCallingPermissionsGranted(
+                permission.ACCESS_NETWORK_STATE, PERMISSION_THREAD_NETWORK_PRIVILEGED);
         mHandler.post(() -> mOtDaemonCallbackProxy.unregisterDatasetCallback(callback));
     }
 
@@ -566,9 +569,15 @@
     private void handleDeviceRoleChanged(@DeviceRole int deviceRole) {
         if (ThreadNetworkController.isAttached(deviceRole)) {
             Log.d(TAG, "Attached to the Thread network");
+
+            // This is an idempotent method which can be called for multiple times when the device
+            // is already attached (e.g. going from Child to Router)
             registerThreadNetwork();
         } else {
             Log.d(TAG, "Detached from the Thread network");
+
+            // This is an idempotent method which can be called for multiple times when the device
+            // is already detached or stopped
             unregisterThreadNetwork();
         }
     }
@@ -625,7 +634,7 @@
         public void registerStateCallback(IStateCallback callback) {
             checkOnHandlerThread();
             if (mStateCallbacks.containsKey(callback)) {
-                return;
+                throw new IllegalStateException("Registering the same IStateCallback twice");
             }
 
             IBinder.DeathRecipient deathRecipient =
@@ -657,7 +666,8 @@
         public void registerDatasetCallback(IOperationalDatasetCallback callback) {
             checkOnHandlerThread();
             if (mOpDatasetCallbacks.containsKey(callback)) {
-                return;
+                throw new IllegalStateException(
+                        "Registering the same IOperationalDatasetCallback twice");
             }
 
             IBinder.DeathRecipient deathRecipient =
@@ -732,7 +742,7 @@
                 mActiveDataset = newActiveDataset;
             } catch (IllegalArgumentException e) {
                 // Is unlikely that OT will generate invalid Operational Dataset
-                Log.w(TAG, "Ignoring invalid Active Operational Dataset changes", e);
+                Log.wtf(TAG, "Invalid Active Operational Dataset from OpenThread", e);
             }
 
             PendingOperationalDataset newPendingDataset;
@@ -746,7 +756,8 @@
                 onPendingOperationalDatasetChanged(newPendingDataset, listenerId);
                 mPendingDataset = newPendingDataset;
             } catch (IllegalArgumentException e) {
-                Log.w(TAG, "Ignoring invalid Pending Operational Dataset changes", e);
+                // Is unlikely that OT will generate invalid Operational Dataset
+                Log.wtf(TAG, "Invalid Pending Operational Dataset from OpenThread", e);
             }
         }
 
diff --git a/thread/service/java/com/android/server/thread/TunInterfaceController.java b/thread/service/java/com/android/server/thread/TunInterfaceController.java
index ac65b11..7223b2a 100644
--- a/thread/service/java/com/android/server/thread/TunInterfaceController.java
+++ b/thread/service/java/com/android/server/thread/TunInterfaceController.java
@@ -16,6 +16,7 @@
 
 package com.android.server.thread;
 
+import android.annotation.Nullable;
 import android.net.LinkAddress;
 import android.net.util.SocketUtils;
 import android.os.ParcelFileDescriptor;
@@ -34,6 +35,7 @@
 /** Controller for virtual/tunnel network interfaces. */
 public class TunInterfaceController {
     private static final String TAG = "TunIfController";
+    private static final long INFINITE_LIFETIME = 0xffffffffL;
     static final int MTU = 1280;
 
     static {
@@ -76,6 +78,7 @@
     }
 
     /** Returns the FD of the tunnel interface. */
+    @Nullable
     public ParcelFileDescriptor getTunFd() {
         return mParcelTunFd;
     }
@@ -98,7 +101,7 @@
 
         if (address.getDeprecationTime() == LinkAddress.LIFETIME_PERMANENT
                 || address.getDeprecationTime() == LinkAddress.LIFETIME_UNKNOWN) {
-            validLifetimeSeconds = 0xffffffffL;
+            validLifetimeSeconds = INFINITE_LIFETIME;
         } else {
             validLifetimeSeconds =
                     Math.max(
@@ -108,7 +111,7 @@
 
         if (address.getExpirationTime() == LinkAddress.LIFETIME_PERMANENT
                 || address.getExpirationTime() == LinkAddress.LIFETIME_UNKNOWN) {
-            preferredLifetimeSeconds = 0xffffffffL;
+            preferredLifetimeSeconds = INFINITE_LIFETIME;
         } else {
             preferredLifetimeSeconds =
                     Math.max(
diff --git a/thread/service/jni/com_android_server_thread_InfraInterfaceController.cpp b/thread/service/jni/com_android_server_thread_InfraInterfaceController.cpp
new file mode 100644
index 0000000..5d24eab
--- /dev/null
+++ b/thread/service/jni/com_android_server_thread_InfraInterfaceController.cpp
@@ -0,0 +1,141 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#define LOG_TAG "jniThreadInfra"
+
+#include <arpa/inet.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <ifaddrs.h>
+#include <inttypes.h>
+#include <linux/if_arp.h>
+#include <linux/ioctl.h>
+#include <log/log.h>
+#include <net/if.h>
+#include <netdb.h>
+#include <netinet/icmp6.h>
+#include <netinet/in.h>
+#include <private/android_filesystem_config.h>
+#include <signal.h>
+#include <spawn.h>
+#include <sys/ioctl.h>
+#include <sys/types.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+#include "jni.h"
+#include "nativehelper/JNIHelp.h"
+#include "nativehelper/scoped_utf_chars.h"
+
+namespace android {
+static jint
+com_android_server_thread_InfraInterfaceController_createIcmp6Socket(JNIEnv *env, jobject clazz,
+                                                                     jstring interfaceName) {
+  ScopedUtfChars ifName(env, interfaceName);
+
+  struct icmp6_filter filter;
+  constexpr int kEnable = 1;
+  constexpr int kIpv6ChecksumOffset = 2;
+  constexpr int kHopLimit = 255;
+
+  // Initializes the ICMPv6 socket.
+  int sock = socket(AF_INET6, SOCK_RAW, IPPROTO_ICMPV6);
+  if (sock == -1) {
+    jniThrowExceptionFmt(env, "java/io/IOException", "failed to create the socket (%s)",
+                         strerror(errno));
+    return -1;
+  }
+
+  // Only accept Router Advertisements, Router Solicitations and Neighbor
+  // Advertisements.
+  ICMP6_FILTER_SETBLOCKALL(&filter);
+  ICMP6_FILTER_SETPASS(ND_ROUTER_SOLICIT, &filter);
+  ICMP6_FILTER_SETPASS(ND_ROUTER_ADVERT, &filter);
+  ICMP6_FILTER_SETPASS(ND_NEIGHBOR_ADVERT, &filter);
+
+  if (setsockopt(sock, IPPROTO_ICMPV6, ICMP6_FILTER, &filter, sizeof(filter)) != 0) {
+    jniThrowExceptionFmt(env, "java/io/IOException", "failed to setsockopt ICMP6_FILTER (%s)",
+                         strerror(errno));
+    close(sock);
+    return -1;
+  }
+
+  // We want a source address and interface index.
+
+  if (setsockopt(sock, IPPROTO_IPV6, IPV6_RECVPKTINFO, &kEnable, sizeof(kEnable)) != 0) {
+    jniThrowExceptionFmt(env, "java/io/IOException", "failed to setsockopt IPV6_RECVPKTINFO (%s)",
+                         strerror(errno));
+    close(sock);
+    return -1;
+  }
+
+  if (setsockopt(sock, IPPROTO_RAW, IPV6_CHECKSUM, &kIpv6ChecksumOffset,
+                 sizeof(kIpv6ChecksumOffset)) != 0) {
+    jniThrowExceptionFmt(env, "java/io/IOException", "failed to setsockopt IPV6_CHECKSUM (%s)",
+                         strerror(errno));
+    close(sock);
+    return -1;
+  }
+
+  // We need to be able to reject RAs arriving from off-link.
+  if (setsockopt(sock, IPPROTO_IPV6, IPV6_RECVHOPLIMIT, &kEnable, sizeof(kEnable)) != 0) {
+    jniThrowExceptionFmt(env, "java/io/IOException", "failed to setsockopt IPV6_RECVHOPLIMIT (%s)",
+                         strerror(errno));
+    close(sock);
+    return -1;
+  }
+
+  if (setsockopt(sock, IPPROTO_IPV6, IPV6_UNICAST_HOPS, &kHopLimit, sizeof(kHopLimit)) != 0) {
+    jniThrowExceptionFmt(env, "java/io/IOException", "failed to setsockopt IPV6_UNICAST_HOPS (%s)",
+                         strerror(errno));
+    close(sock);
+    return -1;
+  }
+
+  if (setsockopt(sock, IPPROTO_IPV6, IPV6_MULTICAST_HOPS, &kHopLimit, sizeof(kHopLimit)) != 0) {
+    jniThrowExceptionFmt(env, "java/io/IOException",
+                         "failed to create the setsockopt IPV6_MULTICAST_HOPS (%s)",
+                         strerror(errno));
+    close(sock);
+    return -1;
+  }
+
+  if (setsockopt(sock, SOL_SOCKET, SO_BINDTODEVICE, ifName.c_str(), strlen(ifName.c_str()))) {
+    jniThrowExceptionFmt(env, "java/io/IOException", "failed to setsockopt SO_BINDTODEVICE (%s)",
+                         strerror(errno));
+    close(sock);
+    return -1;
+  }
+
+  return sock;
+}
+
+/*
+ * JNI registration.
+ */
+
+static const JNINativeMethod gMethods[] = {
+    /* name, signature, funcPtr */
+    {"nativeCreateIcmp6Socket", "(Ljava/lang/String;)I",
+     (void *)com_android_server_thread_InfraInterfaceController_createIcmp6Socket},
+};
+
+int register_com_android_server_thread_InfraInterfaceController(JNIEnv *env) {
+  return jniRegisterNativeMethods(env, "com/android/server/thread/InfraInterfaceController",
+                                  gMethods, NELEM(gMethods));
+}
+
+}; // namespace android
diff --git a/thread/service/jni/com_android_server_thread_TunInterfaceController.cpp b/thread/service/jni/com_android_server_thread_TunInterfaceController.cpp
index ed39fab..c56bc0b 100644
--- a/thread/service/jni/com_android_server_thread_TunInterfaceController.cpp
+++ b/thread/service/jni/com_android_server_thread_TunInterfaceController.cpp
@@ -53,25 +53,25 @@
     strlcpy(ifr.ifr_name, ifName.c_str(), sizeof(ifr.ifr_name));
 
     if (ioctl(fd, TUNSETIFF, &ifr, sizeof(ifr)) != 0) {
-        close(fd);
         jniThrowExceptionFmt(env, "java/io/IOException", "ioctl(TUNSETIFF) failed (%s)",
                              strerror(errno));
+        close(fd);
         return -1;
     }
 
     int inet6 = socket(AF_INET6, SOCK_DGRAM | SOCK_CLOEXEC | SOCK_NONBLOCK, IPPROTO_IP);
     if (inet6 == -1) {
-        close(fd);
         jniThrowExceptionFmt(env, "java/io/IOException", "create inet6 socket failed (%s)",
                              strerror(errno));
+        close(fd);
         return -1;
     }
     ifr.ifr_mtu = mtu;
     if (ioctl(inet6, SIOCSIFMTU, &ifr) != 0) {
-        close(fd);
-        close(inet6);
         jniThrowExceptionFmt(env, "java/io/IOException", "ioctl(SIOCSIFMTU) failed (%s)",
                              strerror(errno));
+        close(fd);
+        close(inet6);
         return -1;
     }
 
@@ -94,7 +94,6 @@
     }
 
     if (ioctl(inet6, SIOCSIFFLAGS, &ifr) != 0) {
-        close(inet6);
         jniThrowExceptionFmt(env, "java/io/IOException", "ioctl(SIOCSIFFLAGS) failed (%s)",
                              strerror(errno));
     }
diff --git a/thread/service/jni/onload.cpp b/thread/service/jni/onload.cpp
index 5081664..66add74 100644
--- a/thread/service/jni/onload.cpp
+++ b/thread/service/jni/onload.cpp
@@ -19,6 +19,7 @@
 
 namespace android {
 int register_com_android_server_thread_TunInterfaceController(JNIEnv* env);
+int register_com_android_server_thread_InfraInterfaceController(JNIEnv* env);
 }
 
 using namespace android;
@@ -33,5 +34,6 @@
     ALOG_ASSERT(env != NULL, "Could not retrieve the env!");
 
     register_com_android_server_thread_TunInterfaceController(env);
+    register_com_android_server_thread_InfraInterfaceController(env);
     return JNI_VERSION_1_4;
 }
diff --git a/thread/tests/cts/Android.bp b/thread/tests/cts/Android.bp
index b75b8e6..6862398 100644
--- a/thread/tests/cts/Android.bp
+++ b/thread/tests/cts/Android.bp
@@ -37,6 +37,7 @@
         "androidx.test.ext.junit",
         "compatibility-device-util-axt",
         "ctstestrunner-axt",
+        "guava",
         "guava-android-testlib",
         "net-tests-utils",
         "truth",
diff --git a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
index e17dd02..362ff39 100644
--- a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
+++ b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
@@ -101,7 +101,7 @@
     public void tearDown() throws Exception {
         if (mManager != null) {
             leaveAndWait();
-            dropPermissions();
+            dropAllPermissions();
         }
     }
 
@@ -128,7 +128,7 @@
         getInstrumentation().getUiAutomation().adoptShellPermissionIdentity(allPermissions);
     }
 
-    private static void dropPermissions() {
+    private static void dropAllPermissions() {
         getInstrumentation().getUiAutomation().dropShellPermissionIdentity();
     }
 
@@ -217,16 +217,21 @@
 
         for (ThreadNetworkController controller : getAllControllers()) {
             SettableFuture<Integer> deviceRole = SettableFuture.create();
+            StateCallback callback = deviceRole::set;
 
-            controller.registerStateCallback(mExecutor, role -> deviceRole.set(role));
+            try {
+                controller.registerStateCallback(mExecutor, callback);
 
-            assertThat(deviceRole.get()).isEqualTo(DEVICE_ROLE_STOPPED);
+                assertThat(deviceRole.get()).isEqualTo(DEVICE_ROLE_STOPPED);
+            } finally {
+                controller.unregisterStateCallback(callback);
+            }
         }
     }
 
     @Test
     public void registerStateCallback_noPermissions_throwsSecurityException() throws Exception {
-        dropPermissions();
+        dropAllPermissions();
 
         for (ThreadNetworkController controller : getAllControllers()) {
             assertThrows(
@@ -252,6 +257,26 @@
     }
 
     @Test
+    public void unregisterStateCallback_noPermissions_throwsSecurityException() throws Exception {
+        for (ThreadNetworkController controller : getAllControllers()) {
+            SettableFuture<Integer> deviceRole = SettableFuture.create();
+            StateCallback callback = role -> deviceRole.set(role);
+            grantPermissions(permission.ACCESS_NETWORK_STATE);
+            controller.registerStateCallback(mExecutor, callback);
+
+            try {
+                dropAllPermissions();
+                assertThrows(
+                        SecurityException.class,
+                        () -> controller.unregisterStateCallback(callback));
+            } finally {
+                grantPermissions(permission.ACCESS_NETWORK_STATE);
+                controller.unregisterStateCallback(callback);
+            }
+        }
+    }
+
+    @Test
     public void unregisterStateCallback_callbackRegistered_success() throws Exception {
         grantPermissions(permission.ACCESS_NETWORK_STATE);
         for (ThreadNetworkController controller : getAllControllers()) {
@@ -282,7 +307,7 @@
         grantPermissions(permission.ACCESS_NETWORK_STATE);
         for (ThreadNetworkController controller : getAllControllers()) {
             SettableFuture<Integer> deviceRole = SettableFuture.create();
-            StateCallback callback = role -> deviceRole.set(role);
+            StateCallback callback = deviceRole::set;
             controller.registerStateCallback(mExecutor, callback);
             controller.unregisterStateCallback(callback);
 
@@ -300,12 +325,70 @@
         for (ThreadNetworkController controller : getAllControllers()) {
             SettableFuture<ActiveOperationalDataset> activeFuture = SettableFuture.create();
             SettableFuture<PendingOperationalDataset> pendingFuture = SettableFuture.create();
+            var callback = newDatasetCallback(activeFuture, pendingFuture);
 
-            controller.registerOperationalDatasetCallback(
-                    mExecutor, newDatasetCallback(activeFuture, pendingFuture));
+            try {
+                controller.registerOperationalDatasetCallback(mExecutor, callback);
 
-            assertThat(activeFuture.get()).isNull();
-            assertThat(pendingFuture.get()).isNull();
+                assertThat(activeFuture.get()).isNull();
+                assertThat(pendingFuture.get()).isNull();
+            } finally {
+                controller.unregisterOperationalDatasetCallback(callback);
+            }
+        }
+    }
+
+    @Test
+    public void registerOperationalDatasetCallback_noPermissions_throwsSecurityException()
+            throws Exception {
+        dropAllPermissions();
+
+        for (ThreadNetworkController controller : getAllControllers()) {
+            SettableFuture<ActiveOperationalDataset> activeFuture = SettableFuture.create();
+            SettableFuture<PendingOperationalDataset> pendingFuture = SettableFuture.create();
+            var callback = newDatasetCallback(activeFuture, pendingFuture);
+
+            assertThrows(
+                    SecurityException.class,
+                    () -> controller.registerOperationalDatasetCallback(mExecutor, callback));
+        }
+    }
+
+    @Test
+    public void unregisterOperationalDatasetCallback_callbackRegistered_success() throws Exception {
+        grantPermissions(permission.ACCESS_NETWORK_STATE, PERMISSION_THREAD_NETWORK_PRIVILEGED);
+        for (ThreadNetworkController controller : getAllControllers()) {
+            SettableFuture<ActiveOperationalDataset> activeFuture = SettableFuture.create();
+            SettableFuture<PendingOperationalDataset> pendingFuture = SettableFuture.create();
+            var callback = newDatasetCallback(activeFuture, pendingFuture);
+            controller.registerOperationalDatasetCallback(mExecutor, callback);
+
+            controller.unregisterOperationalDatasetCallback(callback);
+        }
+    }
+
+    @Test
+    public void unregisterOperationalDatasetCallback_noPermissions_throwsSecurityException()
+            throws Exception {
+        dropAllPermissions();
+
+        for (ThreadNetworkController controller : getAllControllers()) {
+            SettableFuture<ActiveOperationalDataset> activeFuture = SettableFuture.create();
+            SettableFuture<PendingOperationalDataset> pendingFuture = SettableFuture.create();
+            var callback = newDatasetCallback(activeFuture, pendingFuture);
+            grantPermissions(permission.ACCESS_NETWORK_STATE, PERMISSION_THREAD_NETWORK_PRIVILEGED);
+            controller.registerOperationalDatasetCallback(mExecutor, callback);
+
+            try {
+                dropAllPermissions();
+                assertThrows(
+                        SecurityException.class,
+                        () -> controller.unregisterOperationalDatasetCallback(callback));
+            } finally {
+                grantPermissions(
+                        permission.ACCESS_NETWORK_STATE, PERMISSION_THREAD_NETWORK_PRIVILEGED);
+                controller.unregisterOperationalDatasetCallback(callback);
+            }
         }
     }
 
@@ -343,7 +426,7 @@
 
     @Test
     public void join_withoutPrivilegedPermission_throwsSecurityException() throws Exception {
-        dropPermissions();
+        dropAllPermissions();
 
         for (ThreadNetworkController controller : getAllControllers()) {
             ActiveOperationalDataset activeDataset = newRandomizedDataset("TestNet", controller);
@@ -408,7 +491,7 @@
 
     @Test
     public void leave_withoutPrivilegedPermission_throwsSecurityException() {
-        dropPermissions();
+        dropAllPermissions();
 
         for (ThreadNetworkController controller : getAllControllers()) {
             assertThrows(SecurityException.class, () -> controller.leave(mExecutor, v -> {}));
diff --git a/thread/tests/unit/Android.bp b/thread/tests/unit/Android.bp
index 3a087c7..5863673 100644
--- a/thread/tests/unit/Android.bp
+++ b/thread/tests/unit/Android.bp
@@ -36,6 +36,7 @@
         "ctstestrunner-axt",
         "framework-connectivity-pre-jarjar",
         "framework-connectivity-t-pre-jarjar",
+        "guava",
         "guava-android-testlib",
         "net-tests-utils",
         "truth",